Go EP7: Goroutines Are Stackful
Each time we launch a goroutine with go doSomething(), we're immediately reserving 2KB of memory (this was 4KB in Go 1.2 and increased to 8KB by Go 1.4).
Let’s look back at last week’s focus:
Take 1: Keep contexts going with context.WithoutCancel()
Take 2: Loop labels for cleaner breaks and continues
Take 3: Scheduling functions after context cancellation with context.AfterFunc
Take 4: Just… don’t panic()
This week’s topics:
Take 1: Goroutines Are Stackful
Take 2: Lead with context, end with options, and always close with an error
Take 3: Prefer strconv over fmt for converting to/from strings.
Take 4: Naming unexported global variables with an underscore (_) prefix.
Take 1: Goroutines Are Stackful
Goroutines are stackful, they come with their own stack, which starts small but not tiny.
Each time we launch a goroutine with go doSomething()
, we’re immediately reserving 2KB of memory (this was 4KB in Go 1.2 and increased to 8KB by Go 1.4).
So, the disadvantage is, when your Go program handles a lot of things at once, its memory usage can shoot up quicker compared to languages without such stack allocations.
This initial size is a starting point, and the Go runtime automatically adjusts the stack size of goroutines during their execution to accommodate the memory requirements of each goroutine’s workload.
The process works like this:
If a goroutine’s stack fills up, the Go system allocates a bigger stack.
It then transfers the contents from the old stack to the new one and the goroutine continues its work with more space.
This ensures that each goroutine has enough memory to operate efficiently without manual intervention.
Take 2: Lead with context, end with options, and always close with an error
Writing Go in an idiomatic way often involves adhering to certain patterns and best practices that lend predictability to your code. Here are 3 key guidelines for designing function signatures:
1. context.Context goes first
The context.Context parameter should always lead in your function signatures.
“Why?”
A context is often tied to the lifecycle of a request or operation.
When scanning the code, seeing a context.Context as the first parameter signals immediately that the function is aware of:
Cancellation
Deadlines
Other context-related mechanisms.
This consistency aids in readability and makes the codebase easier to navigate.
Also, don’t put the context.Context
in a struct.
The context is inherently meant to be transient and to flow through the program rather than being a part of an object’s state .
(There are exceptions, e.g. Handler of HTTP, it’s idiomatic to extract the context from the request since the context is already associated with the lifecycle of the request.)
2. Options struct trails behind
We’ve talked about the “options struct” pattern and variadic options in Go EP4.
This pattern is a good way to deal with functions that might change as time goes on without causing issues for what’s already there:
The place where we put arguments can show us how key those arguments are. When we put this struct at the end of the function, it does a couple of things for us:
It maintains consistency (with the variadic options pattern).
It shows that these settings are extras, not essential parts of how the function works.
3. Conclude with an error (or bool)
Go’s idiomatic way of signaling success or failure of an operation is through its last return value, conventionally an error.
In cases where a boolean is more appropriate, such as checking for existence, it still comes last.
If both exist, the priority should be (x, bool, error).
In a case like TryFetchData, a bool is used to indicate existence, particularly when non-existence is not considered an error.
Take 3: Prefer strconv over fmt for converting to/from strings.
When we need to change numbers to strings, choosing the right tool can make things faster.
The strconv package is made just for this job, and when it comes to making things run better and saving memory, every bit helps.
Let’s look at a simple benchmark:
These benchmarks show there’s a big difference in how fast they work.
(I can’t be sure about any tweaks the compiler might make, but the situation for both is the same.)
strconv functions are made for certain kinds of changes, so they can do the job quicker than the more general-purpose fmt functions.
The fmt.Sprint function and its similar ones need to use reflection to get what type they’re dealing with and figure out how to turn it into a string:
This reflection step isn’t free; it takes more time and uses more memory.
Take 4: Naming Unexported Global Variables with an Underscore (_) Prefix.
(Note: This convention is not universally considered idiomatic in the Go community; it’s inspired by the Uber Style Guide.)
In Go, top-level variables and constants are accessible throughout the entire package in which they are declared.
What’s the problem with the usual way?
Without a clear naming convention, it’s easy to unintentionally shadow these package-wide variables in a smaller scope:
Consider how a local variable named dataSize might shadow a global variable with the same name.
“But what’s the issue if they have different names?”
The example might seem simple, but it still raises the question: How do you know where maxUsers comes from?
Is it a local variable like ‘limit’?
An argument of the function?
From a global scope?
In more complex scenarios, we might have to search around or use cmd + click (in an IDE) to find and jump to the definition.
This process can be distracting and interrupt our workflow.
With an Underscore Prefix
By prefixing these global symbols with an underscore (_), it signals to us that these identifiers are global:
This clarification ensures that ‘_maxUsers’ is recognized as a global variable, significantly reducing the risk of unintentionally shadowing or modifying it.