Golang patterns: Leaky goroutines and how to clean them
Introduction
With intuitive concurrency primitives Go makes it easier to write concurrent programs and also makes it easier to make sense of such programs. I have covered concurrency support in Concurrency Primer. We will be able to use the features in many different combinations to create various patterns that solve different problems we encounter with concurrent code. But with such simplicity also comes few caveats and we have to be aware of them while building large scale applications on Go. One such is leaking goroutines which I will cover in detail in this post.
Goroutines: a quick recap
Goroutines are created using go keyword appended before the function which is the entry-point to goroutine. It executes asynchronously to the calling goroutine and it is handled by runtime. Goroutine will be terminated by:
- when it completes whatever task it is doing.
- when it errors out
- it is explicitly stopped.
Go GC and goroutines
Go comes with Garbage collector(GC) and it is a neat way to cleanup objects that are not needed and keep the memory/ run time tidy without programmer worrying about allocate-cleaning mess(C++).
But there is a nuance here. GC wont cleanup goroutines. Even if the goroutine is taking forever to complete, GC wont bother.
Take an example of following piece of code. Here since goroutine running poorFoo() receives nil values channel, it will forever be blocked and lies around in a vegetative state.
Though this is a very simplistic and dumb example, there are cases like a web client waiting for a response from some server which can theoretically take forever. We have said goroutines are lightweight, but they still cost in terms resources and we will not like huge number of such waste goroutines lying around. We need a way to signal goroutines to die in a convenient way by the main caller.
Done channel
A very common way to accomplish such signaling is to use done channels. Consider the same above example with a done channel passed to the goroutine as below.
All I have done is pass a done channel which will be used later to signal to return the goroutine. Select call blocks until either one of the channels become readable and in out case since dummy ch won’t be readable ever, when Maincaller() closes the done channel, that case will be unblocked and executes return. Thus the previously vehemently alive goroutine is now closed effectively.
We can have goroutine listening on multiple type of events: setting timeout on the goroutine, done event coming from the caller goroutine or some other eventing from very top caller.Usually in network applications, it makes sense to put a timeout on tasks so that they will not wait unnecessarily or the request parameters might have expired. Below is an example where a timeout signaling is also embedded into the goroutine.
In the above case, if the done or other channels do not signal within 1 second, the last case will be unblocked (time.After() will send time on the channel after elapsing the said time) and executes the statements. This is now getting neater and handling rogue goroutines is better this way.
But Go language itself provides these idioms in Context package.
Go Contexts
Contexts ,as the name suggests, are used to pass along contexts. It has ways to not just signal done or timeouts but in an idiomatic way while giving better information when signaling.
From context package, we get Context which is an interface type as below:
type Context interface {
// ok is false if no deadline set. if true, deadline is time //when work done to be cancelled.
Deadline() (deadline time.Time, ok bool)
//returned channel will be closed when we need to cancel.
Done() <-chan struct{}
// Err returns a non-nil error value after Done is closed. Like
// Canceled if the context was canceled or
// DeadlineExceeded if the context's deadline passed.
Err() error
// Value returns the value associated with this context for key,
// or nil if no value is associated with key.
Value(key interface{}) interface{}
}
Along with these, context package itself has below functions exposed.
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)func Background() Context // returns empty context
Basically context
package will :
- provide a way of cancelling tasks on behalf of caller
- provide a key value store to pass down the call hierarchy.
In this post, we will focus on its utility in the first scenario- cancellation of tasks.
When using context, we pass a context object as argument to our function.
ctx := context.Background()
And we use functions of context package to use the empty context created above to create other contexts which are more useful.
WithCancel()
returns a newContext
that closes itsdone
channel when the returnedcancel
function is called. This wraps done channel with a neat sugar.
func Maincaller(){
ctx := context.Background()
cancelctx, cancel := context.WithCancel(ctx)
go func(){
ret := someIndependentOperation()
if ret{
globalchan<- ret
}
else{
cancel()
}go func(ctx context.Context) error{
select {
case <-ctx.Done():
return ctx.Err() case x<- globalchan:
return x
}
}(cancelctx)
As we can see, when we call context.WithCancel(), we get a new context called cancelctx and a cancelFunc called cancel. The downstream goroutines have access to this context as well as cancel function. When a module calls cancel(), context will close the Done channel associated with it and hence in the second goroutine the first case will unblock and return an error “Canceled
”
WithDeadline
returns aContext
that closes itsdone
channel when the clock runs beyond the passeddeadline
WithTimeout
returns a newContext
that closes itsdone
channel after the giventimeout
duration.
func foo_timings(ctx context.Context) error{
cctx, cancel := context.WithTimeout(ctx, 1*time.Second)
err := someLongCalculation(cctx)
if err{
return err
}
//continue operation
}func someLongCalculation(ctx context.Context) error{
dl_ctx, ok := ctx.Deadline()
if ok{
// check if deadline passed, if yes no need to proceed //further
}
switch{
case <-ctx.Done():
return ctx.Err()
case <-someslowchannel:
return nil
}
}
In case the someslowchannel
channel does not return within the given timeout of 1s, the done channel associated with context is closed and the error “Deadline exceeded” is returned.
We also see, the callee has access to newly created dl_ctx
context which can be used to see if deadline is already passed and if yes any decisions to be taken. Only after this check is passed, we can go ahead with the select statements. Thus, both caller and callee using context can use the context package functions to appropriately take actions on cancellations of tasks.
Conclusion
We saw how to build up a done channel hierarchy using simple channels to signal goroutines to stop. We also saw neat syntactic sugar of context package to achieve the same with added features. This way we can cleanup rogue goroutines and with that covered can spin up safely a large number of goroutine tasks with associated timeouts, stop signaling etc associated.