Takeaways In This Post
- Only store primitives and immutable or read only values in Contexts
- Do not use Context to store application logic values
- Always create a main cancellable Context to allow goroutines to gracefully stop
- Check Context cancellation before and during long running tasks
- Use channels to logically partition concurrent work
- Do not communicate by sharing memory; instead, share memory by communicating
Context
A context.Context is a reference (because it is an interface) to an immutable key/value pair that is one node in a tree of Contexts. Each Context can lookup values using the recursive function Value(key) any
and check cancellation of the chain with Done() <-chan struct{}
. This structure allows values and cancel signals to be shared amongst numerous goroutines. This allows data to be shared safely. One major use case is profiling.
Common Context Pitfalls
Context is commonly abused and misused by allowing values stored in the Context to be mutable and also by storing Contexts inside structs. Both of these mistakes lead to potential race conditions and other issues. Avoid storing maps, slices, references, and channels as Context values. Stick to primitives and struct values. Beware of references stored in struct values as those may also present race condition issues.
Takeaway: Only store primitives and immutable or read only values in Contexts
Another issue is the tendency to use Context as a grab bag of application values. Keep in mind that the more values stored in the Context the longer look ups become, O(n)
. This pattern also makes debugging difficult since all of the values are "hidden". Pass the values you require as arguments.
Takeaway: Do not use Context to store application logic values
A good practice to follow is always create an application Context before anything else. This allows the main to gracefully shutdown any running goroutines before exiting. Otherwise, goroutines will be halted mid-execution without any opportunity to stop.
Hello, Concurrency!
Basic "Hello, World!" main for concurrency.
func main() {
ctx, cancel := context.WithCancel(context.Background())
// Do parallel work...
go task(ctx)
cancel()
time.Sleep(5 * time.Second)
}
There are improvements that should be done, but this will allow you to spawn any number of goroutines and allow five seconds for those to stop.
Takeaway: Always create a main cancellable Context to allow goroutines to gracefully stop
Of course, goroutines do not simply stop by themselves. They have to monitor their Context for cancellations. A good way to do this is using select
. This will block until one of the case
branches becomes available.
func task(ctx context.Context) {
for {
select {
case <-ctx.Done():
// Context cancelled. Should now stop processing work.
return
default:
// Process work
}
}
}
This function will run until its Context is cancelled. Because default
is always available, this will check the Done() channel for a value first and if nothing is ready then it will execute the default
branch. Even if a task is not continuously running, it is always a good idea to check the Context before performing an expensive operation, such as a database query.
Takeaway: Check Context cancellation before and during long running tasks
Data Concurrency
When data needs to be accessed by the same goroutine, it is very easy to wrap that data in a sync.Mutex
and call it a day. This can lead to complex code and deadlocks. Golang provides a better way of communicating data between goroutines in the form of channels. You can think of channels like a hand off of data from one goroutine to another. That hand off blocks on both ends until both sides are ready.
This pattern does a few things for you:
- Guarantees only one process is modifying data at a time
- Provides logical partitions of work that are easy to understand
- Decouples code that would otherwise rely on specific mutexes
func readPacket(ctx context.Context, packetChannel chan Packet) {
for {
select {
case <-ctx.Done():
// Context cancelled. Should now stop processing work.
return
case packet, ok := <-packetChannel:
if !ok {
return
}
packet.ReadCount++
packetChannel <- packet
}
time.Sleep(time.Millisecond)
}
}
readPacket()
continuously checks the channel for new Packets, reads it, and then dumps it back onto the channel for another goroutine to process. If the channel is ever closed then we should stop. ok
becomes false once this happens. The small sleep in such a fast looping goroutine like this one allows time for other goroutines to process and prevents the CPU from maxing.
Instead of taking a Packet blocking on mutex associated with it, we can freely pass the Packet between goroutines. The goroutine is an isolated slice of work to be done on the Packet. There may be other operations performing work on it in other goroutines, but those would never impact the thread safety of this goroutine task.
Takeaway: Use channels to logically partition concurrent work
Takeaway: Do not communicate by sharing memory; instead, share memory by communicating
Top comments (0)