Reduce goroutine leaks and channel misuse in Go
Golang has effective built-in primitives designed for concurrent work. Concurrent algorithms can be implemented in Golang using simpler and more readable code in comparison to other languages. In contrast, misusing these primitives will make the code complex and prone to errors when changing it. In the following paragraphs, I'll describe some techniques to prevent misusing the concurrency primitives that I found useful in my day-to-day work.
There is a great book that I read a while ago about working with goroutines and channels, called Concurrency in Go. To the day of this writing, I haven't found a better book describing concurrency patterns in Go. I learned most of the techniques from this book.
1. Isolate concurrent code from sequential code
Don't mix code that does concurrent work with the code that does sequential work. Isolate the concurrent code in a separate function (or a struct with methods if needed) that takes care of declaring and closing channels, launching and waiting for goroutines, etc. This function should be easy to use, without worrying about concurrency.
1type Task struct {
2 ID int
3}
4
5func main() {
6 taskList := []Task{{ID: 1}, {ID: 2}, {ID: 3}}
7 doWork(taskList...)
8}
9
10func doWork(taskList ...Task) {
11 // Sequential code.
12 doConcurrentWork(taskList...)
13 // Sequential code.
14}
15
16// Isolated concurrent code.
17func doConcurrentWork(taskList ...Task) {
18 wg := sync.WaitGroup{}
19 wg.Add(len(taskList))
20
21 for _, task := range taskList {
22 go func(task Task) {
23 defer wg.Done()
24
25 time.Sleep(1 * time.Second)
26 fmt.Printf("Task %d is done.\n", task.ID)
27 }(task)
28 }
29
30 wg.Wait()
31}
2. Output channels should be returned, not passed as an argument
The function that creates the output channel should also be responsible for closing it. By doing this, the caller of the function doesn't have to worry about when and where to close the output channel. It only has to consume the output values. Another advantage of this is that you can simply range over the values sent on the channel.
To return the output channel, you have to wrap the work in another go routine and defer the instruction that closes the channel. When the work is finished and the wrapping goroutine ends, the defer is called and the channel is closed.
1type Task struct {
2 ID int
3}
4
5type Result struct {
6 Msg string
7}
8
9func main() {
10 taskList := []Task{{ID: 1}, {ID: 2}, {ID: 3}}
11
12 doWork(taskList...)
13}
14
15func doWork(taskList ...Task) {
16 for result := range doConcurrentWork(taskList...) {
17 fmt.Println(result.Msg)
18 }
19}
20
21func doConcurrentWork(taskList ...Task) chan Result {
22 outputCh := make(chan Result)
23
24 // Wrap the work in a goroutine.
25 go func() {
26 // Defer closing the output channel. Going forward, it doesn't matter
27 // when you return in the goroutine, the channel will always be closed. (*)
28 defer close(outputCh)
29
30 wg := sync.WaitGroup{}
31 wg.Add(len(taskList))
32
33 for _, task := range taskList {
34 go func(task Task) {
35 defer wg.Done()
36
37 time.Sleep(1 * time.Second)
38 r := Result{
39 Msg: fmt.Sprintf("Task %d is done.", task.ID),
40 }
41 outputCh <- r
42 }(task)
43 }
44
45 wg.Wait()
46
47 }()
48
49 return outputCh
50}
(*) There can be cases when deferred function calls are not executed. For example, if os.Exit(0)
is called the application will stop without executing the deferred calls.
3. Enforce the direction of channels
An outcome of the previous technique is that the channel can be enforced to be read-only. The compiler won't process your code if it detects an instruction that sends a value on a read-only channel.
1type Task struct {
2 ID int
3}
4
5type Result struct {
6 Msg string
7}
8
9func main() {
10 taskList := []Task{{ID: 1}, {ID: 2}, {ID: 3}}
11
12 doWork(taskList...)
13}
14
15func doWork(taskList ...Task) {
16 for result := range doConcurrentWork(taskList...) {
17 fmt.Println(result.Msg)
18 }
19}
20
21// A read-only channel is returned for the caller to read the output.
22func doConcurrentWork(taskList ...Task) <-chan Result {
23 outputCh := make(chan Result)
24
25 go func() {
26 defer close(outputCh)
27
28 wg := sync.WaitGroup{}
29 wg.Add(len(taskList))
30
31 for _, task := range taskList {
32 go func(task Task) {
33 defer wg.Done()
34
35 time.Sleep(1 * time.Second)
36 r := Result{
37 Msg: fmt.Sprintf("Task %d is done.", task.ID),
38 }
39 outputCh <- r
40 }(task)
41 }
42
43 wg.Wait()
44
45 }()
46
47 return outputCh
48}
4. Pass errors back to the caller
Most of the time, the caller function has more information based on which the best approach for handling the error can be decided. Although it should be common sense when it comes to Go error handling, it might not be so obvious when working with goroutines.
1type Task struct {
2 ID int
3}
4
5type Result struct {
6 Message string
7 Error error
8}
9
10func main() {
11 ctx := context.Background()
12 taskList := []Task{{ID: 1}, {ID: 2}, {ID: 3}}
13
14 doWork(ctx, taskList...)
15}
16
17func doWork(ctx context.Context, taskList ...Task) {
18 ctx, cancel := context.WithCancel(ctx)
19 defer cancel()
20
21 // The caller can decide what to do with the results.
22 for result := range doConcurrentWork(ctx, taskList...) {
23 if result.Error != nil {
24 fmt.Println(result.Error)
25 return
26 }
27
28 fmt.Println(result.Message)
29 }
30}
31
32func doConcurrentWork(ctx context.Context, taskList ...Task) <-chan Result {
33 outputCh := make(chan Result)
34
35 go func() {
36 defer close(outputCh)
37
38 wg := sync.WaitGroup{}
39 wg.Add(len(taskList))
40
41 for _, task := range taskList {
42 go func(ctx context.Context, task Task) {
43 defer wg.Done()
44
45 // The error is returned to the caller.
46 msg, err := solveTask(ctx, task)
47 r := Result{
48 Message: msg,
49 Error: err,
50 }
51
52 outputCh <- r
53 }(ctx, task)
54 }
55
56 wg.Wait()
57
58 }()
59
60 return outputCh
61}
62
63func solveTask(ctx context.Context, t Task) (string, error) {
64 select {
65 case <-ctx.Done():
66 return "", ctx.Err()
67 default:
68 }
69
70 if t.ID%2 == 0 {
71 return "", fmt.Errorf("solving task %d failed", t.ID)
72 }
73
74 return fmt.Sprintf("solving task %d done", t.ID), nil
75}
5. Don't use buffered channels without a solid reason
Buffered channels might hide possible deadlocks because they block only when full. Start with unbuffered channels and change later accordingly after thinking carefully about the optimization that you are trying to do.
There is a great style on Go written by Uber. I always go back to it and check it out when in doubt. I highly recommend it.
These techniques helped me write cleaner and more reliable code. Use them when it is appropriate, don't apply them without consideration, and always think about the context and the problem that you are trying to solve first.
Originally posted on medium.com.