Index
---
comments: true
description: Summary of the mistakes in the 100 Go Mistakes book.
status: new
---
Common Go Mistakes
???+ tip "The Coder Cafe"
Enjoyed my book? You will enjoy my newsletter too.
> AI is getting better every day. Are you? At The Coder Cafe, we serve fundamental concepts to make you an engineer that AI won't replace. Written by a Google SWE, trusted by thousands of engineers worldwide.
<center><a href="https://thecoder.cafe?rd=100go.co"><img src="../img/thecodercafe.png" alt="" style="width:480px;height:240px;"></a></center>
This page is a summary of the mistakes in the 100 Go Mistakes and How to Avoid Them book. Meanwhile, it's also open to the community. If you believe that a common Go mistake should be added, please create an issue.
???+ warning "Beta"
You're viewing a beta version enriched with significantly more content. However, this version is not yet complete, and I'm looking for volunteers to help me summarize the remaining mistakes (GitHub issue #43).
Progress:
<progress value="81" max="100"/>
Code and Project Organization
Unintended variable shadowing (#1)
???+ info "TL;DR"
Avoiding shadowed variables can help prevent mistakes like referencing the wrong variable or confusing readers.
Variable shadowing occurs when a variable name is redeclared in an inner block, but this practice is prone to mistakes. Imposing a rule to forbid shadowed variables depends on personal taste. For example, sometimes it can be convenient to reuse an existing variable name like err for errors. Yet, in general, we should remain cautious because we now know that we can face a scenario where the code compiles, but the variable that receives the value is not the one expected.
Unnecessary nested code (#2)
???+ info "TL;DR"
Avoiding nested levels and keeping the happy path aligned on the left makes building a mental code model easier.
In general, the more nested levels a function requires, the more complex it is to read and understand. Let’s see some different applications of this rule to optimize our code for readability:
* When an if block returns, we should omit the else block in all cases. For example, we shouldn’t write:
if foo() {
// ...
return true
} else {
// ...
}Instead, we omit the else block like this:
if foo() {
// ...
return true
}
// ...* We can also follow this logic with a non-happy path:
if s != "" {
// ...
} else {
return errors.New("empty string")
} Here, an empty s represents the non-happy path. Hence, we should flip the
condition like so:
if s == "" {
return errors.New("empty string")
}
// ...Writing readable code is an important challenge for every developer. Striving to reduce the number of nested blocks, aligning the happy path on the left, and returning as early as possible are concrete means to improve our code’s readability.
Misusing init functions (#3)
???+ info "TL;DR"
When initializing variables, remember that init functions have limited error handling and make state handling and testing more complex. In most cases, initializations should be handled as specific functions.
An init function is a function used to initialize the state of an application. It takes no arguments and returns no result (a func() function). When a package is initialized, all the constant and variable declarations in the package are evaluated. Then, the init functions are executed.
Init functions can lead to some issues:
* They can limit error management.
* They can complicate how to implement tests (for example, an external dependency must be set up, which may not be necessary for the scope of unit tests).
* If the initialization requires us to set a state, that has to be done through global variables.
We should be cautious with init functions. They can be helpful in some situations, however, such as defining static configuration. Otherwise, and in most cases, we should handle initializations through ad hoc functions.
Overusing getters and setters (#4)
???+ info "TL;DR"
Forcing the use of getters and setters isn’t idiomatic in Go. Being pragmatic and finding the right balance between efficiency and blindly following certain idioms should be the way to go.
Data encapsulation refers to hiding the values or state of an object. Getters and setters are means to enable encapsulation by providing exported methods on top of unexported object fields.
In Go, there is no automatic support for getters and setters as we see in some languages. It is also considered neither mandatory nor idiomatic to use getters and setters to access struct fields. We shouldn’t overwhelm our code with getters and setters on structs if they don’t bring any value. We should be pragmatic and strive to find the right balance between efficiency and following idioms that are sometimes considered indisputable in other programming paradigms.
Remember that Go is a unique language designed for many characteristics, including simplicity. However, if we find a need for getters and setters or, as mentioned, foresee a future need while guaranteeing forward compatibility, there’s nothing wrong with using them.
Interface pollution (#5)
???+ info "TL;DR"
Abstractions should be discovered, not created. To prevent unnecessary complexity, create an interface when you need it and not when you foresee needing it, or if you can at least prove the abstraction to be a valid one.
Read the full section here.
Interface on the producer side (#6)
???+ info "TL;DR"
Keeping interfaces on the client side avoids unnecessary abstractions.
Interfaces are satisfied implicitly in Go, which tends to be a gamechanger compared to languages with an explicit implementation. In most cases, the approach to follow is similar to what we described in the previous section: _abstractions should be discovered, not created_. This means that it’s not up to the producer to force a given abstraction for all the clients. Instead, it’s up to the client to decide whether it needs some form of abstraction and then determine the best abstraction level for its needs.
An interface should live on the consumer side in most cases. However, in particular contexts (for example, when we know—not foresee—that an abstraction will be helpful for consumers), we may want to have it on the producer side. If we do, we should strive to keep it as minimal as possible, increasing its reusability potential and making it more easily composable.
Returning interfaces (#7)
???+ info "TL;DR"
To prevent being restricted in terms of flexibility, a function shouldn’t return interfaces but concrete implementations in most cases. Conversely, a function should accept interfaces whenever possible.
In most cases, we shouldn’t return interfaces but concrete implementations. Otherwise, it can make our design more complex due to package dependencies and can restrict flexibility because all the clients would have to rely on the same abstraction. Again, the conclusion is similar to the previous sections: if we know (not foresee) that an abstraction will be helpful for clients, we can consider returning an interface. Otherwise, we shouldn’t force abstractions; they should be discovered by clients. If a client needs to abstract an implementation for whatever reason, it can still do that on the client’s side.
any says nothing (#8)
???+ info "TL;DR"
Only use any if you need to accept or return any possible type, such as json.Marshal. Otherwise, any doesn’t provide meaningful information and can lead to compile-time issues by allowing a caller to call methods with any data type.
The any type can be helpful if there is a genuine need for accepting or returning any possible type (for instance, when it comes to marshaling or formatting). In general, we should avoid overgeneralizing the code we write at all costs. Perhaps a little bit of duplicated code might occasionally be better if it improves other aspects such as code expressiveness.
Being confused about when to use generics (#9)
???+ info "TL;DR"
Relying on generics and type parameters can prevent writing boilerplate code to factor out elements or behaviors. However, do not use type parameters prematurely, but only when you see a concrete need for them. Otherwise, they introduce unnecessary abstractions and complexity.
Read the full section here.
Not being aware of the possible problems with type embedding (#10)
???+ info "TL;DR"
Using type embedding can also help avoid boilerplate code; however, ensure that doing so doesn’t lead to visibility issues where some fields should have remained hidden.
When creating a struct, Go offers the option to embed types. But this can sometimes lead to unexpected behaviors if we don’t understand all the implications of type embedding. Throughout this section, we look at how to embed types, what these bring, and the possible issues.
In Go, a struct field is called embedded if it’s declared without a name. For example,
type Foo struct {
Bar // Embedded field
}type Bar struct {
Baz int
}
In the Foo struct, the Bar type is declared without an associated name; hence, it’s an embedded field.
We use embedding to promote the fields and methods of an embedded type. Because Bar contains a Baz field, this field is
promoted to Foo. Therefore, Baz becomes available from Foo.
What can we say about type embedding? First, let’s note that it’s rarely a necessity, and it means that whatever the use case, we can probably solve it as well without type embedding. Type embedding is mainly used for convenience: in most cases, to promote behaviors.
If we decide to use type embedding, we need to keep two main constraints in mind:
* It shouldn’t be used solely as some syntactic sugar to simplify accessing a field (such as Foo.Baz() instead of Foo.Bar.Baz()). If this is the only rationale, let’s not embed the inner type and use a field instead.
* It shouldn’t promote data (fields) or a behavior (methods) we want to hide from the outside: for example, if it allows clients to access a locking behavior that should remain private to the struct.
Using type embedding consciously by keeping these constraints in mind can help avoid boilerplate code with additional forwarding methods. However, let’s make sure we don’t do it solely for cosmetics and not promote elements that should remain hidden.
Not using the functional options pattern (#11)
???+ info "TL;DR"
To handle options conveniently and in an API-friendly manner, use the functional options pattern.
Although there are different implementations with minor variations, the main idea is as follows:
* An unexported struct holds the configuration: options.
Each option is a function that returns the same type: type Option func(options options) error. For example, WithPort accepts an int argument that represents the port and returns an Option type that represents how to update the options struct.
type options struct {
port *int
}type Option func(options *options) error
func WithPort(port int) Option {
return func(options *options) error {
if port < 0 {
return errors.New("port should be positive")
}
options.port = &port
return nil
}
}
func NewServer(addr string, opts ...Option) ( *http.Server, error) {
var options options
for _, opt := range opts {
err := opt(&options)
if err != nil {
return nil, err
}
}
// At this stage, the options struct is built and contains the config
// Therefore, we can implement our logic related to port configuration
var port int
if options.port == nil {
port = defaultHTTPPort
} else {
if *options.port == 0 {
port = randomPort()
} else {
port = *options.port
}
}
// ...
}
The functional options pattern provides a handy and API-friendly way to handle options. Although the builder pattern can be a valid option, it has some minor downsides (having to pass a config struct that can be empty or a less handy way to handle error management) that tend to make the functional options pattern the idiomatic way to deal with these kind of problems in Go.
Project misorganization (project structure and package organization) (#12)
Regarding the overall organization, there are different schools of thought. For example, should we organize our application by context or by layer? It depends on our preferences. We may favor grouping code per context (such as the customer context, the contract context, etc.), or we may favor following hexagonal architecture principles and group per technical layer. If the decision we make fits our use case, it cannot be a wrong decision, as long as we remain consistent with it.
Regarding packages, there are multiple best practices that we should follow. First, we should avoid premature packaging because it might cause us to overcomplicate a project. Sometimes, it’s better to use a simple organization and have our project evolve when we understand what it contains rather than forcing ourselves to make the perfect structure up front.
Granularity is another essential thing to consider. We should avoid having dozens of nano packages containing only one or two files. If we do, it’s because we have probably missed some logical connections across these packages, making our project harder for readers to understand. Conversely, we should also avoid huge packages that dilute the meaning of a package name.
Package naming should also be considered with care. As we all know (as developers), naming is hard. To help clients understand a Go project, we should name our packages after what they provide, not what they contain. Also, naming should be meaningful. Therefore, a package name should be short, concise, expressive, and, by convention, a single lowercase word.
Regarding what to export, the rule is pretty straightforward. We should minimize what should be exported as much as possible to reduce the coupling between packages and keep unnecessary exported elements hidden. If we are unsure whether to export an element or not, we should default to not exporting it. Later, if we discover that we need to export it, we can adjust our code. Let’s also keep in mind some exceptions, such as making fields exported so that a struct can be unmarshaled with encoding/json.
Organizing a project isn’t straightforward, but following these rules should help make it easier to maintain. However, remember that consistency is also vital to ease maintainability. Therefore, let’s make sure that we keep things as consistent as possible within a codebase.
???+ note
In 2023, the Go team has published an official guideline for organizing / structuring a Go project: go.dev/doc/modules/layout
Creating utility packages (#13)
???+ info "TL;DR"
Naming is a critical piece of application design. Creating packages such as common, util, and shared doesn’t bring much value for the reader. Refactor such packages into meaningful and specific package names.
Also, bear in mind that naming a package after what it provides and not what it contains can be an efficient way to increase its expressiveness.
Ignoring package name collisions (#14)
???+ info "TL;DR"
To avoid naming collisions between variables and packages, leading to confusion or perhaps even bugs, use unique names for each one. If this isn’t feasible, use an import alias to change the qualifier to differentiate the package name from the variable name, or think of a better name.
Package collisions occur when a variable name collides with an existing package name, preventing the package from being reused. We should prevent variable name collisions to avoid ambiguity. If we face a collision, we should either find another meaningful name or use an import alias.
Missing code documentation (#15)
???+ info "TL;DR"
To help clients and maintainers understand your code’s purpose, document exported elements.
Documentation is an important aspect of coding. It simplifies how clients can consume an API but can also help in maintaining a project. In Go, we should follow some rules to make our code idiomatic:
First, every exported element must be documented. Whether it is a structure, an interface, a function, or something else, if it’s exported, it must be documented. The convention is to add comments, starting with the name of the exported element.
As a convention, each comment should be a complete sentence that ends with punctuation. Also bear in mind that when we document a function (or a method), we should highlight what the function intends to do, not how it does it; this belongs to the core of a function and comments, not documentation. Furthermore, the documentation should ideally provide enough information that the consumer does not have to look at our code to understand how to use an exported element.
When it comes to documenting a variable or a constant, we might be interested in conveying two aspects: its purpose and its content. The former should live as code documentation to be useful for external clients. The latter, though, shouldn’t necessarily be public.
To help clients and maintainers understand a package’s scope, we should also document each package. The convention is to start the comment with // Package followed by the package name. The first line of a package comment should be concise. That’s because it will appear in the package. Then, we can provide all the information we need in the following lines.
Documenting our code shouldn’t be a constraint. We should take the opportunity to make sure it helps clients and maintainers to understand the purpose of our code.
Not using linters (#16)
???+ info "TL;DR"
To improve code quality and consistency, use linters and formatters.
A linter is an automatic tool to analyze code and catch errors. The scope of this section isn’t to give an exhaustive list of the existing linters; otherwise, it will become deprecated pretty quickly. But we should understand and remember why linters are essential for most Go projects.
However, if you’re not a regular user of linters, here is a list that you may want to use daily:
* https://golang.org/cmd/vet—A standard Go analyzer
* https://github.com/kisielk/errcheck—An error checker
* https://github.com/fzipp/gocyclo—A cyclomatic complexity analyzer
* https://github.com/jgautheron/goconst—A repeated string constants analyzer
Besides linters, we should also use code formatters to fix code style. Here is a list of some code formatters for you to try:
* https://golang.org/cmd/gofmt—A standard Go code formatter
* https://godoc.org/golang.org/x/tools/cmd/goimports—A standard Go imports formatter
Meanwhile, we should also look at golangci-lint (https://github.com/golangci/golangci-lint). It’s a linting tool that provides a facade on top of many useful linters and formatters. Also, it allows running the linters in parallel to improve analysis speed, which is quite handy.
Linters and formatters are a powerful way to improve the quality and consistency of our codebase. Let’s take the time to understand which one we should use and make sure we automate their execution (such as a CI or Git precommit hook).
Data Types
Creating confusion with octal literals (#17)
???+ info "TL;DR"
When reading existing code, bear in mind that integer literals starting with 0 are octal numbers. Also, to improve readability, make octal integers explicit by prefixing them with 0o.
Octal numbers start with a 0 (e.g., 010 is equal to 8 in base 10). To improve readability and avoid potential mistakes for future code readers, we should make octal numbers explicit using the 0o prefix (e.g., 0o10).
We should also note the other integer literal representations:
* _Binary_—Uses a 0b or 0B prefix (for example, 0b100 is equal to 4 in base 10)
* _Hexadecimal_—Uses an 0x or 0X prefix (for example, 0xF is equal to 15 in base 10)
* _Imaginary_—Uses an i suffix (for example, 3i)
We can also use an underscore character (_) as a separator for readability. For example, we can write 1 billion this way: 1_000_000_000. We can also use the underscore character with other representations (for example, 0b00_00_01).
Neglecting integer overflows (#18)
???+ info "TL;DR"
Because integer overflows and underflows are handled silently in Go, you can implement your own functions to catch them.
In Go, an integer overflow that can be detected at compile time generates a compilation error. For example,
var counter int32 = math.MaxInt32 + 1constant 2147483648 overflows int32However, at run time, an integer overflow or underflow is silent; this does not lead to an application panic. It is essential to keep this behavior in mind, because it can lead to sneaky bugs (for example, an integer increment or addition of positive integers that leads to a negative result).
Not understanding floating-points (#19)
???+ info "TL;DR"
Making floating-point comparisons within a given delta can ensure that your code is portable. When performing addition or subtraction, group the operations with a similar order of magnitude to favor accuracy. Also, perform multiplication and division before addition and subtraction.
In Go, there are two floating-point types (if we omit imaginary numbers): float32 and float64. The concept of a floating point was invented to solve the major problem with integers: their inability to represent fractional values. To avoid bad surprises, we need to know that floating-point arithmetic is an approximation of real arithmetic.
For that, we’ll look at a multiplication example:
var n float32 = 1.0001
fmt.Println(n * n)We may expect this code to print the result of 1.0001 * 1.0001 = 1.00020001, right? However, running it on most x86 processors prints 1.0002, instead.
Because Go’s float32 and float64 types are approximations, we have to bear a few rules in mind:
* When comparing two floating-point numbers, check that their difference is within an acceptable range.
* When performing additions or subtractions, group operations with a similar order of magnitude for better accuracy.
* To favor accuracy, if a sequence of operations requires addition, subtraction, multiplication, or division, perform the multiplication and division operations first.
Not understanding slice length and capacity (#20)
???+ info "TL;DR"
Understanding the difference between slice length and capacity should be part of a Go developer’s core knowledge. The slice length is the number of available elements in the slice, whereas the slice capacity is the number of elements in the backing array.
Read the full section here.
Inefficient slice initialization (#21)
???+ info "TL;DR"
When creating a slice, initialize it with a given length or capacity if its length is already known. This reduces the number of allocations and improves performance.
While initializing a slice using make, we can provide a length and an optional capacity. Forgetting to pass an appropriate value for both of these parameters when it makes sense is a widespread mistake. Indeed, it can lead to multiple copies and additional effort for the GC to clean the temporary backing arrays. Performance-wise, there’s no good reason not to give the Go runtime a helping hand.
Our options are to allocate a slice with either a given capacity or a given length. Of these two solutions, we have seen that the second tends to be slightly faster. But using a given capacity and append can be easier to implement and read in some contexts.
Being confused about nil vs. empty slice (#22)
???+ info "TL;DR"
To prevent common confusions such as when using the encoding/json or the reflect package, you need to understand the difference between nil and empty slices. Both are zero-length, zero-capacity slices, but only a nil slice doesn’t require allocation.
In Go, there is a distinction between nil and empty slices. A nil slice is equals to nil, whereas an empty slice has a length of zero. A nil slice is empty, but an empty slice isn’t necessarily nil. Meanwhile, a nil slice doesn’t require any allocation. We have seen throughout this section how to initialize a slice depending on the context by using
* var s []string if we aren’t sure about the final length and the slice can be empty
* []string(nil) as syntactic sugar to create a nil and empty slice
* make([]string, length) if the future length is known
The last option, []string{}, should be avoided if we initialize the slice without elements. Finally, let’s check whether the libraries we use make the distinctions between nil and empty slices to prevent unexpected behaviors.
Not properly checking if a slice is empty (#23)
???+ info "TL;DR"
To check if a slice doesn’t contain any element, check its length. This check works regardless of whether the slice is nil or empty. The same goes for maps. To design unambiguous APIs, you shouldn’t distinguish between nil and empty slices.
To determine whether a slice has elements, we can either do it by checking if the slice is nil or if its length is equal to 0. Checking the length is the best option to follow as it will cover both if the slice is empty or if the slice is nil.
Meanwhile, when designing interfaces, we should avoid distinguishing nil and empty slices, which leads to subtle programming errors. When returning slices, it should make neither a semantic nor a technical difference if we return a nil or empty slice. Both should mean the same thing for the callers. This principle is the same with maps. To check if a map is empty, check its length, not whether it’s nil.
Not making slice copies correctly (#24)
???+ info "TL;DR"
To copy one slice to another using the copy built-in function, remember that the number of copied elements corresponds to the minimum between the two slice’s lengths.
Copying elements from one slice to another is a reasonably frequent operation. When using copy, we must recall that the number of elements copied to the destination corresponds to the minimum between the two slices’ lengths. Also bear in mind that other alternatives exist to copy a slice, so we shouldn’t be surprised if we find them in a codebase.
Unexpected side effects using slice append (#25)
???+ info "TL;DR"
Using copy or the full slice expression is a way to prevent append from creating conflicts if two different functions use slices backed by the same array. However, only a slice copy prevents memory leaks if you want to shrink a large slice.
When using slicing, we must remember that we can face a situation leading to unintended side effects. If the resulting slice has a length smaller than its capacity, append can mutate the original slice. If we want to restrict the range of possible side effects, we can use either a slice copy or the full slice expression, which prevents us from doing a copy.
???+ note
s[low:high:max] (full slice expression): This statement creates a slice similar to the one created with s[low:high], except that the resulting slice’s capacity is equal to max - low.
Slices and memory leaks (#26)
???+ info "TL;DR"
Working with a slice of pointers or structs with pointer fields, you can avoid memory leaks by marking as nil the elements excluded by a slicing operation.
#### Leaking capacity
Remember that slicing a large slice or array can lead to potential high memory consumption. The remaining space won’t be reclaimed by the GC, and we can keep a large backing array despite using only a few elements. Using a slice copy is the solution to prevent such a case.
#### Slice and pointers
When we use the slicing operation with pointers or structs with pointer fields, we need to know that the GC won’t reclaim these elements. In that case, the two options are to either perform a copy or explicitly mark the remaining elements or their fields to nil.
Inefficient map initialization (#27)
???+ info "TL;DR"
When creating a map, initialize it with a given length if its length is already known. This reduces the number of allocations and improves performance.
A map provides an unordered collection of key-value pairs in which all the keys are distinct. In Go, a map is based on the hash table data structure. Internally, a hash table is an array of buckets, and each bucket is a pointer to an array of key-value pairs.
If we know up front the number of elements a map will contain, we should create it by providing an initial size. Doing this avoids potential map growth, which is quite heavy computation-wise because it requires reallocating enough space and rebalancing all the elements.
Maps and memory leaks (#28)
???+ info "TL;DR"
A map can always grow in memory, but it never shrinks. Hence, if it leads to some memory issues, you can try different options, such as forcing Go to recreate the map or using pointers.
Read the full section here.
Comparing values incorrectly (#29)
???+ info "TL;DR"
To compare types in Go, you can use the == and != operators if two types are comparable: Booleans, numerals, strings, pointers, channels, and structs are composed entirely of comparable types. Otherwise, you can either use reflect.DeepEqual and pay the price of reflection or use custom implementations and libraries.
It’s essential to understand how to use == and != to make comparisons effectively. We can use these operators on operands that are comparable:
* _Booleans_—Compare whether two Booleans are equal.
* _Numerics (int, float, and complex types)_—Compare whether two numerics are equal.
* _Strings_—Compare whether two strings are equal.
* _Channels_—Compare whether two channels were created by the same call to make or if both are nil.
* _Interfaces_—Compare whether two interfaces have identical dynamic types and equal dynamic values or if both are nil.
* _Pointers_—Compare whether two pointers point to the same value in memory or if both are nil.
* _Structs and arrays_—Compare whether they are composed of similar types.
???+ note
We can also use the ?, >=, <, and > operators with numeric types to compare values and with strings to compare their lexical order.
If operands are not comparable (e.g., slices and maps), we have to use other options such as reflection. Reflection is a form of metaprogramming, and it refers to the ability of an application to introspect and modify its structure and behavior. For example, in Go, we can use reflect.DeepEqual. This function reports whether two elements are deeply equal by recursively traversing two values. The elements it accepts are basic types plus arrays, structs, slices, maps, pointers, interfaces, and functions. Yet, the main catch is the performance penalty.
If performance is crucial at run time, implementing our custom method might be the best solution.
One additional note: we must remember that the standard library has some existing comparison methods. For example, we can use the optimized bytes.Compare function to compare two slices of bytes. Before implementing a custom method, we need to make sure we don’t reinvent the wheel.
Control Structures
Ignoring that elements are copied in range loops (#30)
???+ info "TL;DR"
The value element in a range loop is a copy. Therefore, to mutate a struct, for example, access it via its index or via a classic for loop (unless the element or the field you want to modify is a pointer).
A range loop allows iterating over different data structures:
* String
* Array
* Pointer to an array
* Slice
* Map
* Receiving channel
Compared to a classic for loop, a range loop is a convenient way to iterate over all the elements of one of these data structures, thanks to its concise syntax.
Yet, we should remember that the value element in a range loop is a copy. Therefore, if the value is a struct we need to mutate, we will only update the copy, not the element itself, unless the value or field we modify is a pointer. The favored options are to access the element via the index using a range loop or a classic for loop.
Ignoring how arguments are evaluated in range loops (channels and arrays) (#31)
???+ info "TL;DR"
Understanding that the expression passed to the range operator is evaluated only once before the beginning of the loop can help you avoid common mistakes such as inefficient assignment in channel or slice iteration.
The range loop evaluates the provided expression only once, before the beginning of the loop, by doing a copy (regardless of the type). We should remember this behavior to avoid common mistakes that might, for example, lead us to access the wrong element. For example:
a := [3]int{0, 1, 2}
for i, v := range a {
a[2] = 10
if i == 2 {
fmt.Println(v)
}
}This code updates the last index to 10. However, if we run this code, it does not print 10; it prints 2.
:warning: Ignoring the impacts of using pointer elements in range loops (#32)
???+ warning
This mistake isn't relevant anymore from Go 1.22 (details).
Making wrong assumptions during map iterations (ordering and map insert during iteration) (#33)
???+ info "TL;DR"
To ensure predictable outputs when using maps, remember that a map data structure:
* Doesn’t order the data by keys
* Doesn’t preserve the insertion order
* Doesn’t have a deterministic iteration order
* Doesn’t guarantee that an element added during an iteration will be produced during this iteration
Ignoring how the break statement works (#34)
???+ info "TL;DR"
Using break or continue with a label enforces breaking a specific statement. This can be helpful with switch or select statements inside loops.
A break statement is commonly used to terminate the execution of a loop. When loops are used in conjunction with switch or select, developers frequently make the mistake of breaking the wrong statement. For example:
for i := 0; i < 5; i++ {
fmt.Printf("%d ", i) switch i {
default:
case 2:
break
}
}
The break statement doesn’t terminate the for loop: it terminates the switch statement, instead. Hence, instead of iterating from 0 to 2, this code iterates from 0 to 4: 0 1 2 3 4.
One essential rule to keep in mind is that a break statement terminates the execution of the innermost for, switch, or select statement. In the previous example, it terminates the switch statement.
To break the loop instead of the switch statement, the most idiomatic way is to use a label:
``go hl_lines="1 8"
loop:
for i := 0; i < 5; i++ {
fmt.Printf("%d ", i)
switch i {
default:
case 2:
break loop
}
}
Here, we associate thelooplabel with theforloop. Then, because we provide thelooplabel to thebreakstatement, it breaks the loop, not the switch. Therefore, this new version will print0 1 2, as we expected.deferUsing
inside a loop (#35)defer???+ info "TL;DR"
Extracting loop logic inside a function leads to executing a
statement at the end of each iteration.deferThe
statement delays a call’s execution until the surrounding function returns. It’s mainly used to reduce boilerplate code. For example, if a resource has to be closed eventually, we can usedeferto avoid repeating the closure calls before every singlereturn.deferOne common mistake with
is to forget that it schedules a function call when the _surrounding_ function returns. For example:
func readFiles(ch <-chan string) error {
for path := range ch {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
// Do something with file
}
return nil
}
Thedefercalls are executed not during each loop iteration but when thereadFilesfunction returns. IfreadFilesdoesn’t return, the file descriptors will be kept open forever, causing leaks.deferOne common option to fix this problem is to create a surrounding function after
, called during each iteration:
func readFiles(ch <-chan string) error {
for path := range ch {
if err := readFile(path); err != nil {
return err
}
}
return nil
}
func readFile(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
// Do something with file
return nil
}
Another solution is to make thereadFilefunction a closure but intrinsically, this remains the same solution: adding another surrounding function to execute thedefercalls during each iteration.runeStrings
Not understanding the concept of rune (#36)
???+ info "TL;DR"
Understanding that a rune corresponds to the concept of a Unicode code point and that it can be composed of multiple bytes should be part of the Go developer’s core knowledge to work accurately with strings.
As runes are everywhere in Go, it's important to understand the following:
* A charset is a set of characters, whereas an encoding describes how to translate a charset into binary.
* In Go, a string references an immutable slice of arbitrary bytes.
* Go source code is encoded using UTF-8. Hence, all string literals are UTF-8 strings. But because a string can contain arbitrary bytes, if it’s obtained from somewhere else (not the source code), it isn’t guaranteed to be based on the UTF-8 encoding.
* Acorresponds to the concept of a Unicode code point, meaning an item represented by a single value.len()
* Using UTF-8, a Unicode code point can be encoded into 1 to 4 bytes.
* Usingon a string in Go returns the number of bytes, not the number of runes.rangeInaccurate string iteration (#37)
???+ info "TL;DR"
Iterating on a string with the
operator iterates on the runes with the index corresponding to the starting index of the rune’s byte sequence. To access a specific rune index (such as the third rune), convert the string into a[]rune.Iterating on a string is a common operation for developers. Perhaps we want to perform an operation for each rune in the string or implement a custom function to search for a specific substring. In both cases, we have to iterate on the different runes of a string. But it’s easy to get confused about how iteration works.
For example, consider the following example:
s := "hêllo"
for i := range s {
fmt.Printf("position %d: %c\n", i, s[i])
}
fmt.Printf("len=%d\n", len(s))
position 0: h
position 1: Ã
position 3: l
position 4: l
position 5: o
len=6
Let's highlight three points that might be confusing:s* The second rune is à in the output instead of ê.
* We jumped from position 1 to position 3: what is at position 2?
* len returns a count of 6, whereas s contains only 5 runes.Let’s start with the last observation. We already mentioned that len returns the number of bytes in a string, not the number of runes. Because we assigned a string literal to
,sis a UTF-8 string. Meanwhile, the special character "ê" isn’t encoded in a single byte; it requires 2 bytes. Therefore, callinglen(s)returns 6.s[i]Meanwhile, in the previous example, we have to understand that we don't iterate over each rune; instead, we iterate over each starting index of a rune:
Printing
doesn’t print the ith rune; it prints the UTF-8 representation of the byte at indexi. Hence, we printed "hÃllo" instead of "hêllo".rangeIf we want to print all the different runes, we can either use the value element of the
operator:
s := "hêllo"
for i, r := range s {
fmt.Printf("position %d: %c\n", i, r)
}
Or, we can convert the string into a slice of runes and iterate over it:s := "hêllo"
runes := []rune(s)
for i, r := range runes {
fmt.Printf("position %d: %c\n", i, r)
}
Note that this solution introduces a run-time overhead compared to the previous one. Indeed, converting a string into a slice of runes requires allocating an additional slice and converting the bytes into runes: an O(n) time complexity with n the number of bytes in the string. Therefore, if we want to iterate over all the runes, we should use the first solution.However, if we want to access the ith rune of a string with the first option, we don’t have access to the rune index; rather, we know the starting index of a rune in the byte sequence.
s := "hêllo"
r := []rune(s)[4]
fmt.Printf("%c\n", r) // o
:simple-github: Source codestrings.TrimRightMisusing trim functions (#38)
???+ info "TL;DR"
/strings.TrimLeftremoves all the trailing/leading runes contained in a given set, whereasstrings.TrimSuffix/strings.TrimPrefixreturns a string without a provided suffix/prefix.For example:
fmt.Println(strings.TrimRight("123oxo", "xo"))
The example prints 123:strings.TrimLeftConversely,
removes all the leading runes contained in a set.strings.TrimSuffixOn the other side,
/strings.TrimPrefixreturns a string without the provided trailing suffix / prefix.strings.BuilderUnder-optimized strings concatenation (#39)
???+ info "TL;DR"
Concatenating a list of strings should be done with
to prevent allocating a new string during each iteration.concatLet’s consider a
function that concatenates all the string elements of a slice using the+=operator:
func concat(values []string) string {
s := ""
for _, value := range values {
s += value
}
return s
}
During each iteration, the+=operator concatenatesswith the value string. At first sight, this function may not look wrong. But with this implementation, we forget one of the core characteristics of a string: its immutability. Therefore, each iteration doesn’t updates; it reallocates a new string in memory, which significantly impacts the performance of this function.strings.BuilderFortunately, there is a solution to deal with this problem, using
:
func concat(values []string) string {
sb := strings.Builder{}
for _, value := range values {
_, _ = sb.WriteString(value)
}
return sb.String()
}
During each iteration, we constructed the resulting string by calling theWriteStringmethod that appends the content of value to its internal buffer, hence minimizing memory copying.WriteString???+ note
returns an error as the second output, but we purposely ignore it. Indeed, this method will never return a non-nil error. So what’s the purpose of this method returning an error as part of its signature?strings.Builderimplements theio.StringWriterinterface, which contains a single method:WriteString(s string) (n int, err error). Hence, to comply with this interface,WriteStringmust return an error.strings.BuilderInternally,
holds a byte slice. Each call toWriteStringresults in a call to append on this slice. There are two impacts. First, this struct shouldn’t be used concurrently, as the calls toappendwould lead to race conditions. The second impact is something that we saw in mistake #21, "Inefficient slice initialization": if the future length of a slice is already known, we should preallocate it. For that purpose,strings.Builderexposes a methodGrow(n int)to guarantee space for anothernbytes:
func concat(values []string) string {
total := 0
for i := 0; i < len(values); i++ {
total += len(values[i])
}
sb := strings.Builder{}
sb.Grow(total) (2)
for _, value := range values {
_, _ = sb.WriteString(value)
}
return sb.String()
}
Let’s run a benchmark to compare the three versions (v1 using+=; v2 usingstrings.Builder{}without preallocation; and v3 usingstrings.Builder{}with preallocation). The input slice contains 1,000 strings, and each string contains 1,000 bytes:
BenchmarkConcatV1-4 16 72291485 ns/op
BenchmarkConcatV2-4 1188 878962 ns/op
BenchmarkConcatV3-4 5922 190340 ns/op
As we can see, the latest version is by far the most efficient: 99% faster than v1 and 78% faster than v2.strings.Builderis the recommended solution to concatenate a list of strings. Usually, this solution should be used within a loop. Indeed, if we just have to concatenate a few strings (such as a name and a surname), usingstrings.Builderis not recommended as doing so will make the code a bit less readable than using the+=operator orfmt.Sprintf.bytesUseless string conversions (#40)
???+ info "TL;DR"
Remembering that the
package offers the same operations as thestringspackage can help avoid extra byte/string conversions.[]byteWhen choosing to work with a string or a
, most programmers tend to favor strings for convenience. But most I/O is actually done with[]byte. For example,io.Reader,io.Writer, andio.ReadAllwork with[]byte, not strings.[]byteWhen we’re wondering whether we should work with strings or
, let’s recall that working with[]byteisn’t necessarily less convenient. Indeed, all the exported functions of the strings package also have alternatives in thebytespackage:Split,Count,Contains,Index, and so on. Hence, whether we’re doing I/O or not, we should first check whether we could implement a whole workflow using bytes instead of strings and avoid the price of additional conversions.strings.CloneSubstring and memory leaks (#41)
???+ info "TL;DR"
Using copies instead of substrings can prevent memory leaks, as the string returned by a substring operation will be backed by the same byte array.
In mistake #26, “Slices and memory leaks,” we saw how slicing a slice or array may lead to memory leak situations. This principle also applies to string and substring operations.
We need to keep two things in mind while using the substring operation in Go. First, the interval provided is based on the number of bytes, not the number of runes. Second, a substring operation may lead to a memory leak as the resulting substring will share the same backing array as the initial string. The solutions to prevent this case from happening are to perform a string copy manually or to use
from Go 1.18.Functions and Methods
Not knowing which type of receiver to use (#42)
???+ info "TL;DR"
The decision whether to use a value or a pointer receiver should be made based on factors such as the type, whether it has to be mutated, whether it contains a field that can’t be copied, and how large the object is. When in doubt, use a pointer receiver.
Choosing between value and pointer receivers isn’t always straightforward. Let’s discuss some of the conditions to help us choose.
A receiver _must_ be a pointer
* If the method needs to mutate the receiver. This rule is also valid if the receiver is a slice and a method needs to append elements:
type slice []int
func (s *slice) add(element int) {
s = append(s, element)
}
* If the method receiver contains a field that cannot be copied: for example, a type part of the sync package (see #74, “Copying a sync type”).time.TimeA receiver _should_ be a pointer
* If the receiver is a large object. Using a pointer can make the call more efficient, as doing so prevents making an extensive copy. When in doubt about how large is large, benchmarking can be the solution; it’s pretty much impossible to state a specific size, because it depends on many factors.
A receiver _must_ be a value
* If we have to enforce a receiver’s immutability.
* If the receiver is a map, function, or channel. Otherwise, a compilation error
occurs.A receiver _should_ be a value
* If the receiver is a slice that doesn’t have to be mutated.
* If the receiver is a small array or struct that is naturally a value type without mutable fields, such as.int
* If the receiver is a basic type such as,float64, orstring.bOf course, it’s impossible to be exhaustive, as there will always be edge cases, but this section’s goal was to provide guidance to cover most cases. By default, we can choose to go with a value receiver unless there’s a good reason not to do so. In doubt, we should use a pointer receiver.
Never using named result parameters (#43)
???+ info "TL;DR"
Using named result parameters can be an efficient way to improve the readability of a function/method, especially if multiple result parameters have the same type. In some cases, this approach can also be convenient because named result parameters are initialized to their zero value. But be cautious about potential side effects.
When we return parameters in a function or a method, we can attach names to these parameters and use them as regular variables. When a result parameter is named, it’s initialized to its zero value when the function/method begins. With named result parameters, we can also call a naked return statement (without arguments). In that case, the current values of the result parameters are used as the returned values.
Here’s an example that uses a named result parameter
:
func f(a int) (b int) {
b = a
return
}
In this example, we attach a name to the result parameter:b. When we call return without arguments, it returns the current value ofb.In some cases, named result parameters can also increase readability: for example, if two parameters have the same type. In other cases, they can also be used for convenience. Therefore, we should use named result parameters sparingly when there’s a clear benefit.
Unintended side effects with named result parameters (#44)
???+ info "TL;DR"
See #43.
We mentioned why named result parameters can be useful in some situations. But as these result parameters are initialized to their zero value, using them can sometimes lead to subtle bugs if we’re not careful enough. For example, can you spot what’s wrong with this code?
func (l loc) getCoordinates(ctx context.Context, address string) (
lat, lng float32, err error) {
isValid := l.validateAddress(address) (1)
if !isValid {
return 0, 0, errors.New("invalid address")
}
if ctx.Err() != nil { (2)
return 0, 0, err
}
// Get and return coordinates
}
The error might not be obvious at first glance. Here, the error returned in theif ctx.Err() != nilscope iserr. But we haven’t assigned any value to theerrvariable. It’s still assigned to the zero value of anerrortype:nil. Hence, this code will always return a nil error.io.Reader
When using named result parameters, we must recall that each parameter is initialized to its zero value. As we have seen in this section, this can lead to subtle bugs that aren’t always straightforward to spot while reading code. Therefore, let’s remain cautious when using named result parameters, to avoid potential side effects.Returning a nil receiver (#45)
???+ info "TL;DR"
When returning an interface, be cautious about not returning a nil pointer but an explicit nil value. Otherwise, unintended consequences may occur and the caller will receive a non-nil value.
Using a filename as a function input (#46)
???+ info "TL;DR"
Designing functions to receive
types instead of filenames improves the reusability of a function and makes testing easier.os.OpenAccepting a filename as a function input to read from a file should, in most cases, be considered a code smell (except in specific functions such as
). Indeed, it makes unit tests more complex because we may have to create multiple files. It also reduces the reusability of a function (although not all functions are meant to be reused). Using theio.Readerinterface abstracts the data source. Regardless of whether the input is a file, a string, an HTTP request, or a gRPC request, the implementation can be reused and easily tested.deferIgnoring how
arguments and receivers are evaluated (argument evaluation, pointer, and value receivers) (#47)defer???+ info "TL;DR"
Passing a pointer to a
function and wrapping a call inside a closure are two possible solutions to overcome the immediate evaluation of arguments and receivers.deferIn a
function the arguments are evaluated right away, not once the surrounding function returns. For example, in this code, we always callnotifyandincrementCounterwith the same status: an empty string.
const (
StatusSuccess = "success"
StatusErrorFoo = "error_foo"
StatusErrorBar = "error_bar"
)
func f() error {
var status string
defer notify(status)
defer incrementCounter(status)
if err := foo(); err != nil {
status = StatusErrorFoo
return err
}
if err := bar(); err != nil {
status = StatusErrorBar
return err
}
status = StatusSuccess
return nil
}
Indeed, we callnotify(status)andincrementCounter(status)asdeferfunctions. Therefore, Go will delay these calls to be executed oncefreturns with the current value of status at the stage we used defer, hence passing an empty string.deferTwo leading options if we want to keep using
.The first solution is to pass a string pointer:
func f() error {
var status string
defer notify(&status)
defer incrementCounter(&status)
// The rest of the function unchanged
}
Usingdeferevaluates the arguments right away: here, the address of status. Yes, status itself is modified throughout the function, but its address remains constant, regardless of the assignments. Hence, ifnotifyorincrementCounteruses the value referenced by the string pointer, it will work as expected. But this solution requires changing the signature of the two functions, which may not always be possible.deferThere’s another solution: calling a closure (an anonymous function value that references variables from outside its body) as a
statement:
func f() error {
var status string
defer func() {
notify(status)
incrementCounter(status)
}()
// The rest of the function unchanged
}
Here, we wrap the calls to bothnotifyandincrementCounterwithin a closure. This closure references the status variable from outside its body. Therefore,statusis evaluated once the closure is executed, not when we calldefer. This solution also works and doesn’t requirenotifyandincrementCounterto change their signature.panicLet's also note this behavior applies with method receiver: the receiver is evaluated immediately.
Error Management
Panicking (#48)
???+ info "TL;DR"
Using
is an option to deal with errors in Go. However, it should only be used sparingly in unrecoverable conditions: for example, to signal a programmer error or when you fail to load a mandatory dependency.In Go, panic is a built-in function that stops the ordinary flow:
func main() {
fmt.Println("a")
panic("foo")
fmt.Println("b")
}
This code prints a and then stops before printing b:a
panic: foo
goroutine 1 [running]:
main.main()
main.go:7 +0xb3
Panicking in Go should be used sparingly. There are two prominent cases, one to signal a programmer error (e.g.,sql.Registerthat panics if the driver isnilor has already been register) and another where our application fails to create a mandatory dependency. Hence, exceptional conditions that lead us to stop the application. In most other cases, error management should be done with a function that returns a proper error type as the last return argument.%wIgnoring when to wrap an error (#49)
???+ info "TL;DR"
Wrapping an error allows you to mark an error and/or provide additional context. However, error wrapping creates potential coupling as it makes the source error available for the caller. If you want to prevent that, don’t use error wrapping.
Since Go 1.13, the %w directive allows us to wrap errors conveniently. Error wrapping is about wrapping or packing an error inside a wrapper container that also makes the source error available. In general, the two main use cases for error wrapping are the following:
* Adding additional context to an error
* Marking an error as a specific errorWhen handling an error, we can decide to wrap it. Wrapping is about adding additional context to an error and/or marking an error as a specific type. If we need to mark an error, we should create a custom error type. However, if we just want to add extra context, we should use fmt.Errorf with the %w directive as it doesn’t require creating a new error type. Yet, error wrapping creates potential coupling as it makes the source error available for the caller. If we want to prevent it, we shouldn’t use error wrapping but error transformation, for example, using fmt.Errorf with the %v directive.
Comparing an error type inaccurately (#50)
???+ info "TL;DR"
If you use Go 1.13 error wrapping with the
directive andfmt.Errorf, comparing an error against a type has to be done usingerrors.As. Otherwise, if the returned error you want to check is wrapped, it will fail the checks.%wComparing an error value inaccurately (#51)
???+ info "TL;DR"
If you use Go 1.13 error wrapping with the
directive andfmt.Errorf, comparing an error against or a value has to be done usingerrors.As. Otherwise, if the returned error you want to check is wrapped, it will fail the checks.A sentinel error is an error defined as a global variable:
import "errors"
var ErrFoo = errors.New("foo")
In general, the convention is to start withErrfollowed by the error type: here,ErrFoo. A sentinel error conveys an _expected_ error, an error that clients will expect to check. As general guidelines:var ErrFoo = errors.New("foo")* Expected errors should be designed as error values (sentinel errors):
.type BarError struct { ... }
* Unexpected errors should be designed as error types:, withBarErrorimplementing theerrorinterface.%wIf we use error wrapping in our application with the
directive andfmt.Errorf, checking an error against a specific value should be done usingerrors.Isinstead of==. Thus, even if the sentinel error is wrapped,errors.Iscan recursively unwrap it and compare each error in the chain against the provided value.deferHandling an error twice (#52)
???+ info "TL;DR"
In most situations, an error should be handled only once. Logging an error is handling an error. Therefore, you have to choose between logging or returning an error. In many cases, error wrapping is the solution as it allows you to provide additional context to an error and return the source error.
Handling an error multiple times is a mistake made frequently by developers, not specifically in Go. This can cause situations where the same error is logged multiple times make debugging harder.
Let's remind us that handling an error should be done only once. Logging an error is handling an error. Hence, we should either log or return an error. By doing this, we simplify our code and gain better insights into the error situation. Using error wrapping is the most convenient approach as it allows us to propagate the source error and add context to an error.
Not handling an error (#53)
???+ info "TL;DR"
Ignoring an error, whether during a function call or in a
function, should be done explicitly using the blank identifier. Otherwise, future readers may be confused about whether it was intentional or a miss.deferNot handling
errors (#54)defer???+ info "TL;DR"
In many cases, you shouldn’t ignore an error returned by a
function. Either handle it directly or propagate it to the caller, depending on the context. If you want to ignore it, use the blank identifier.Consider the following code:
func f() {
// ...
notify() // Error handling is omitted
}
func notify() error {
// ...
}
From a maintainability perspective, the code can lead to some issues. Let’s consider a new reader looking at it. This reader notices that notify returns an error but that the error isn’t handled by the parent function. How can they guess whether or not handling the error was intentional? How can they know whether the previous developer forgot to handle it or did it purposely?_For these reasons, when we want to ignore an error, there's only one way to do it, using the blank identifier (
):
_ = notify
In terms of compilation and run time, this approach doesn’t change anything compared to the first piece of code. But this new version makes explicit that we aren’t interested in the error. Also, we can add a comment that indicates the rationale for why an error is ignored:// At-most once delivery.
// Hence, it's accepted to miss some of them in case of errors.
_ = notify()
:simple-github: Source codechan struct{}Concurrency: Foundations
Mixing up concurrency and parallelism (#55)
???+ info "TL;DR"
Understanding the fundamental differences between concurrency and parallelism is a cornerstone of the Go developer’s knowledge. Concurrency is about structure, whereas parallelism is about execution.
Concurrency and parallelism are not the same:
* Concurrency is about structure. We can change a sequential implementation into a concurrent one by introducing different steps that separate concurrent goroutines can tackle.
* Meanwhile, parallelism is about execution. We can use parallism at the steps level by adding more parallel goroutines.In summary, concurrency provides a structure to solve a problem with parts that may be parallelized. Therefore, _concurrency enables parallelism_.
Thinking concurrency is always faster (#56)
???+ info "TL;DR"
To be a proficient developer, you must acknowledge that concurrency isn’t always faster. Solutions involving parallelization of minimal workloads may not necessarily be faster than a sequential implementation. Benchmarking sequential versus concurrent solutions should be the way to validate assumptions.
Read the full section here.
Being puzzled about when to use channels or mutexes (#57)
???+ info "TL;DR"
Being aware of goroutine interactions can also be helpful when deciding between channels and mutexes. In general, parallel goroutines require synchronization and hence mutexes. Conversely, concurrent goroutines generally require coordination and orchestration and hence channels.
Given a concurrency problem, it may not always be clear whether we can implement a
solution using channels or mutexes. Because Go promotes sharing memory by communication, one mistake could be to always force the use of channels, regardless of
the use case. However, we should see the two options as complementary.When should we use channels or mutexes? We will use the example in the next figure as a backbone. Our example has three different goroutines with specific relationships:
* G1 and G2 are parallel goroutines. They may be two goroutines executing the same function that keeps receiving messages from a channel, or perhaps two goroutines executing the same HTTP handler at the same time.
* On the other hand, G1 and G3 are concurrent goroutines, as are G2 and G3. All the goroutines are part of an overall concurrent structure, but G1 and G2 perform the first step, whereas G3 does the next step.In general, parallel goroutines have to _synchronize_: for example, when they need to access or mutate a shared resource such as a slice. Synchronization is enforced with mutexes but not with any channel types (not with buffered channels). Hence, in general, synchronization between parallel goroutines should be achieved via mutexes.
Conversely, in general, concurrent goroutines have to _coordinate and orchestrate_. For example, if G3 needs to aggregate results from both G1 and G2, G1 and G2 need to signal to G3 that a new intermediate result is available. This coordination falls under the scope of communication—therefore, channels.
Regarding concurrent goroutines, there’s also the case where we want to transfer the ownership of a resource from one step (G1 and G2) to another (G3); for example, if G1 and G2 are enriching a shared resource and at some point, we consider this job as complete. Here, we should use channels to signal that a specific resource is ready and handle the ownership transfer.
Mutexes and channels have different semantics. Whenever we want to share a state or access a shared resource, mutexes ensure exclusive access to this resource. Conversely, channels are a mechanic for signaling with or without data (
or not). Coordination or ownership transfer should be achieved via channels. It’s important to know whether goroutines are parallel or concurrent because, in general, we need mutexes for parallel goroutines and channels for concurrent ones.sync/atomicNot understanding race problems (data races vs. race conditions and the Go memory model) (#58)
???+ info "TL;DR"
Being proficient in concurrency also means understanding that data races and race conditions are different concepts. Data races occur when multiple goroutines simultaneously access the same memory location and at least one of them is writing. Meanwhile, being data-race-free doesn’t necessarily mean deterministic execution. When a behavior depends on the sequence or the timing of events that can’t be controlled, this is a race condition.
Race problems can be among the hardest and most insidious bugs a programmer can face. As Go developers, we must understand crucial aspects such as data races and race conditions, their possible impacts, and how to avoid them.
#### Data Race
A data race occurs when two or more goroutines simultaneously access the same memory location and at least one is writing. In this case, the result can be hazardous. Even worse, in some situations, the memory location may end up holding a value containing a meaningless combination of bits.
We can prevent a data race from happening using different techniques. For example:
* Using the
packageruntime.GOMAXPROCS
* In synchronizing the two goroutines with an ad hoc data structure like a mutex
* Using channels to make the two goroutines communicating to ensure that a variable is updated by only one goroutine at a time#### Race Condition
Depending on the operation we want to perform, does a data-race-free application necessarily mean a deterministic result? Not necessarily.
A race condition occurs when the behavior depends on the sequence or the timing of events that can’t be controlled. Here, the timing of events is the goroutines’ execution order.
In summary, when we work in concurrent applications, it’s essential to understand that a data race is different from a race condition. A data race occurs when multiple goroutines simultaneously access the same memory location and at least one of them is writing. A data race means unexpected behavior. However, a data-race-free application doesn’t necessarily mean deterministic results. An application can be free of data races but still have behavior that depends on uncontrolled events (such as goroutine execution, how fast a message is published to a channel, or how long a call to a database lasts); this is a race condition. Understanding both concepts is crucial to becoming proficient in designing concurrent applications.
Not understanding the concurrency impacts of a workload type (#59)
???+ info "TL;DR"
When creating a certain number of goroutines, consider the workload type. Creating CPU-bound goroutines means bounding this number close to the GOMAXPROCS variable (based by default on the number of CPU cores on the host). Creating I/O-bound goroutines depends on other factors, such as the external system.
In programming, the execution time of a workload is limited by one of the following:
* The speed of the CPU—For example, running a merge sort algorithm. The workload is called CPU-bound.
* The speed of I/O—For example, making a REST call or a database query. The workload is called I/O-bound.
* The amount of available memory—The workload is called memory-bound.???+ note
The last is the rarest nowadays, given that memory has become very cheap in recent decades. Hence, this section focuses on the two first workload types: CPU- and I/O-bound.
If the workload executed by the workers is I/O-bound, the value mainly depends on the external system. Conversely, if the workload is CPU-bound, the optimal number of goroutines is close to the number of available CPU cores (a best practice can be to use
). Knowing the workload type (I/O or CPU) is crucial when designing concurrent applications.time.DurationMisunderstanding Go contexts (#60)
???+ info "TL;DR"
Go contexts are also one of the cornerstones of concurrency in Go. A context allows you to carry a deadline, a cancellation signal, and/or a list of keys-values.
!!! quote "https://pkg.go.dev/context"
A Context carries a deadline, a cancellation signal, and other values across API boundaries.
#### Deadline
A deadline refers to a specific point in time determined with one of the following:
* A
from now (for example, in 250 ms)time.Time
* A(for example, 2023-02-07 00:00:00 UTC)CreateFileWatcher(ctx context.Context, filename string)The semantics of a deadline convey that an ongoing activity should be stopped if this deadline is met. An activity is, for example, an I/O request or a goroutine waiting to receive a message from a channel.
#### Cancellation signals
Another use case for Go contexts is to carry a cancellation signal. Let’s imagine that we want to create an application that calls
within another goroutine. This function creates a specific file watcher that keeps reading from a file and catches updates. When the provided context expires or is canceled, this function handles it to close the file descriptor.context.Context#### Context values
The last use case for Go contexts is to carry a key-value list. What’s the point of having a context carrying a key-value list? Because Go contexts are generic and mainstream, there are infinite use cases.
For example, if we use tracing, we may want different subfunctions to share the same correlation ID. Some developers may consider this ID too invasive to be part of the function signature. In this regard, we could also decide to include it as part of the provided context.
#### Catching a context cancellation
The
type exports aDonemethod that returns a receive-only notification channel:<-chan struct{}. This channel is closed when the work associated with the context should be canceled. For example,context.WithCancel* The Done channel related to a context created with
is closed when the cancel function is called.context.WithDeadline
* The Done channel related to a context created withis closed when the deadline has expired.publishOne thing to note is that the internal channel should be closed when a context is canceled or has met a deadline, instead of when it receives a specific value, because the closure of a channel is the only channel action that all the consumer goroutines will receive. This way, all the consumers will be notified once a context is canceled or a deadline is reached.
In summary, to be a proficient Go developer, we have to understand what a context is and how to use it. In general, a function that users wait for should take a context, as doing so allows upstream callers to decide when calling this function should be aborted.
Concurrency: Practice
Propagating an inappropriate context (#61)
???+ info "TL;DR"
Understanding the conditions when a context can be canceled should matter when propagating it: for example, an HTTP handler canceling the context when the response has been sent.
In many situations, it is recommended to propagate Go contexts. However, context propagation can sometimes lead to subtle bugs, preventing subfunctions from being correctly executed.
Let’s consider the following example. We expose an HTTP handler that performs some tasks and returns a response. But just before returning the response, we also want to send it to a Kafka topic. We don’t want to penalize the HTTP consumer latency-wise, so we want the publish action to be handled asynchronously within a new goroutine. We assume that we have at our disposal a
function that accepts a context so the action of publishing a message can be interrupted if the context is canceled, for example. Here is a possible implementation:
func handler(w http.ResponseWriter, r *http.Request) {
response, err := doSomeTask(r.Context(), r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
go func() {
err := publish(r.Context(), response)
// Do something with err
}()
writeResponse(response)
}
What’s wrong with this piece of code? We have to know that the context attached to an HTTP request can cancel in different conditions:context.WithoutCancel* When the client’s connection closes
* In the case of an HTTP/2 request, when the request is canceled
* When the response has been written back to the clientIn the first two cases, we probably handle things correctly. For example, if we get a response from doSomeTask but the client has closed the connection, it’s probably OK to call publish with a context already canceled so the message isn’t published. But what about the last case?
When the response has been written to the client, the context associated with the request will be canceled. Therefore, we are facing a race condition:
* If the response is written after the Kafka publication, we both return a response and publish a message successfully
* However, if the response is written before or during the Kafka publication, the message shouldn’t be published.In the latter case, calling publish will return an error because we returned the HTTP response quickly.
???+ note
From Go 1.21, there is a way to create a new context without cancel.
returns a copy of parent that is not canceled when parent is canceled.In summary, propagating a context should be done cautiously.
Starting a goroutine without knowing when to stop it (#62)
???+ info "TL;DR"
Avoiding leaks means being mindful that whenever a goroutine is started, you should have a plan to stop it eventually.
Goroutines are easy and cheap to start—so easy and cheap that we may not necessarily have a plan for when to stop a new goroutine, which can lead to leaks. Not knowing when to stop a goroutine is a design issue and a common concurrency mistake in Go.
Let’s discuss a concrete example. We will design an application that needs to watch some external configuration (for example, using a database connection). Here’s a first implementation:
func main() {
newWatcher()
// Run the application
}
type watcher struct { / Some resources / }
func newWatcher() {
w := watcher{}
go w.watch() // Creates a goroutine that watches some external configuration
}
The problem with this code is that when the main goroutine exits (perhaps because of an OS signal or because it has a finite workload), the application is stopped. Hence, the resources created by watcher aren’t closed gracefully. How can we prevent this from happening?One option could be to pass to newWatcher a context that will be canceled when main returns:
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
newWatcher(ctx)
// Run the application
}
func newWatcher(ctx context.Context) {
w := watcher{}
go w.watch(ctx)
}
We propagate the context created to the watch method. When the context is canceled, the watcher struct should close its resources. However, can we guarantee that watch will have time to do so? Absolutely not—and that’s a design flaw. The problem is that we used signaling to convey that a goroutine had to be stopped. We didn’t block the parent goroutine until the resources had been closed. Let’s make sure we do:
func main() {
w := newWatcher()
defer w.close()
// Run the application
}
func newWatcher() watcher {
w := watcher{}
go w.watch()
return w
}
func (w watcher) close() {
// Close the resources
}
Instead of signalingwatcherthat it’s time to close its resources, we now call thisclosemethod, usingdeferto guarantee that the resources are closed before the application exits.selectIn summary, let’s be mindful that a goroutine is a resource like any other that must eventually be closed to free memory or other resources. Starting a goroutine without knowing when to stop it is a design issue. Whenever a goroutine is started, we should have a clear plan about when it will stop. Last but not least, if a goroutine creates resources and its lifetime is bound to the lifetime of the application, it’s probably safer to wait for this goroutine to complete before exiting the application. This way, we can ensure that the resources can be freed.
:warning: Not being careful with goroutines and loop variables (#63)
???+ warning
This mistake isn't relevant anymore from Go 1.22 (details).
Expecting a deterministic behavior using select and channels (#64)
???+ info "TL;DR"
Understanding that
with multiple channels chooses the case randomly if multiple options are possible prevents making wrong assumptions that can lead to subtle concurrency bugs.disconnectChOne common mistake made by Go developers while working with channels is to make wrong assumptions about how select behaves with multiple channels.
For example, let's consider the following case (
is a unbuffered channel):
go func() {
for i := 0; i < 10; i++ {
messageCh <- i
}
disconnectCh <- struct{}{}
}()
for {
select {
case v := <-messageCh:
fmt.Println(v)
case <-disconnectCh:
fmt.Println("disconnection, return")
return
}
}
If we run this example multiple times, the result will be random:0
1
2
disconnection, return
0
disconnection, return
Instead of consuming the 10 messages, we only received a few of them. What’s the reason? It lies in the specification of the select statement with multiple channels (https:// go.dev/ref/spec):select!!! quote
If one or more of the communications can proceed, a single one that can proceed is chosen via a uniform pseudo-random selection.
Unlike a switch statement, where the first case with a match wins, the select statement selects randomly if multiple options are possible.
This behavior might look odd at first, but there’s a good reason for it: to prevent possible starvation. Suppose the first possible communication chosen is based on the source order. In that case, we may fall into a situation where, for example, we only receive from one channel because of a fast sender. To prevent this, the language designers decided to use a random selection.
When using
with multiple channels, we must remember that if multiple options are possible, the first case in the source order does not automatically win. Instead, Go selects randomly, so there’s no guarantee about which option will be chosen. To overcome this behavior, in the case of a single producer goroutine, we can use either unbuffered channels or a single channel.chan struct{}Not using notification channels (#65)
???+ info "TL;DR"
Send notifications using a
type.chan boolChannels are a mechanism for communicating across goroutines via signaling. A signal can be either with or without data.
Let’s look at a concrete example. We will create a channel that will notify us whenever a certain disconnection occurs. One idea is to handle it as a
:
disconnectCh := make(chan bool)
Now, let’s say we interact with an API that provides us with such a channel. Because it’s a channel of Booleans, we can receive eithertrueorfalsemessages. It’s probably clear whattrueconveys. But what doesfalsemean? Does it mean we haven’t been disconnected? And in this case, how frequently will we receive such a signal? Does it mean we have reconnected? Should we even expect to receivefalse? Perhaps we should only expect to receivetruemessages.chan struct{}If that’s the case, meaning we don’t need a specific value to convey some information, we need a channel _without_ data. The idiomatic way to handle it is a channel of empty structs:
.selectNot using nil channels (#66)
???+ info "TL;DR"
Using nil channels should be part of your concurrency toolset because it allows you to _remove_ cases from
statements, for example.What should this code do?
var ch chan int
<-ch
chis achan inttype. The zero value of a channel being nil,chisnil. The goroutine won’t panic; however, it will block forever.The principle is the same if we send a message to a nil channel. This goroutine blocks forever:
var ch chan int
ch <- 0
Then what’s the purpose of Go allowing messages to be received from or sent to a nil channel? For example, we can use nil channels to implement an idiomatic way to merge two channels:func merge(ch1, ch2 <-chan int) <-chan int {
ch := make(chan int, 1)
go func() {
for ch1 != nil || ch2 != nil { // Continue if at least one channel isn’t nil
select {
case v, open := <-ch1:
if !open {
ch1 = nil // Assign ch1 to a nil channel once closed
break
}
ch <- v
case v, open := <-ch2:
if !open {
ch2 = nil // Assigns ch2 to a nil channel once closed
break
}
ch <- v
}
}
close(ch)
}()
return ch
}
This elegant solution relies on nil channels to somehow _remove_ one case from theselectstatement.Let’s keep this idea in mind: nil channels are useful in some conditions and should be part of the Go developer’s toolset when dealing with concurrent code.
Being puzzled about channel size (#67)
???+ info "TL;DR"
Carefully decide on the right channel type to use, given a problem. Only unbuffered channels provide strong synchronization guarantees. For buffered channels, you should have a good reason to specify a channel size other than one.
An unbuffered channel is a channel without any capacity. It can be created by either omitting the size or providing a 0 size:
ch1 := make(chan int)
ch2 := make(chan int, 0)
With an unbuffered channel (sometimes called a synchronous channel), the sender will block until the receiver receives data from the channel.Conversely, a buffered channel has a capacity, and it must be created with a size greater than or equal to 1:
ch3 := make(chan int, 1)
With a buffered channel, a sender can send messages while the channel isn’t full. Once the channel is full, it will block until a receiver goroutine receives a message:ch3 := make(chan int, 1)
ch3 <-1 // Non-blocking
ch3 <-2 // Blocking
The first send isn’t blocking, whereas the second one is, as the channel is full at this stage.CustomerWhat's the main difference between unbuffered and buffered channels:
* An unbuffered channel enables synchronization. We have the guarantee that two goroutines will be in a known state: one receiving and another sending a message.
* A buffered channel doesn’t provide any strong synchronization. Indeed, a producer goroutine can send a message and then continue its execution if the channel isn’t full. The only guarantee is that a goroutine won’t receive a message before it is sent. But this is only a guarantee because of causality (you don’t drink your coffee before you prepare it).If we need a buffered channel, what size should we provide?
The default value we should use for buffered channels is its minimum: 1. So, we may approach the problem from this standpoint: is there any good reason not to use a value of 1? Here’s a list of possible cases where we should use another size:
* While using a worker pooling-like pattern, meaning spinning a fixed number of goroutines that need to send data to a shared channel. In that case, we can tie the channel size to the number of goroutines created.
* When using channels for rate-limiting problems. For example, if we need to enforce resource utilization by bounding the number of requests, we should set up the channel size according to the limit.If we are outside of these cases, using a different channel size should be done cautiously. Let’s bear in mind that deciding about an accurate queue size isn’t an easy problem:
!!! quote "Martin Thompson"
Queues are typically always close to full or close to empty due to the differences in pace between consumers and producers. They very rarely operate in a balanced middle ground where the rate of production and consumption is evenly matched.
Forgetting about possible side effects with string formatting (#68)
???+ info "TL;DR"
Being aware that string formatting may lead to calling existing functions means watching out for possible deadlocks and other data races.
It’s pretty easy to forget the potential side effects of string formatting while working in a concurrent application.
#### etcd data race
github.com/etcd-io/etcd/pull/7816 shows an example of an issue where a map's key was formatted based on a mutable values from a context.
#### Deadlock
Can you see what the problem is in this code with a
struct exposing anUpdateAgemethod and implementing thefmt.Stringerinterface?
type Customer struct {
mutex sync.RWMutex // Uses a sync.RWMutex to protect concurrent accesses
id string
age int
}
func (c *Customer) UpdateAge(age int) error {
c.mutex.Lock() // Locks and defers unlock as we update Customer
defer c.mutex.Unlock()
if age < 0 { // Returns an error if age is negative
return fmt.Errorf("age should be positive for customer %v", c)
}
c.age = age
return nil
}
func (c *Customer) String() string {
c.mutex.RLock() // Locks and defers unlock as we read Customer
defer c.mutex.RUnlock()
return fmt.Sprintf("id %s, age %d", c.id, c.age)
}
The problem here may not be straightforward. If the provided age is negative, we return an error. Because the error is formatted, using the%sdirective on the receiver, it will call theStringmethod to formatCustomer. But becauseUpdateAgealready acquires the mutex lock, theStringmethod won’t be able to acquire it. Hence, this leads to a deadlock situation. If all goroutines are also asleep, it leads to a panic.One possible solution is to restrict the scope of the mutex lock:
func (c *Customer) UpdateAge(age int) error {
if age < 0 {
return fmt.Errorf("age should be positive for customer %v", c)
}
c.mutex.Lock()
defer c.mutex.Unlock()
c.age = age
return nil
}
Yet, such an approach isn't always possible. In these conditions, we have to be extremely careful with string formatting.idAnother approach is to access the
field directly:
func (c *Customer) UpdateAge(age int) error {
c.mutex.Lock()
defer c.mutex.Unlock()
if age < 0 {
return fmt.Errorf("age should be positive for customer id %s", c.id)
}
c.age = age
return nil
}
In concurrent applications, we should remain cautious about the possible side effects of string formatting.appendCreating data races with append (#69)
???+ info "TL;DR"
Calling
isn’t always data-race-free; hence, it shouldn’t be used concurrently on a shared slice.appendShould adding an element to a slice using
is data-race-free? Spoiler: it depends.Do you believe this example has a data race?
s := make([]int, 1)
go func() { // In a new goroutine, appends a new element on s
s1 := append(s, 1)
fmt.Println(s1)
}()
go func() { // Same
s2 := append(s, 1)
fmt.Println(s2)
}()
The answer is no.make([]int, 1)In this example, we create a slice with
. The code creates a one-length, one-capacity slice. Thus, because the slice is full, using append in each goroutine returns a slice backed by a new array. It doesn’t mutate the existing array; hence, it doesn’t lead to a data race.sNow, let’s run the same example with a slight change in how we initialize
. Instead of creating a slice with a length of 1, we create it with a length of 0 but a capacity of 1. How about this new example? Does it contain a data race?
s := make([]int, 0, 1)
go func() {
s1 := append(s, 1)
fmt.Println(s1)
}()
go func() {
s2 := append(s, 1)
fmt.Println(s2)
}()
The answer is yes. We create a slice withmake([]int, 0, 1). Therefore, the array isn’t full. Both goroutines attempt to update the same index of the backing array (index 1), which is a data race.sHow can we prevent the data race if we want both goroutines to work on a slice containing the initial elements of
plus an extra element? One solution is to create a copy ofs.CacheWe should remember that using append on a shared slice in concurrent applications can lead to a data race. Hence, it should be avoided.
Using mutexes inaccurately with slices and maps (#70)
???+ info "TL;DR"
Remembering that slices and maps are pointers can prevent common data races.
Let's implement a
struct used to handle caching for customer balances. This struct will contain a map of balances per customer ID and a mutex to protect concurrent accesses:
type Cache struct {
mu sync.RWMutex
balances map[string]float64
}
Next, we add anAddBalancemethod that mutates thebalancesmap. The mutation is done in a critical section (within a mutex lock and a mutex unlock):
func (c *Cache) AddBalance(id string, balance float64) {
c.mu.Lock()
c.balances[id] = balance
c.mu.Unlock()
}
Meanwhile, we have to implement a method to calculate the average balance for all the customers. One idea is to handle a minimal critical section this way:func (c *Cache) AverageBalance() float64 {
c.mu.RLock()
balances := c.balances // Creates a copy of the balances map
c.mu.RUnlock()
sum := 0.
for _, balance := range balances { // Iterates over the copy, outside of the critical section
sum += balance
}
return sum / float64(len(balances))
}
What's the problem with this code?-raceIf we run a test using the
flag with two concurrent goroutines, one callingAddBalance(hence mutating balances) and another callingAverageBalance, a data race occurs. What’s the problem here?runtime.hmapInternally, a map is a
struct containing mostly metadata (for example, a counter) and a pointer referencing data buckets. So,balances := c.balancesdoesn’t copy the actual data. Therefore, the two goroutines perform operations on the same data set, and one mutates it. Hence, it's a data race.AverageBalanceOne possible solution is to protect the whole
function:
func (c *Cache) AverageBalance() float64 {
c.mu.RLock()
defer c.mu.RUnlock() // Unlocks when the function returns
sum := 0.
for _, balance := range c.balances {
sum += balance
}
return sum / float64(len(c.balances))
}
Another option, if the iteration operation isn’t lightweight, is to work on an actual copy of the data and protect only the copy:func (c *Cache) AverageBalance() float64 {
c.mu.RLock()
m := maps.Clone(c.balances)
c.mu.RUnlock()
sum := 0.
for _, balance := range m {
sum += balance
}
return sum / float64(len(m))
}
Once we have made a deep copy, we release the mutex. The iterations are done on the copy outside of the critical section.sync.WaitGroupIn summary, we have to be careful with the boundaries of a mutex lock. In this section, we have seen why assigning an existing map (or an existing slice) to a map isn’t enough to protect against data races. The new variable, whether a map or a slice, is backed by the same data set. There are two leading solutions to prevent this: protect the whole function, or work on a copy of the actual data. In all cases, let’s be cautious when designing critical sections and make sure the boundaries are accurately defined.
Misusing
(#71)sync.WaitGroup???+ info "TL;DR"
To accurately use
, call theAddmethod before spinning up goroutines.In the following example, we will initialize a wait group, start three goroutines that will update a counter atomically, and then wait for them to complete. We want to wait for these three goroutines to print the value of the counter (which should be 3):
wg := sync.WaitGroup{}
var v uint64
for i := 0; i < 3; i++ {
go func() {
wg.Add(1)
atomic.AddUint64(&v, 1)
wg.Done()
}()
}
wg.Wait()
fmt.Println(v)
If we run this example, we get a non-deterministic value: the code can print any value from 0 to 3. Also, if we enable the-raceflag, Go will even catch a data race.wg.Add(1)The problem is that
is called within the newly created goroutine, not in the parent goroutine. Hence, there is no guarantee that we have indicated to the wait group that we want to wait for three goroutines before callingwg.Wait().wg.AddTo fix this issue, we can call
before the loop:
wg := sync.WaitGroup{}
var v uint64
wg.Add(3)
for i := 0; i < 3; i++ {
go func() {
atomic.AddUint64(&v, 1)
wg.Done()
}()
}
wg.Wait()
fmt.Println(v)
Or inside the loop but not in the newly created goroutine:wg := sync.WaitGroup{}
var v uint64
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
atomic.AddUint64(&v, 1)
wg.Done()
}()
}
wg.Wait()
fmt.Println(v)
:simple-github: Source codesync.CondForgetting about
(#72)sync.Cond???+ info "TL;DR"
You can send repeated notifications to multiple goroutines with
.errgroupNot using
(#73)errgroup???+ info "TL;DR"
You can synchronize a group of goroutines and handle errors and contexts with the
package.syncCopying a
type (#74)sync???+ info "TL;DR"
types shouldn’t be copied.time.DurationStandard Library
Providing a wrong time duration (#75)
???+ info "TL;DR"
Remain cautious with functions accepting a
. Even though passing an integer is allowed, strive to use the time API to prevent any possible confusion.time.DurationMany common functions in the standard library accept a
, which is an alias for theint64type. However, onetime.Durationunit represents one nanosecond, instead of one millisecond, as commonly seen in other programming languages. As a result, passing numeric types instead of using thetime.DurationAPI can lead to unexpected behavior.time.TickerA developer with experience in other languages might assume that the following code creates a new
that delivers ticks every second, given the value1000:
ticker := time.NewTicker(1000)
for {
select {
case <-ticker.C:
// Do something
}
}
However, because 1,000time.Durationunits = 1,000 nanoseconds, ticks are delivered every 1,000 nanoseconds = 1 microsecond, not every second as assumed.time.DurationWe should always use the
API to avoid confusion and unexpected behavior:
ticker = time.NewTicker(time.Microsecond)
// Or
ticker = time.NewTicker(1000 * time.Nanosecond)
:simple-github: Source codetime.Afterand memory leaks (#76)json.Marshaler???+ warning
This mistake isn't relevant anymore from Go 1.23 (details).
JSON handling common mistakes (#77)
* Unexpected behavior because of type embedding
Be careful about using embedded fields in Go structs. Doing so may lead to sneaky bugs like an embedded time.Time field implementing the
interface, hence overriding the default marshaling behavior.time.Time* JSON and the monotonic clock
When comparing two
structs, recall thattime.Timecontains both a wall clock and a monotonic clock, and the comparison using the == operator is done on both clocks.any* Map of
float64To avoid wrong assumptions when you provide a map while unmarshaling JSON data, remember that numerics are converted to
by default.sql.OpenCommon SQL mistakes (#78)
* Forgetting that
doesn't necessarily establish connections to a databasePingCall the
orPingContextmethod if you need to test your configuration and make sure a database is reachable.sql.NullXXX* Forgetting about connections pooling
Configure the database connection parameters for production-grade applications.
* Not using prepared statements
Using SQL prepared statements makes queries more efficient and more secure.
* Mishandling null values
Deal with nullable columns in tables using pointers or
types.Err* Not handling rows iteration errors
Call the
method ofsql.Rowsafter row iterations to ensure that you haven’t missed an error while preparing the next row.sql.RowsNot closing transient resources (HTTP body,
, andos.File) (#79)os.File) (#79)')" title="Copy section prompt for LLMs"> Copy Sectionio.Closer???+ info "TL;DR"
Eventually close all structs implementing
to avoid possible leaks.returnForgetting the return statement after replying to an HTTP request (#80)
???+ info "TL;DR"
To avoid unexpected behaviors in HTTP handler implementations, make sure you don’t miss the
statement if you want a handler to stop afterhttp.Error.fooConsider the following HTTP handler that handles an error from
usinghttp.Error:
func handler(w http.ResponseWriter, req *http.Request) {
err := foo(req)
if err != nil {
http.Error(w, "foo", http.StatusInternalServerError)
}
_, _ = w.Write([]byte("all good"))
w.WriteHeader(http.StatusCreated)
}
If we run this code anderr != nil, the HTTP response would be:
foo
all good
The response contains both the error and success messages, and also the first HTTP status code, 500. There would also be a warning log indicating that we attempted to write the status code multiple times:2023/10/10 16:45:33 http: superfluous response.WriteHeader call from main.handler (main.go:20)
The mistake in this code is thathttp.Errordoes not stop the handler's execution, which means the success message and status code get written in addition to the error. Beyond an incorrect response, failing to return after writing an error can lead to the unwanted execution of code and unexpected side-effects. The following code adds thereturnstatement following thehttp.Errorand exhibits the desired behavior when ran:
func handler(w http.ResponseWriter, req *http.Request) {
err := foo(req)
if err != nil {
http.Error(w, "foo", http.StatusInternalServerError)
return // Adds the return statement
}
_, _ = w.Write([]byte("all good"))
w.WriteHeader(http.StatusCreated)
}
:simple-github: Source code-raceUsing the default HTTP client and server (#81)
???+ info "TL;DR"
For production-grade applications, don’t use the default HTTP client and server implementations. These implementations are missing timeouts and behaviors that should be mandatory in production.
Testing
Not categorizing tests (build tags, environment variables, and short mode) (#82)
???+ info "TL;DR"
Categorizing tests using build flags, environment variables, or short mode makes the testing process more efficient. You can create test categories using build flags or environment variables (for example, unit versus integration tests) and differentiate short from long-running tests to decide which kinds of tests to execute.
Not enabling the race flag (#83)
???+ info "TL;DR"
Enabling the
flag is highly recommended when writing concurrent applications. Doing so allows you to catch potential data races that can lead to software bugs.In Go, the race detector isn’t a static analysis tool used during compilation; instead, it’s a tool to find data races that occur at runtime. To enable it, we have to enable the -race flag while compiling or running a test. For example:
go test -race ./...
Once the race detector is enabled, the compiler instruments the code to detect data races. Instrumentation refers to a compiler adding extra instructions: here, tracking all memory accesses and recording when and how they occur.Enabling the race detector adds an overhead in terms of memory and execution time; hence, it's generally recommended to enable it only during local testing or continuous integration, not production.
If a race is detected, Go raises a warning. For example:
package main
import (
"fmt"
)
func main() {
i := 0
go func() { i++ }()
fmt.Println(i)
}
Running this code with the-racelogs the following warning:
==================
WARNING: DATA RACE
Write at 0x00c000026078 by goroutine 7: # (1)
main.main.func1()
/tmp/app/main.go:9 +0x4e
Previous read at 0x00c000026078 by main goroutine: # (2)
main.main()
/tmp/app/main.go:10 +0x88
Goroutine 7 (running) created at: # (3)
main.main()
/tmp/app/main.go:9 +0x7a
==================
1. Indicates that goroutine 7 was writing!race
2. Indicates that the main goroutine was reading
3. Indicates when the goroutine 7 was createdLet’s make sure we are comfortable reading these messages. Go always logs the following:
* The concurrent goroutines that are incriminated: here, the main goroutine and goroutine 7.
* Where accesses occur in the code: in this case, lines 9 and 10.
* When these goroutines were created: goroutine 7 was created in main().In addition, if a specific file contains tests that lead to data races, we can exclude it :material-information-outline:{ title="temporarily! 😉" } from race detection using the
build tag:
//go:build !race
package main
import (
"testing"
)
func TestFoo(t *testing.T) {
// ...
}
-parallelNot using test execution modes (parallel and shuffle) (#84)
???+ info "TL;DR"
Using the
flag is an efficient way to speed up tests, especially long-running ones. Use the-shuffleflag to help ensure that a test suite doesn’t rely on wrong assumptions that could hide bugs.httptestNot using table-driven tests (#85)
???+ info "TL;DR"
Table-driven tests are an efficient way to group a set of similar tests to prevent code duplication and make future updates easier to handle.
Sleeping in unit tests (#86)
???+ info "TL;DR"
Avoid sleeps using synchronization to make a test less flaky and more robust. If synchronization isn’t possible, consider a retry approach.
Not dealing with the time API efficiently (#87)
???+ info "TL;DR"
Understanding how to deal with functions using the time API is another way to make a test less flaky. You can use standard techniques such as handling the time as part of a hidden dependency or asking clients to provide it.
Not using testing utility packages (
andiotest) (#88)iotest) (#88)')" title="Copy section prompt for LLMs"> Copy Sectionhttptest* The
package is helpful for dealing with HTTP applications. It provides a set of utilities to test both clients and servers.iotest* The
package helps write io.Reader and test that an application is tolerant to errors.-coverprofileWriting inaccurate benchmarks (#89)
???+ info "TL;DR"
Regarding benchmarks:
* Use time methods to preserve the accuracy of a benchmark.
* Increasing benchtime or using tools such as benchstat can be helpful when dealing with micro-benchmarks.
* Be careful with the results of a micro-benchmark if the system that ends up running the application is different from the one running the micro-benchmark.
* Make sure the function under test leads to a side effect, to prevent compiler optimizations from fooling you about the benchmark results.
* To prevent the observer effect, force a benchmark to re-create the data used by a CPU-bound function.Read the full section here.
Not exploring all the Go testing features (#90)
* Code coverage
Use code coverage with the
flag to quickly see which part of the code needs more attention.*testing.T* Testing from a different package
Place unit tests in a different package to enforce writing tests that focus on an exposed behavior, not internals.
* Utility functions
Handling errors using the
variable instead of the classicif err != nilmakes code shorter and easier to read.sync.Pool* Setup and teardown
You can use setup and teardown functions to configure a complex environment, such as in the case of integration tests.
Not using fuzzing (community mistake)
???+ info "TL;DR"
Fuzzing is an efficient strategy to detect random, unexpected, or malformed inputs to complex functions and methods in order to discover vulnerabilities, bugs, or even potential crashes.
Credits: @jeromedoucet
Optimizations
Not understanding CPU caches (#91)
* CPU architecture
Understanding how to use CPU caches is important for optimizing CPU-bound applications because the L1 cache is about 50 to 100 times faster than the main memory.
* Cache line
Being conscious of the cache line concept is critical to understanding how to organize data in data-intensive applications. A CPU doesn’t fetch memory word by word; instead, it usually copies a memory block to a 64-byte cache line. To get the most out of each individual cache line, enforce spatial locality.
* Slice of structs vs. struct of slices
* Predictability
Making code predictable for the CPU can also be an efficient way to optimize certain functions. For example, a unit or constant stride is predictable for the CPU, but a non-unit stride (for example, a linked list) isn’t predictable.
* Cache placement policy
To avoid a critical stride, hence utilizing only a tiny portion of the cache, be aware that caches are partitioned.
Writing concurrent code that leads to false sharing (#92)
???+ info "TL;DR"
Knowing that lower levels of CPU caches aren’t shared across all the cores helps avoid performance-degrading patterns such as false sharing while writing concurrency code. Sharing memory is an illusion.
Read the full section here.
Not taking into account instruction-level parallelism (#93)
???+ info "TL;DR"
Use ILP to optimize specific parts of your code to allow a CPU to execute as many parallel instructions as possible. Identifying data hazards is one of the main steps.
Not being aware of data alignment (#94)
???+ info "TL;DR"
You can avoid common mistakes by remembering that in Go, basic types are aligned with their own size. For example, keep in mind that reorganizing the fields of a struct by size in descending order can lead to more compact structs (less memory allocation and potentially a better spatial locality).
Not understanding stack vs. heap (#95)
???+ info "TL;DR"
Understanding the fundamental differences between heap and stack should also be part of your core knowledge when optimizing a Go application. Stack allocations are almost free, whereas heap allocations are slower and rely on the GC to clean the memory.
Not knowing how to reduce allocations (API change, compiler optimizations, and
) (#96)sync.Pool???+ info "TL;DR"
Reducing allocations is also an essential aspect of optimizing a Go application. This can be done in different ways, such as designing the API carefully to prevent sharing up, understanding the common Go compiler optimizations, and using
.implementsNot relying on inlining (#97)
???+ info "TL;DR"
Use the fast-path inlining technique to efficiently reduce the amortized time to call a function.
Not using Go diagnostics tooling (#98)
???+ info "TL;DR"
Rely on profiling and the execution tracer to understand how an application performs and the parts to optimize.
Read the full section here.
Not understanding how the GC works (#99)
???+ info "TL;DR"
Understanding how to tune the GC can lead to multiple benefits such as handling sudden load increases more efficiently.
:warning: Not understanding the impacts of running Go in Docker and Kubernetes (#100)
???+ warning
This mistake isn't relevant anymore from Go 1.25 (details).
???+ tip "The Coder Cafe"
Enjoyed my book? You will enjoy my newsletter too.
> AI is getting better every day. Are you? At The Coder Cafe, we serve fundamental concepts to make you an engineer that AI won't replace. Written by a Google SWE, trusted by thousands of engineers worldwide.
<center><a href="https://thecoder.cafe?rd=100go.co"><img src="../img/thecodercafe.png" alt="" style="width:480px;height:240px;"></a></center>
Community
Thanks to all the contributors:
<a href="https://contrib.rocks/image?repo=teivah/100-go-mistakes">
<img src="https://contrib.rocks/image?repo=teivah/100-go-mistakes" alt="Description of the image">
</a>Powered by
[](https://jb.gg/OpenSource)
---
5 Interface Pollution
---
title: Interface pollution (#5)
comments: true
hide:
- toc
status: new
---Interface pollution
Interfaces are one of the cornerstones of the Go language when designing and structuring our code. However, like many tools or concepts, abusing them is generally not a good idea. Interface pollution is about overwhelming our code with unnecessary abstractions, making it harder to understand. It’s a common mistake made by developers coming from another language with different habits. Before delving into the topic, let’s refresh our minds about Go’s interfaces. Then, we will see when it’s appropriate to use interfaces and when it may be considered pollution.
Concepts
An interface provides a way to specify the behavior of an object. We use interfaces to create common abstractions that multiple objects can implement. What makes Go interfaces so different is that they are satisfied implicitly. There is no explicit keyword like
to mark that an object X implements interface Y.io.ReaderTo understand what makes interfaces so powerful, we will dig into two popular ones from the standard library:
andio.Writer. Theiopackage provides abstractions for I/O primitives. Among these abstractions,io.Readerrelates to reading data from a data source andio.Writerto writing data to a target, as represented in the next figure:io.Reader<figure markdown>
</figure>The
contains a single Read method:
type Reader interface {
Read(p []byte) (n int, err error)
}
Custom implementations of theio.Readerinterface should accept a slice of bytes, filling it with its data and returning either the number of bytes read or an error.io.WriterOn the other hand,
defines a single method, Write:
type Writer interface {
Write(p []byte) (n int, err error)
}
Custom implementations ofio.Writershould write the data coming from a slice to a target and return either the number of bytes written or an error. Therefore, both interfaces provide fundamental abstractions:io.Reader*
reads data from a sourceio.Writer
*writes data to a target*os.FileWhat is the rationale for having these two interfaces in the language? What is the point of creating these abstractions?
Let’s assume we need to implement a function that should copy the content of one file to another. We could create a specific function that would take as input two
. Or, we can choose to create a more generic function usingio.Readerandio.Writerabstractions:
func copySourceToDest(source io.Reader, dest io.Writer) error {
// ...
}
This function would work withos.Fileparameters (asos.Fileimplements bothio.Readerandio.Writer) and any other type that would implement these interfaces. For example, we could create our ownio.Writerthat writes to a database, and the code would remain the same. It increases the genericity of the function; hence, its reusability.stringsFurthermore, writing a unit test for this function is easier because, instead of having to handle files, we can use the
andbytespackages that provide helpful implementations:
func TestCopySourceToDest(t *testing.T) {
const input = "foo"
source := strings.NewReader(input) // Creates an io.Reader
dest := bytes.NewBuffer(make([]byte, 0)) // Creates an io.Writer
err := copySourceToDest(source, dest) // Calls copySourceToDest from a strings.Reader and a bytes.Buffer
if err != nil {
t.FailNow()
}
got := dest.String()
if got != input {
t.Errorf("expected: %s, got: %s", input, got)
}
}
In the example, source is astrings.Reader, whereas dest is abytes.Buffer. Here, we test the behavior ofcopySourceToDestwithout creating any files.io.ReaderWhile designing interfaces, the granularity (how many methods the interface contains) is also something to keep in mind. A known proverb in Go relates to how big an interface should be:
!!! quote "Rob Pike"
The bigger the interface, the weaker the abstraction.
Indeed, adding methods to an interface can decrease its level of reusability.
andio.Writerare powerful abstractions because they cannot get any simpler. Furthermore, we can also combine fine-grained interfaces to create higher-level abstractions. This is the case withio.ReadWriter, which combines the reader and writer behaviors:
type ReadWriter interface {
Reader
Writer
}
???+ notesortAs Einstein said, “_Everything should be made as simple as possible, but no simpler._” Applied to interfaces, this denotes that finding the perfect granularity for an interface isn’t necessarily a straightforward process.
Let’s now discuss common cases where interfaces are recommended.
When to use interfaces
When should we create interfaces in Go? Let’s look at three concrete use cases where interfaces are usually considered to bring value. Note that the goal isn’t to be exhaustive because the more cases we add, the more they would depend on the context. However, these three cases should give us a general idea:
* Common behavior
* Decoupling
* Restricting behaviorCommon behavior
The first option we will discuss is to use interfaces when multiple types implement a common behavior. In such a case, we can factor out the behavior inside an interface. If we look at the standard library, we can find many examples of such a use case. For example, sorting a collection can be factored out via three methods:
* Retrieving the number of elements in the collection
* Reporting whether one element must be sorted before another
* Swapping two elementsHence, the following interface was added to the
package:
type Interface interface {
Len() int // Number of elements
Less(i, j int) bool // Checks two elements
Swap(i, j int) // Swaps two elements
}
This interface has a strong potential for reusability because it encompasses the common behavior to sort any collection that is index-based.sortThroughout the
package, we can find dozens of implementations. If at some point we compute a collection of integers, for example, and we want to sort it, are we necessarily interested in the implementation type? Is it important whether the sorting algorithm is a merge sort or a quicksort? In many cases, we don’t care. Hence, the sorting behavior can be abstracted, and we can depend on thesort.Interface.sortFinding the right abstraction to factor out a behavior can also bring many benefits. For example, the
package provides utility functions that also rely onsort.Interface, such as checking whether a collection is already sorted. For instance:
func IsSorted(data Interface) bool {
n := data.Len()
for i := n - 1; i > 0; i-- {
if data.Less(i, i-1) {
return false
}
}
return true
}
Becausesort.Interfaceis the right level of abstraction, it makes it highly valuable.CreateNewCustomerLet’s now see another main use case when using interfaces.
Decoupling
Another important use case is about decoupling our code from an implementation. If we rely on an abstraction instead of a concrete implementation, the implementation itself can be replaced with another without even having to change our code. This is the Liskov Substitution Principle (the L in Robert C. Martin’s SOLID design principles).
One benefit of decoupling can be related to unit testing. Let’s assume we want to implement a
method that creates a new customer and stores it. We decide to rely on the concrete implementation directly (let’s say amysql.Storestruct):
type CustomerService struct {
store mysql.Store // Depends on the concrete implementation
}
func (cs CustomerService) CreateNewCustomer(id string) error {
customer := Customer{id: id}
return cs.store.StoreCustomer(customer)
}
Now, what if we want to test this method? BecausecustomerServicerelies on the actual implementation to store aCustomer, we are obliged to test it through integration tests, which requires spinning up a MySQL instance (unless we use an alternative technique such asgo-sqlmock, but this isn’t the scope of this section). Although integration tests are helpful, that’s not always what we want to do. To give us more flexibility, we should decoupleCustomerServicefrom the actual implementation, which can be done via an interface like so:
type customerStorer interface { // Creates a storage abstraction
StoreCustomer(Customer) error
}
type CustomerService struct {
storer customerStorer // Decouples CustomerService from the actual implementation
}
func (cs CustomerService) CreateNewCustomer(id string) error {
customer := Customer{id: id}
return cs.storer.StoreCustomer(customer)
}
Because storing a customer is now done via an interface, this gives us more flexibility in how we want to test the method. For instance, we can:int* Use the concrete implementation via integration tests
* Use a mock (or any kind of test double) via unit tests
* Or bothLet’s now discuss another use case: to restrict a behavior.
Restricting behavior
The last use case we will discuss can be pretty counterintuitive at first sight. It’s about restricting a type to a specific behavior. Let’s imagine we implement a custom configuration package to deal with dynamic configuration. We create a specific container for
configurations via anIntConfigstruct that also exposes two methods:GetandSet. Here’s how that code would look:
type IntConfig struct {
// ...
}
func (c *IntConfig) Get() int {
// Retrieve configuration
}
func (c *IntConfig) Set(value int) {
// Update configuration
}
Now, suppose we receive anIntConfigthat holds some specific configuration, such as a threshold. Yet, in our code, we are only interested in retrieving the configuration value, and we want to prevent updating it. How can we enforce that, semantically, this configuration is read-only, if we don’t want to change our configuration package? By creating an abstraction that restricts the behavior to retrieving only a config value:
type intConfigGetter interface {
Get() int
}
Then, in our code, we can rely onintConfigGetterinstead of the concrete implementation:
type Foo struct {
threshold intConfigGetter
}
func NewFoo(threshold intConfigGetter) Foo { // Injects the configuration getter
return Foo{threshold: threshold}
}
func (f Foo) Bar() {
threshold := f.threshold.Get() // Reads the configuration
// ...
}
In this example, the configuration getter is injected into theNewFoofactory method. It doesn’t impact a client of this function because it can still pass anIntConfigstruct as it implementsintConfigGetter. Then, we can only read the configuration in theBarmethod, not modify it. Therefore, we can also use interfaces to restrict a type to a specific behavior for various reasons, such as semantics enforcement.map[string]intIn this section, we saw three potential use cases where interfaces are generally considered as bringing value: factoring out a common behavior, creating some decoupling, and restricting a type to a certain behavior. Again, this list isn’t exhaustive, but it should give us a general understanding of when interfaces are helpful in Go.
Now, let’s finish this section and discuss the problems with interface pollution.
Interface pollution
It’s fairly common to see interfaces being overused in Go projects. Perhaps the developer’s background was C# or Java, and they found it natural to create interfaces before concrete types. However, this isn’t how things should work in Go.
As we discussed, interfaces are made to create abstractions. And the main caveat when programming meets abstractions is remembering that abstractions should be discovered, not created. What does this mean? It means we shouldn’t start creating abstractions in our code if there is no immediate reason to do so. We shouldn’t design with interfaces but wait for a concrete need. Said differently, we should create an interface when we need it, not when we foresee that we could need it.
What’s the main problem if we overuse interfaces? The answer is that they make the code flow more complex. Adding a useless level of indirection doesn’t bring any value; it creates a worthless abstraction making the code more difficult to read, understand, and reason about. If we don’t have a strong reason for adding an interface and it’s unclear how an interface makes a code better, we should challenge this interface’s purpose. Why not call the implementation directly?
???+ note
We may also experience performance overhead when calling a method through an interface. It requires a lookup in a hash table’s data structure to find the concrete type an interface points to. But this isn’t an issue in many contexts as the overhead is minimal.
In summary, we should be cautious when creating abstractions in our code—abstractions should be discovered, not created. It’s common for us, software developers, to overengineer our code by trying to guess what the perfect level of abstraction is, based on what we think we might need later. This process should be avoided because, in most cases, it pollutes our code with unnecessary abstractions, making it more complex to read.
!!! quote "Rob Pike"
Don’t design with interfaces, discover them.
Let’s not try to solve a problem abstractly but solve what has to be solved now. Last, but not least, if it’s unclear how an interface makes the code better, we should probably consider removing it to make our code simpler.
---
9 Generics
---
title: Being confused about when to use generics (#9)
comments: true
hide:
- toc
---Being confused about when to use generics
Generics is a fresh addition to the language. In a nutshell, it allows writing code with types that can be specified later and instantiated when needed. However, it can be pretty easy to be confused about when to use generics and when not to. Throughout this post, we will describe the concept of generics in Go and then delve into common use and misuses.
Concepts
Consider the following function that extracts all the keys from a
type:
func getKeys(m map[string]int) []string {
var keys []string
for k := range m {
keys = append(keys, k)
}
return keys
}
What if we would like to use a similar feature for another map type such as amap[int]string? Before generics, Go developers had a couple of options: using code generation, reflection, or duplicating code.getKeysFor example, we could write two functions, one for each map type, or even try to extend
to accept different map types:
func getKeys(m any) ([]any, error) {
switch t := m.(type) {
default:
return nil, fmt.Errorf("unknown type: %T", t)
case map[string]int:
var keys []any
for k := range t {
keys = append(keys, k)
}
return keys, nil
case map[int]string:
// Copy the extraction logic
}
}
We can start noticing a couple of issues:range* First, it increases boilerplate code. Indeed, whenever we want to add a case, it will require duplicating the
loop.int
* Meanwhile, the function now accepts an empty interface, which means we are losing some of the benefits of Go being a typed language. Indeed, checking whether a type is supported is done at runtime instead of compile-time. Hence, we also need to return an error if the provided type is unknown.
* Last but not least, as the key type can be eitherorstring, we are obliged to return a slice of empty interfaces to factor out key types. This approach increases the effort on the caller-side as the client may also have to perform a type check of the keys or extra conversion.Thanks to generics, we can now refactor this code using type parameters.
Type parameters are generic types we can use with functions and types. For example, the following function accepts a type parameter:
func fooT any {
// ...
}
When callingfoo, we will pass a type argument of any type. Passing a type argument is called instantiation because the work is done at compile time which keeps type safety as part of the core language features and avoids runtime overheads.getKeysLet’s get back to the
function and use type parameters to write a generic version that would accept any kind of map:
func getKeysK comparable, V any []K {
var keys []K
for k := range m {
keys = append(keys, k)
}
return keys
}
To handle the map, we defined two kinds of type parameters. First, the values can be of any type:V any. However, in Go, the map keys can’t be of any type. For example, we cannot use slices:
var m map[[]byte]int
This code leads to a compilation error:invalid map key type []byte. Therefore, instead of accepting any key type, we are obliged to restrict type arguments so that the key type meets specific requirements. Here, being comparable (we can use==or!=). Hence, we definedKascomparableinstead ofany.comparableRestricting type arguments to match specific requirements is called a constraint. A constraint is an interface type that can contain:
* A set of behaviors (methods)
* But also arbitrary typeLet’s see a concrete example for the latter. Imagine we don’t want to accept any
type for map key type. For instance, we would like to restrict it to eitherintorstringtypes. We can define a custom constraint this way:
type customConstraint interface {
~int | ~string // Define a custom type that will restrict types to int and string
}
// Change the type parameter K to be custom
func getKeysK customConstraint, V any []K {
// Same implementation
}
First, we define acustomConstraintinterface to restrict the types to be eitherintorstringusing the union operator|(we will discuss the use of~a bit later). Then,Kis now acustomConstraintinstead of acomparableas before.getKeysNow, the signature of
enforces that we can call it with a map of any value type, but the key type has to be anintor astring. For example, on the caller-side:
m = map[string]int{
"one": 1,
"two": 2,
"three": 3,
}
keys := getKeys(m)
Note that Go can infer thatgetKeysis called with astringtype argument. The previous call was similar to this:
keys := getKeysstring
???+ note~intWhat’s the difference between a constraint using
orint? Usingintrestricts it to that type, whereas~intrestricts all the types whose underlying type is anint.intTo illustrate it, let’s imagine a constraint where we would like to restrict a type to any
type implementing theString() stringmethod:
type customConstraint interface {
~int
String() string
}
Using this constraint will restrict type arguments to custom types like this one:type customInt int
func (i customInt) String() string {
return strconv.Itoa(int(i))
}
AscustomIntis anintand implements theString() stringmethod, thecustomInttype satisfies the constraint defined.intHowever, if we change the constraint to contain an
instead of an~int, usingcustomIntwould lead to a compilation error because theinttype doesn’t implementString() string.constraintsLet’s also note the
package contains a set of common constraints such asSignedthat includes all the signed integer types. Let’s ensure that a constraint doesn’t already exist in this package before creating a new one.AddSo far, we have discussed examples using generics for functions. However, we can also use generics with data structures.
For example, we will create a linked list containing values of any type. Meanwhile, we will write an
method to append a node:
type Node[T any] struct { // Use type parameter
Val T
next *Node[T]
}
func (n Node[T]) Add(next Node[T]) { // Instantiate type receiver
n.next = next
}
We use type parameters to defineTand use both fields inNode. Regarding the method, the receiver is instantiated. Indeed, becauseNodeis generic, it has to follow also the type parameter defined.One last thing to note about type parameters: they can’t be used on methods, only on functions. For example, the following method wouldn’t compile:
type Foo struct {}
func (Foo) barT any {}
./main.go:29:15: methods cannot have type parameters
Now, let’s delve into concrete cases where we should and shouldn’t use generics.Common uses and misuses
So when are generics useful? Let’s discuss a couple of common uses where generics are recommended:
* Data structures. For example, we can use generics to factor out the element type if we implement a binary tree, a linked list, or a heap.
* Functions working with slices, maps, and channels of any type. For example, a function to merge two channels would work with any channel type. Hence, we could use type parameters to factor out the channel type:
func mergeT any <-chan T {
// ...
}
* Meanwhile, instead of factoring out a type, we can factor out behaviors. For example, thesortpackage contains functions to sort different slice types such assort.Intsorsort.Float64s. Using type parameters, we can factor out the sorting behaviors that rely on three methods,Len,Less, andSwap:
type sliceFn[T any] struct { // Use type parameter
s []T
compare func(T, T) bool // Compare two T elements
}
func (s sliceFn[T]) Len() int { return len(s.s) }
func (s sliceFn[T]) Less(i, j int) bool { return s.compare(s.s[i], s.s[j]) }
func (s sliceFn[T]) Swap(i, j int) { s.s[i], s.s[j] = s.s[j], s.s[i] }
Conversely, when is it recommended not to use generics?io.Writer* When just calling a method of the type argument. For example, consider a function that receives an
and call theWritemethod:
func fooT io.Writer {
b := getBytes()
_, _ = w.Write(b)
}
* When it makes our code more complex. Generics are never mandatory, and as Go developers, we have been able to live without them for more than a decade. If writing generic functions or structures we figure out that it doesn’t make our code clearer, we should probably reconsider our decision for this particular use case.Conclusion
Though generics can be very helpful in particular conditions, we should be cautious about when to use them and not use them.
In general, when we want to answer when not to use generics, we can find similarities with when not to use interfaces. Indeed, generics introduce a form of abstraction, and we have to remember that unnecessary abstractions introduce complexity.
Let’s not pollute our code with needless abstractions, and let’s focus on solving concrete problems for now. It means that we shouldn’t use type parameters prematurely. Let’s wait until we are about to write boilerplate code to consider using generics.
---
20 Slice
---
title: Not understanding slice length and capacity (#20)
comments: true
hide:
- toc
---
Not understanding slice length and capacity
It’s pretty common for Go developers to mix slice length and capacity or not understand them thoroughly. Assimilating these two concepts is essential for efficiently handling core operations such as slice initialization and adding elements with append, copying, or slicing. This misunderstanding can lead to using slices suboptimally or even to memory leaks.
In Go, a slice is backed by an array. That means the slice’s data is stored contiguously in an array data structure. A slice also handles the logic of adding an element if the backing array is full or shrinking the backing array if it’s almost empty.
Internally, a slice holds a pointer to the backing array plus a length and a capacity. The length is the number of elements the slice contains, whereas the capacity is the number of elements in the backing array, counting from the first element in the slice. Let’s go through a few examples to make things clearer. First, let’s initialize a slice with a given length and capacity:
s := make([]int, 3, 6) // Three-length, six-capacity slice
The first argument, representing the length, is mandatory. However, the second argument representing the capacity is optional. Figure 1 shows the result of this code in memory.make<figure markdown>
<figcaption>Figure 1: A three-length, six-capacity slice.</figcaption>
</figure>In this case,
creates an array of six elements (the capacity). But because the length was set to 3, Go initializes only the first three elements. Also, because the slice is an[]inttype, the first three elements are initialized to the zeroed value of anint: 0. The grayed elements are allocated but not yet used.[0 0 0]If we print this slice, we get the elements within the range of the length,
. If we sets[1]to 1, the second element of the slice updates without impacting its length or capacity. Figure 2 illustrates this.s[4]<figure markdown>
<figcaption>Figure 2: Updating the slice’s second element: s[1] = 1.</figcaption>
</figure>However, accessing an element outside the length range is forbidden, even though it’s already allocated in memory. For example,
= 0 would lead to the following panic:
panic: runtime error: index out of range [4] with length 3
How can we use the remaining space of the slice? By using theappendbuilt-in function:
s = append(s, 2)
This code appends to the existingsslice a new element. It uses the first grayed element (which was allocated but not yet used) to store element 2, as figure 3 shows.<figure markdown>
<figcaption>Figure 3: Appending an element to s.</figcaption>
</figure>The length of the slice is updated from 3 to 4 because the slice now contains four elements. Now, what happens if we add three more elements so that the backing array isn’t large enough?
s = append(s, 3, 4, 5)
fmt.Println(s)
If we run this code, we see that the slice was able to cope with our request:[0 1 0 2 3 4 5]
Because an array is a fixed-size structure, it can store the new elements until element 4. When we want to insert element 5, the array is already full: Go internally creates another array by doubling the capacity, copying all the elements, and then inserting element 5. Figure 4 shows this process.<figure markdown>
<figcaption>Figure 4: Because the initial backing array is full, Go creates another array and copies all the elements.</figcaption>
</figure>
The slice now references the new backing array. What will happen to the previous backing array? If it’s no longer referenced, it’s eventually freed by the garbage collector (GC) if allocated on the heap. (We discuss heap memory in mistake #95, “Not understanding stack vs. heap,” and we look at how the GC works in mistake #99, “Not understanding how the GC works.”)
What happens with slicing? Slicing is an operation done on an array or a slice, providing a half-open range; the first index is included, whereas the second is excluded. The following example shows the impact, and figure 5 displays the result in memory:
s1 := make([]int, 3, 6) // Three-length, six-capacity slice
s2 := s1[1:3] // Slicing from indices 1 to 3
<figure markdown>s1
<figcaption>Figure 5: The slices s1 and s2 reference the same backing array with different lengths and capacities.</figcaption>
</figure>First,
is created as a three-length, six-capacity slice. Whens2is created by slicings1, both slices reference the same backing array. However,s2starts from a different index, 1. Therefore, its length and capacity (a two-length, five-capacity slice) differ from s1. If we updates1[1]ors2[0], the change is made to the same array, hence, visible in both slices, as figure 6 shows.s2<figure markdown>
<figcaption>Figure 6: Because s1 and s2 are backed by the same array, updating a common element makes the change visible in both slices.</figcaption>
</figure>Now, what happens if we append an element to
? Does the following code changes1as well?
s2 = append(s2, 2)
The shared backing array is modified, but only the length ofs2changes. Figure 7 shows the result of appending an element tos2.s1<figure markdown>
<figcaption>Figure 7: Appending an element to s2.</figcaption>
</figure>remains a three-length, six-capacity slice. Therefore, if we prints1ands2, the added element is only visible fors2:
s1=[0 1 0], s2=[1 0 2]
It’s important to understand this behavior so that we don’t make wrong assumptions while using append.s2???+ note
In these examples, the backing array is internal and not available directly to the Go developer. The only exception is when a slice is created from slicing an existing array.
One last thing to note: what if we keep appending elements to
until the backing array is full? What will the state be, memory-wise? Let’s add three more elements so that the backing array will not have enough capacity:
s2 = append(s2, 3)
s2 = append(s2, 4) // At this stage, the backing is already full
s2 = append(s2, 5)
This code leads to creating another backing array. Figure 8 displays the results in memory.s1<figure markdown>
<figcaption>Figure 8: Appending elements to s2 until the backing array is full.</figcaption>
</figure>ands2now reference two different arrays. Ass1is still a three-length, six-capacity slice, it still has some available buffer, so it keeps referencing the initial array. Also, the new backing array was made by copying the initial one from the first index ofs2. That’s why the new array starts with element 1, not 0.To summarize, the slice length is the number of available elements in the slice, whereas the slice capacity is the number of elements in the backing array. Adding an element to a full slice (length == capacity) leads to creating a new backing array with a new capacity, copying all the elements from the previous array, and updating the slice pointer to the new array.
---
28 Maps Memory Leaks
---
title: Maps and memory leaks (#28)
comments: true
hide:
- toc
---Maps and memory leaks
When working with maps in Go, we need to understand some important characteristics of how a map grows and shrinks. Let’s delve into this to prevent issues that can cause memory leaks.
First, to view a concrete example of this problem, let’s design a scenario where we will work with the following map:
m := make(map[int][128]byte)
Each value of m is an array of 128 bytes. We will do the following:printAlloc1. Allocate an empty map.
2. Add 1 million elements.
3. Remove all the elements, and run a Garbage Collection (GC).After each step, we want to print the size of the heap (using a
utility function). This shows us how this example behaves memory-wise:
func main() {
n := 1_000_000
m := make(map[int][128]byte)
printAlloc()
for i := 0; i < n; i++ { // Adds 1 million elements
m[i] = [128]byte{}
}
printAlloc()
for i := 0; i < n; i++ { // Deletes 1 million elements
delete(m, i)
}
runtime.GC() // Triggers a manual GC
printAlloc()
runtime.KeepAlive(m) // Keeps a reference to m so that the map isn’t collected
}
func printAlloc() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("%d MB\n", m.Alloc/(1024*1024))
}
We allocate an empty map, add 1 million elements, remove 1 million elements, and then run a GC. We also make sure to keep a reference to the map usingruntime.KeepAliveso that the map isn’t collected as well. Let’s run this example:
0 MB <-- After m is allocated
461 MB <-- After we add 1 million elements
293 MB <-- After we remove 1 million elements
What can we observe? At first, the heap size is minimal. Then it grows significantly after having added 1 million elements to the map. But if we expected the heap size to decrease after removing all the elements, this isn’t how maps work in Go. In the end, even though the GC has collected all the elements, the heap size is still 293 MB. So the memory shrunk, but not as we might have expected. What’s the rationale? We need to delve into how a map works in Go.A map provides an unordered collection of key-value pairs in which all the keys are distinct. In Go, a map is based on the hash table data structure: an array where each element is a pointer to a bucket of key-value pairs, as shown in figure 1.
<figure markdown>
<figcaption>Figure 1: A hash table example with a focus on bucket 0.</figcaption>
</figure>
Each bucket is a fixed-size array of eight elements. In the case of an insertion into a bucket that is already full (a bucket overflow), Go creates another bucket of eight elements and links the previous one to it. Figure 2 shows an example:
<figure markdown>
<figcaption>Figure 2: In case of a bucket overflow, Go allocates a new bucket and links the previous bucket to it.</figcaption>
</figure>
Under the hood, a Go map is a pointer to a runtime.hmap struct. This struct contains multiple fields, including a B field, giving the number of buckets in the map:
type hmap struct {
B uint8 // log_2 of # of buckets
// (can hold up to loadFactor * 2^B items)
// ...
}
After adding 1 million elements, the value of B equals 18, which means 2¹⁸ = 262,144 buckets. When we remove 1 million elements, what’s the value of B? Still 18. Hence, the map still contains the same number of buckets.map[int][128]byteThe reason is that the number of buckets in a map cannot shrink. Therefore, removing elements from a map doesn’t impact the number of existing buckets; it just zeroes the slots in the buckets. A map can only grow and have more buckets; it never shrinks.
In the previous example, we went from 461 MB to 293 MB because the elements were collected, but running the GC didn’t impact the map itself. Even the number of extra buckets (the buckets created because of overflows) remains the same.
Let’s take a step back and discuss when the fact that a map cannot shrink can be a problem. Imagine building a cache using a
. This map holds per customer ID (theint), a sequence of 128 bytes. Now, suppose we want to save the last 1,000 customers. The map size will remain constant, so we shouldn’t worry about the fact that a map cannot shrink.map[int]*[128]byteHowever, let’s say we want to store one hour of data. Meanwhile, our company has decided to have a big promotion for Black Friday: in one hour, we may have millions of customers connected to our system. But a few days after Black Friday, our map will contain the same number of buckets as during the peak time. This explains why we can experience high memory consumption that doesn’t significantly decrease in such a scenario.
What are the solutions if we don’t want to manually restart our service to clean the amount of memory consumed by the map? One solution could be to re-create a copy of the current map at a regular pace. For example, every hour, we can build a new map, copy all the elements, and release the previous one. The main drawback of this option is that following the copy and until the next garbage collection, we may consume twice the current memory for a short period.
Another solution would be to change the map type to store an array pointer:
. It doesn’t solve the fact that we will have a significant number of buckets; however, each bucket entry will reserve the size of a pointer for the value instead of 128 bytes (8 bytes on 64-bit systems and 4 bytes on 32-bit systems).map[int][128]byteComing back to the original scenario, let’s compare the memory consumption for each map type following each step. The following table shows the comparison.
| Step |
|map[int]*[128]byte|
|---|---|---|
| Allocate an empty map | 0 MB | 0 MB |
| Add 1 million elements | 461 MB | 182 MB |
| Remove all the elements and run a GC | 293 MB | 38 MB |???+ note
If a key or a value is over 128 bytes, Go won’t store it directly in the map bucket. Instead, Go stores a pointer to reference the key or the value.
As we have seen, adding n elements to a map and then deleting all the elements means keeping the same number of buckets in memory. So, we must remember that because a Go map can only grow in size, so does its memory consumption. There is no automated strategy to shrink it. If this leads to high memory consumption, we can try different options such as forcing Go to re-create the map or using pointers to check if it can be optimized.
---
56 Concurrency Faster
---
title: Thinking concurrency is always faster (#56)
comments: true
hide:
- toc
---Thinking concurrency is always faster
A misconception among many developers is believing that a concurrent solution is always faster than a sequential one. This couldn’t be more wrong. The overall performance of a solution depends on many factors, such as the efficiency of our code structure (concurrency), which parts can be tackled in parallel, and the level of contention among the computation units. This post reminds us about some fundamental knowledge of concurrency in Go; then we will see a concrete example where a concurrent solution isn’t necessarily faster.
Go Scheduling
A thread is the smallest unit of processing that an OS can perform. If a process wants to execute multiple actions simultaneously, it spins up multiple threads. These threads can be:
* _Concurrent_ — Two or more threads can start, run, and complete in overlapping time periods.
* _Parallel_ — The same task can be executed multiple times at once.The OS is responsible for scheduling the thread’s processes optimally so that:
* All the threads can consume CPU cycles without being starved for too much time.
* The workload is distributed as evenly as possible among the different CPU cores.???+ note
The word thread can also have a different meaning at a CPU level. Each physical core can be composed of multiple logical cores (the concept of hyper-threading), and a logical core is also called a thread. In this post, when we use the word thread, we mean the unit of processing, not a logical core.
A CPU core executes different threads. When it switches from one thread to another, it executes an operation called _context switching_. The active thread consuming CPU cycles was in an _executing_ state and moves to a _runnable_ state, meaning it’s ready to be executed pending an available core. Context switching is considered an expensive operation because the OS needs to save the current execution state of a thread before the switch (such as the current register values).
As Go developers, we can’t create threads directly, but we can create goroutines, which can be thought of as application-level threads. However, whereas an OS thread is context-switched on and off a CPU core by the OS, a goroutine is context-switched on and off an OS thread by the Go runtime. Also, compared to an OS thread, a goroutine has a smaller memory footprint: 2 KB for goroutines from Go 1.4. An OS thread depends on the OS, but, for example, on Linux/x86–32, the default size is 2 MB (see https://man7.org/linux/man-pages/man3/pthread_create.3.html). Having a smaller size makes context switching faster.
???+ note
Context switching a goroutine versus a thread is about 80% to 90% faster, depending on the architecture.
Let’s now discuss how the Go scheduler works to overview how goroutines are handled. Internally, the Go scheduler uses the following terminology (see proc.go):
* _G_ — Goroutine
* _M_ — OS thread (stands for machine)
* _P_ — CPU core (stands for processor)Each OS thread (M) is assigned to a CPU core (P) by the OS scheduler. Then, each goroutine (G) runs on an M. The GOMAXPROCS variable defines the limit of Ms in charge of executing user-level code simultaneously. But if a thread is blocked in a system call (for example, I/O), the scheduler can spin up more Ms. As of Go 1.5, GOMAXPROCS is by default equal to the number of available CPU cores.
A goroutine has a simpler lifecycle than an OS thread. It can be doing one of the following:
* _Executing_ — The goroutine is scheduled on an M and executing its instructions.
* _Runnable_ — The goroutine is waiting to be in an executing state.
* _Waiting_ — The goroutine is stopped and pending something completing, such as a system call or a synchronization operation (such as acquiring a mutex).There’s one last stage to understand about the implementation of Go scheduling: when a goroutine is created but cannot be executed yet; for example, all the other Ms are already executing a G. In this scenario, what will the Go runtime do about it? The answer is queuing. The Go runtime handles two kinds of queues: one local queue per P and a global queue shared among all the Ps.
Figure 1 shows a given scheduling situation on a four-core machine with GOMAXPROCS equal to 4. The parts are the logical cores (Ps), goroutines (Gs), OS threads (Ms), local queues, and global queue:
<figure markdown>
<figcaption>Figure 1: An example of the current state of a Go application executed on a four-core machine. Goroutines that aren’t in an executing state are either runnable (pending being executed) or waiting (pending a blocking operation)</figcaption>
</figure>First, we can see five Ms, whereas GOMAXPROCS is set to 4. But as we mentioned, if needed, the Go runtime can create more OS threads than the GOMAXPROCS value.
P0, P1, and P3 are currently busy executing Go runtime threads. But P2 is presently idle as M3 is switched off P2, and there’s no goroutine to be executed. This isn’t a good situation because six runnable goroutines are pending being executed, some in the global queue and some in other local queues. How will the Go runtime handle this situation? Here’s the scheduling implementation in pseudocode (see proc.go):
runtime.schedule() {
// Only 1/61 of the time, check the global runnable queue for a G.
// If not found, check the local queue.
// If not found,
// Try to steal from other Ps.
// If not, check the global runnable queue.
// If not found, poll network.
}
Every sixty-first execution, the Go scheduler will check whether goroutines from the global queue are available. If not, it will check its local queue. Meanwhile, if both the global and local queues are empty, the Go scheduler can pick up goroutines from other local queues. This principle in scheduling is called _work stealing_, and it allows an underutilized processor to actively look for another processor’s goroutines and _steal_ some.One last important thing to mention: prior to Go 1.14, the scheduler was cooperative, which meant a goroutine could be context-switched off a thread only in specific blocking cases (for example, channel send or receive, I/O, waiting to acquire a mutex). Since Go 1.14, the Go scheduler is now preemptive: when a goroutine is running for a specific amount of time (10 ms), it will be marked preemptible and can be context-switched off to be replaced by another goroutine. This allows a long-running job to be forced to share CPU time.
Now that we understand the fundamentals of scheduling in Go, let’s look at a concrete example: implementing a merge sort in a parallel manner.
Parallel Merge Sort
First, let’s briefly review how the merge sort algorithm works. Then we will implement a parallel version. Note that the objective isn’t to implement the most efficient version but to support a concrete example showing why concurrency isn’t always faster.
The merge sort algorithm works by breaking a list repeatedly into two sublists until each sublist consists of a single element and then merging these sublists so that the result is a sorted list (see figure 2). Each split operation splits the list into two sublists, whereas the merge operation merges two sublists into a sorted list.
<figure markdown>
<figcaption>Figure 2: Applying the merge sort algorithm repeatedly breaks each list into two sublists. Then the algorithm uses a merge operation such that the resulting list is sorted</figcaption>
</figure>
Here is the sequential implementation of this algorithm. We don’t include all of the code as it’s not the main point of this section:
func sequentialMergesort(s []int) {
if len(s) <= 1 {
return
}
middle := len(s) / 2
sequentialMergesort(s[:middle]) // First half
sequentialMergesort(s[middle:]) // Second half
merge(s, middle) // Merges the two halves
}
func merge(s []int, middle int) {
// ...
}
This algorithm has a structure that makes it open to concurrency. Indeed, as each _sequentialMergesort_ operation works on an independent set of data that doesn’t need to be fully copied (here, an independent view of the underlying array using slicing), we could distribute this workload among the CPU cores by spinning up each _sequentialMergesort_ operation in a different goroutine. Let’s write a first parallel implementation:func parallelMergesortV1(s []int) {
if len(s) <= 1 {
return
}
middle := len(s) / 2
var wg sync.WaitGroup
wg.Add(2)
go func() { // Spins up the first half of the work in a goroutine
defer wg.Done()
parallelMergesortV1(s[:middle])
}()
go func() { // Spins up the second half of the work in a goroutine
defer wg.Done()
parallelMergesortV1(s[middle:])
}()
wg.Wait()
merge(s, middle) // Merges the halves
}
In this version, each half of the workload is handled in a separate goroutine. The parent goroutine waits for both parts by using _sync.WaitGroup_. Hence, we call the Wait method before the merge operation.We now have a parallel version of the merge sort algorithm. Therefore, if we run a benchmark to compare this version against the sequential one, the parallel version should be faster, correct? Let’s run it on a four-core machine with 10,000 elements:
Benchmark_sequentialMergesort-4 2278993555 ns/op
Benchmark_parallelMergesortV1-4 17525998709 ns/op
Surprisingly, the parallel version is almost an order of magnitude slower. How can we explain this result? How is it possible that a parallel version that distributes a workload across four cores is slower than a sequential version running on a single machine? Let’s analyze the problem.If we have a slice of, say, 1,024 elements, the parent goroutine will spin up two goroutines, each in charge of handling a half consisting of 512 elements. Each of these goroutines will spin up two new goroutines in charge of handling 256 elements, then 128, and so on, until we spin up a goroutine to compute a single element.
If the workload that we want to parallelize is too small, meaning we’re going to compute it too fast, the benefit of distributing a job across cores is destroyed: the time it takes to create a goroutine and have the scheduler execute it is much too high compared to directly merging a tiny number of items in the current goroutine. Although goroutines are lightweight and faster to start than threads, we can still face cases where a workload is too small.
So what can we conclude from this result? Does it mean the merge sort algorithm cannot be parallelized? Wait, not so fast.
Let’s try another approach. Because merging a tiny number of elements within a new goroutine isn’t efficient, let’s define a threshold. This threshold will represent how many elements a half should contain in order to be handled in a parallel manner. If the number of elements in the half is fewer than this value, we will handle it sequentially. Here’s a new version:
const max = 2048 // Defines the threshold
func parallelMergesortV2(s []int) {
if len(s) <= 1 {
return
}
if len(s) <= max {
sequentialMergesort(s) // Calls our initial sequential version
} else { // If bigger than the threshold, keeps the parallel version
middle := len(s) / 2
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
parallelMergesortV2(s[:middle])
}()
go func() {
defer wg.Done()
parallelMergesortV2(s[middle:])
}()
wg.Wait()
merge(s, middle)
}
}
If the number of elements in the s slice is smaller than max, we call the sequential version. Otherwise, we keep calling our parallel implementation. Does this approach impact the result? Yes, it does:Benchmark_sequentialMergesort-4 2278993555 ns/op
Benchmark_parallelMergesortV1-4 17525998709 ns/op
Benchmark_parallelMergesortV2-4 1313010260 ns/op
Our v2 parallel implementation is more than 40% faster than the sequential one, thanks to this idea of defining a threshold to indicate when parallel should be more efficient than sequential.???+ note
Why did I set the threshold to 2,048? Because it was the optimal value for this specific workload on my machine. In general, such magic values should be defined carefully with benchmarks (running on an execution environment similar to production). It’s also pretty interesting to note that running the same algorithm in a programming language that doesn’t implement the concept of goroutines has an impact on the value. For example, running the same example in Java using threads means an optimal value closer to 8,192. This tends to illustrate how goroutines are more efficient than threads.
Conclusion
We have seen throughout this post the fundamental concepts of scheduling in Go: the differences between a thread and a goroutine and how the Go runtime schedules goroutines. Meanwhile, using the parallel merge sort example, we illustrated that concurrency isn’t always necessarily faster. As we have seen, spinning up goroutines to handle minimal workloads (merging only a small set of elements) demolishes the benefit we could get from parallelism.
So, where should we go from here? We must keep in mind that concurrency isn’t always faster and shouldn’t be considered the default way to go for all problems. First, it makes things more complex. Also, modern CPUs have become incredibly efficient at executing sequential code and predictable code. For example, a superscalar processor can parallelize instruction execution over a single core with high efficiency.
Does this mean we shouldn’t use concurrency? Of course not. However, it’s essential to keep these conclusions in mind. If we’re not sure that a parallel version will be faster, the right approach may be to start with a simple sequential version and build from there using profiling (mistake #98, “Not using Go diagnostics tooling”) and benchmarks (mistake #89, “Writing inaccurate benchmarks”), for example. It can be the only way to ensure that a concurrent implementation is worth it.
---
89 Benchmarks
---
title: Writing inaccurate benchmarks (#89)
comments: true
hide:
- toc
---
Writing inaccurate benchmarks
In general, we should never guess about performance. When writing optimizations, so many factors may come into play that even if we have a strong opinion about the results, it’s rarely a bad idea to test them. However, writing benchmarks isn’t straightforward. It can be pretty simple to write inaccurate benchmarks and make wrong assumptions based on them. The goal of this post is to examine four common and concrete traps leading to inaccuracy:
* Not resetting or pausing the timer
* Making wrong assumptions about micro-benchmarks
* Not being careful about compiler optimizations
* Being fooled by the observer effect
General concepts
Before discussing these traps, let’s briefly review how benchmarks work in Go. The skeleton of a benchmark is as follows:
func BenchmarkFoo(b *testing.B) {
for i := 0; i < b.N; i++ {
foo()
}
}
The function name starts with theBenchmarkprefix. The function under test (foo) is called within theforloop.b.Nrepresents a variable number of iterations. When running a benchmark, Go tries to make it match the requested benchmark time. The benchmark time is set by default to 1 second and can be changed with the-benchtimeflag.b.Nstarts at 1; if the benchmark completes in under 1 second,b.Nis increased, and the benchmark runs again untilb.Nroughly matches benchtime:
$ go test -bench=.
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkFoo-4 73 16511228 ns/op
Here, the benchmark took about 1 second, andfoowas executed 73 times, for an average execution time of 16,511,228 nanoseconds. We can change the benchmark time using-benchtime:
$ go test -bench=. -benchtime=2s
BenchmarkFoo-4 150 15832169 ns/op
foowas executed roughly twice more than during the previous benchmark.Next, let’s look at some common traps.
Not resetting or pausing the timer
In some cases, we need to perform operations before the benchmark loop. These operations may take quite a while (for example, generating a large slice of data) and may significantly impact the benchmark results:
func BenchmarkFoo(b *testing.B) {
expensiveSetup()
for i := 0; i < b.N; i++ {
functionUnderTest()
}
}
In this case, we can use theResetTimermethod before entering the loop:
func BenchmarkFoo(b *testing.B) {
expensiveSetup()
b.ResetTimer() // Reset the benchmark timer
for i := 0; i < b.N; i++ {
functionUnderTest()
}
}
CallingResetTimerzeroes the elapsed benchmark time and memory allocation counters since the beginning of the test. This way, an expensive setup can be discarded from the test results.What if we have to perform an expensive setup not just once but within each loop iteration?
func BenchmarkFoo(b *testing.B) {
for i := 0; i < b.N; i++ {
expensiveSetup()
functionUnderTest()
}
}
We can’t reset the timer, because that would be executed during each loop iteration. But we can stop and resume the benchmark timer, surrounding the call toexpensiveSetup:
func BenchmarkFoo(b *testing.B) {
for i := 0; i < b.N; i++ {
b.StopTimer() // Pause the benchmark timer
expensiveSetup()
b.StartTimer() // Resume the benchmark timer
functionUnderTest()
}
}
Here, we pause the benchmark timer to perform the expensive setup and then resume the timer.benchtime???+ note
There’s one catch to remember about this approach: if the function under test is too fast to execute compared to the setup function, the benchmark may take too long to complete. The reason is that it would take much longer than 1 second to reach
. Calculating the benchmark time is based solely on the execution time offunctionUnderTest. So, if we wait a significant time in each loop iteration, the benchmark will be much slower than 1 second. If we want to keep the benchmark, one possible mitigation is to decreasebenchtime.atomic.StoreInt32We must be sure to use the timer methods to preserve the accuracy of a benchmark.
Making wrong assumptions about micro-benchmarks
A micro-benchmark measures a tiny computation unit, and it can be extremely easy to make wrong assumptions about it. Let’s say, for example, that we aren’t sure whether to use
oratomic.StoreInt64(assuming that the values we handle will always fit in 32 bits). We want to write a benchmark to compare both functions:
func BenchmarkAtomicStoreInt32(b *testing.B) {
var v int32
for i := 0; i < b.N; i++ {
atomic.StoreInt32(&v, 1)
}
}
func BenchmarkAtomicStoreInt64(b *testing.B) {
var v int64
for i := 0; i < b.N; i++ {
atomic.StoreInt64(&v, 1)
}
}
If we run this benchmark, here’s some example output:cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkAtomicStoreInt32
BenchmarkAtomicStoreInt32-4 197107742 5.682 ns/op
BenchmarkAtomicStoreInt64
BenchmarkAtomicStoreInt64-4 213917528 5.134 ns/op
We could easily take this benchmark for granted and decide to useatomic.StoreInt64because it appears to be faster. Now, for the sake of doing a fair benchmark, we reverse the order and testatomic.StoreInt64first, followed byatomic.StoreInt32. Here is some example output:
BenchmarkAtomicStoreInt64
BenchmarkAtomicStoreInt64-4 224900722 5.434 ns/op
BenchmarkAtomicStoreInt32
BenchmarkAtomicStoreInt32-4 230253900 5.159 ns/op
This time,atomic.StoreInt32has better results. What happened?perflockIn the case of micro-benchmarks, many factors can impact the results, such as machine activity while running the benchmarks, power management, thermal scaling, and better cache alignment of a sequence of instructions. We must remember that many factors, even outside the scope of our Go project, can impact the results.
???+ note
We should make sure the machine executing the benchmark is idle. However, external processes may run in the background, which may affect benchmark results. For that reason, tools such as
can limit how much CPU a benchmark can consume. For example, we can run a benchmark with 70% of the total available CPU, giving 30% to the OS and other processes and reducing the impact of the machine activity factor on the results.-benchtimeOne option is to increase the benchmark time using the
option. Similar to the law of large numbers in probability theory, if we run a benchmark a large number of times, it should tend to approach its expected value (assuming we omit the benefits of instructions caching and similar mechanics).benchstatAnother option is to use external tools on top of the classic benchmark tooling. For instance, the
tool, which is part of thegolang.org/xrepository, allows us to compute and compare statistics about benchmark executions.-countLet’s run the benchmark 10 times using the
option and pipe the output to a specific file:
$ go test -bench=. -count=10 | tee stats.txt
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkAtomicStoreInt32-4 234935682 5.124 ns/op
BenchmarkAtomicStoreInt32-4 235307204 5.112 ns/op
// ...
BenchmarkAtomicStoreInt64-4 235548591 5.107 ns/op
BenchmarkAtomicStoreInt64-4 235210292 5.090 ns/op
// ...
We can then runbenchstaton this file:
$ benchstat stats.txt
name time/op
AtomicStoreInt32-4 5.10ns ± 1%
AtomicStoreInt64-4 5.10ns ± 1%
The results are the same: both functions take on average 5.10 nanoseconds to complete. We also see the percent variation between the executions of a given benchmark: ± 1%. This metric tells us that both benchmarks are stable, giving us more confidence in the computed average results. Therefore, instead of concluding thatatomic.StoreInt32is faster or slower, we can conclude that its execution time is similar to that ofatomic.StoreInt64for the usage we tested (in a specific Go version on a particular machine).benchstatIn general, we should be cautious about micro-benchmarks. Many factors can significantly impact the results and potentially lead to wrong assumptions. Increasing the benchmark time or repeating the benchmark executions and computing stats with tools such as
can be an efficient way to limit external factors and get more accurate results, leading to better conclusions.Let’s also highlight that we should be careful about using the results of a micro-benchmark executed on a given machine if another system ends up running the application. The production system may act quite differently from the one on which we ran the micro-benchmark.
Not being careful about compiler optimizations
Another common mistake related to writing benchmarks is being fooled by compiler optimizations, which can also lead to wrong benchmark assumptions. In this section, we look at Go issue 14813 (https://github.com/golang/go/issues/14813, also discussed by Go project member Dave Cheney) with a population count function (a function that counts the number of bits set to 1):
const m1 = 0x5555555555555555
const m2 = 0x3333333333333333
const m4 = 0x0f0f0f0f0f0f0f0f
const h01 = 0x0101010101010101
func popcnt(x uint64) uint64 {
x -= (x >> 1) & m1
x = (x & m2) + ((x >> 2) & m2)
x = (x + (x >> 4)) & m4
return (x * h01) >> 56
}
This function takes and returns auint64. To benchmark this function, we can write the following:
func BenchmarkPopcnt1(b *testing.B) {
for i := 0; i < b.N; i++ {
popcnt(uint64(i))
}
}
However, if we execute this benchmark, we get a surprisingly low result:cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkPopcnt1-4 1000000000 0.2858 ns/op
A duration of 0.28 nanoseconds is roughly one clock cycle, so this number is unreasonably low. The problem is that the developer wasn’t careful enough about compiler optimizations. In this case, the function under test is simple enough to be a candidate for inlining: an optimization that replaces a function call with the body of the called function and lets us prevent a function call, which has a small footprint. Once the function is inlined, the compiler notices that the call has no side effects and replaces it with the following benchmark:func BenchmarkPopcnt1(b *testing.B) {
for i := 0; i < b.N; i++ {
// Empty
}
}
The benchmark is now empty — which is why we got a result close to one clock cycle. To prevent this from happening, a best practice is to follow this pattern:1. During each loop iteration, assign the result to a local variable (local in the context of the benchmark function).
2. Assign the latest result to a global variable.
In our case, we write the following benchmark:
var global uint64 // Define a global variable
func BenchmarkPopcnt2(b *testing.B) {
var v uint64 // Define a local variable
for i := 0; i < b.N; i++ {
v = popcnt(uint64(i)) // Assign the result to the local variable
}
global = v // Assign the result to the global variable
}
globalis a global variable, whereas v is a local variable whose scope is the benchmark function. During each loop iteration, we assign the result ofpopcntto the local variable. Then we assign the latest result to the global variable.???+ note
Why not assign the result of the popcnt call directly to global to simplify the test? Writing to a global variable is slower than writing to a local variable (these concepts are discussed in 100 Go Mistakes, mistake #95: “Not understanding stack vs. heap”). Therefore, we should write each result to a local variable to limit the footprint during each loop iteration.
If we run these two benchmarks, we now get a significant difference in the results:
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkPopcnt1-4 1000000000 0.2858 ns/op
BenchmarkPopcnt2-4 606402058 1.993 ns/op
BenchmarkPopcnt2is the accurate version of the benchmark. It guarantees that we avoid the inlining optimizations, which can artificially lower the execution time or even remove the call to the function under test. Relying on the results ofBenchmarkPopcnt1could have led to wrong assumptions.int64Let’s remember the pattern to avoid compiler optimizations fooling benchmark results: assign the result of the function under test to a local variable, and then assign the latest result to a global variable. This best practice also prevents us from making incorrect assumptions.
Being fooled by the observer effect
In physics, the observer effect is the disturbance of an observed system by the act of observation. This effect can also be seen in benchmarks and can lead to wrong assumptions about results. Let’s look at a concrete example and then try to mitigate it.
We want to implement a function receiving a matrix of
elements. This matrix has a fixed number of 512 columns, and we want to compute the total sum of the first eight columns, as shown in figure 1.<figure markdown>
<figcaption>Figure 1: Computing the sum of the first eight columns.</figcaption>
</figure>For the sake of optimizations, we also want to determine whether varying the number of columns has an impact, so we also implement a second function with 513 columns. The implementation is the following:
func calculateSum512(s [][512]int64) int64 {
var sum int64
for i := 0; i < len(s); i++ { // Iterate over each row
for j := 0; j < 8; j++ { // Iterate over the first eight columns
sum += s[i][j] // Increment sum
}
}
return sum
}
func calculateSum513(s [][513]int64) int64 {
// Same implementation as calculateSum512
}
We iterate over each row and then over the first eight columns, and we increment a sum variable that we return. The implementation incalculateSum513remains the same.We want to benchmark these functions to decide which one is the most performant given a fixed number of rows:
const rows = 1000
var res int64
func BenchmarkCalculateSum512(b *testing.B) {
var sum int64
s := createMatrix512(rows) // Create a matrix of 512 columns
b.ResetTimer()
for i := 0; i < b.N; i++ {
sum = calculateSum512(s) // Create a matrix of 512 columns
}
res = sum
}
func BenchmarkCalculateSum513(b *testing.B) {
var sum int64
s := createMatrix513(rows) // Create a matrix of 513 columns
b.ResetTimer()
for i := 0; i < b.N; i++ {
sum = calculateSum513(s) // Calculate the sum
}
res = sum
}
We want to create the matrix only once, to limit the footprint on the results. Therefore, we callcreateMatrix512andcreateMatrix513outside of the loop. We may expect the results to be similar as again we only want to iterate on the first eight columns, but this isn’t the case (on my machine):
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkCalculateSum512-4 81854 15073 ns/op
BenchmarkCalculateSum513-4 161479 7358 ns/op
The second benchmark with 513 columns is about 50% faster. Again, because we iterate only over the first eight columns, this result is quite surprising.calculateSumTo understand this difference, we need to understand the basics of CPU caches. In a nutshell, a CPU is composed of different caches (usually L1, L2, and L3). These caches reduce the average cost of accessing data from the main memory. In some conditions, the CPU can fetch data from the main memory and copy it to L1. In this case, the CPU tries to fetch into L1 the matrix’s subset that
is interested in (the first eight columns of each row). However, the matrix fits in memory in one case (513 columns) but not in the other case (512 columns).calculateSum513???+ note
This isn’t in the scope of this post to explain why, but we look at this problem in 100 Go Mistakes, mistake #91: “Not understanding CPU caches.”
Coming back to the benchmark, the main issue is that we keep reusing the same matrix in both cases. Because the function is repeated thousands of times, we don’t measure the function’s execution when it receives a plain new matrix. Instead, we measure a function that gets a matrix that already has a subset of the cells present in the cache. Therefore, because
leads to fewer cache misses, it has a better execution time.This is an example of the observer effect. Because we keep observing a repeatedly called CPU-bound function, CPU caching may come into play and significantly affect the results. In this example, to prevent this effect, we should create a matrix during each test instead of reusing one:
func BenchmarkCalculateSum512(b *testing.B) {
var sum int64
for i := 0; i < b.N; i++ {
b.StopTimer()
s := createMatrix512(rows) // Create a new matrix during each loop iteration
b.StartTimer()
sum = calculateSum512(s)
}
res = sum
}
A new matrix is now created during each loop iteration. If we run the benchmark again (and adjustbenchtime— otherwise, it takes too long to execute), the results are closer to each other:
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkCalculateSum512-4 1116 33547 ns/op
BenchmarkCalculateSum513-4 998 35507 ns/op
Instead of making the incorrect assumption that calculateSum513 is faster, we see that both benchmarks lead to similar results when receiving a new matrix.InputAs we have seen in this post, because we were reusing the same matrix, CPU caches significantly impacted the results. To prevent this, we had to create a new matrix during each loop iteration. In general, we should remember that observing a function under test may lead to significant differences in results, especially in the context of micro-benchmarks of CPU-bound functions where low-level optimizations matter. Forcing a benchmark to re-create data during each iteration can be a good way to prevent this effect.
---
92 False Sharing
---
title: Writing concurrent code that leads to false sharing (#92)
comments: true
hide:
- toc
status: new
---Writing concurrent code that leads to false sharing
In previous sections, we have discussed the fundamental concepts of CPU caching. We have seen that some specific caches (typically, L1 and L2) aren’t shared among all the logical cores but are specific to a physical core. This specificity has some concrete impacts such as concurrency and the concept of false sharing, which can lead to a significant performance decrease. Let’s look at what false sharing is via an example and then see how to prevent it.
In this example, we use two structs,
andResult:
type Input struct {
a int64
b int64
}
type Result struct {
sumA int64
sumB int64
}
The goal is to implement acountfunction that receives a slice ofInputand computes the following:Input.a* The sum of all the
fields intoResult.sumAInput.b
* The sum of all thefields intoResult.sumBsumAFor the sake of the example, we implement a concurrent solution with one goroutine that computes
and another that computessumB:
func count(inputs []Input) Result {
wg := sync.WaitGroup{}
wg.Add(2)
result := Result{} // Init the result struct
go func() {
for i := 0; i < len(inputs); i++ {
result.sumA += inputs[i].a // Computes sumA
}
wg.Done()
}()
go func() {
for i := 0; i < len(inputs); i++ {
result.sumB += inputs[i].b // Computes sumB
}
wg.Done()
}()
wg.Wait()
return result
}
We spin up two goroutines: one that iterates over each a field and another that iterates over each b field. This example is fine from a concurrency perspective. For instance, it doesn’t lead to a data race, because each goroutine increments its own variable. But this example illustrates the false sharing concept that degrades expected performance.sumALet’s look at the main memory. Because
andsumBare allocated contiguously, in most cases (seven out of eight), both variables are allocated to the same memory block:sumA<figure markdown>
<figcaption>In this example, sumA and sumB are part of the same memory block.</figcaption>
</figure>
Now, let’s assume that the machine contains two cores. In most cases, we should eventually have two threads scheduled on different cores. So if the CPU decides to copy this memory block to a cache line, it is copied twice:<figure markdown>
<figcaption>Each block is copied to a cache line on both code 0 and core 1.</figcaption>
</figure>Both cache lines are replicated because L1D (L1 data) is per core. Recall that in our example, each goroutine updates its own variable:
on one side, andsumBon the other side:sumA<figure markdown>
<figcaption>Each goroutine updates its own variable.</figcaption>
</figure>Because these cache lines are replicated, one of the goals of the CPU is to guarantee cache coherency. For example, if one goroutine updates
and another readssumA(after some synchronization), we expect our application to get the latest value.sumAHowever, our example doesn’t do exactly this. Both goroutines access their own variables, not a shared one. We might expect the CPU to know about this and understand that it isn’t a conflict, but this isn’t the case. When we write a variable that’s in a cache, the granularity tracked by the CPU isn’t the variable: it’s the cache line.
When a cache line is shared across multiple cores and at least one goroutine is a writer, the entire cache line is invalidated. This happens even if the updates are logically independent (for example,
andsumB). This is the problem of false sharing, and it degrades performance.sumA???+ note
Internally, a CPU uses the MESI protocol to guarantee cache coherency. It tracks each cache line, marking it modified, exclusive, shared, or invalid (MESI).
One of the most important aspects to understand about memory and caching is that sharing memory across cores isn’t real—it’s an illusion. This understanding comes from the fact that we don’t consider a machine a black box; instead, we try to have mechanical sympathy with underlying levels.
So how do we solve false sharing? There are two main solutions.
The first solution is to use the same approach we’ve shown but ensure that
andsumBaren’t part of the same cache line. For example, we can update theResultstruct to add _padding_ between the fields. Padding is a technique to allocate extra memory. Because anint64requires an 8-byte allocation and a cache line 64 bytes long, we need 64 – 8 = 56 bytes of padding:
type Result struct {
sumA int64
_ [56]byte // Padding
sumB int64
}
`
The next figure shows a possible memory allocation. Using padding,
sumA and sumB` will always be part of different memory blocks and hence different cache lines.<figure markdown>
<figcaption>sumA and sumB are part of different memory blocks.</figcaption>
</figure>
If we benchmark both solutions (with and without padding), we see that the padding solution is significantly faster (about 40% on my machine). This is an important improvement that results from the addition of padding between the two fields to prevent false sharing.
The second solution is to rework the structure of the algorithm. For example, instead of having both goroutines share the same struct, we can make them communicate their local result via channels. The result benchmark is roughly the same as with padding.
In summary, we must remember that sharing memory across goroutines is an illusion at the lowest memory levels. False sharing occurs when a cache line is shared across two cores when at least one goroutine is a writer. If we need to optimize an application that relies on concurrency, we should check whether false sharing applies, because this pattern is known to degrade application performance. We can prevent false sharing with either padding or communication.
---