How to Write Concurrent Go Code
Categories:
… continued from the previous post.
Shuffling through Stack Overflow questions I realized that there is one point I tried to make clear, but didn’t emphasize enough:
Write Synchronous Code First
Many programs work perfectly fine without concurrency. It’s better to prioritize creating a functional and thoroughly tested program initially and introduce concurrency when its benefits become apparent through observation of runtime behavior. Rushing into concurrent implementations riddled with bugs invariably results in more time spent on subsequent fixes of a bad design and less on real improvement; moreover, it’s more efficient to start with a straightforward approach and iterate towards improvement, rather than to deal with a buggy program and invest significant time in bug fixes afterward.
Problems in the Wild
Let me give some examples. It’s so much better to have:
1func task() int {
2 x := subX()
3 y := subY(x)
4
5 return y
6}
7
8func subX() int {
9 return 1
10}
11
12func subY(i int) int {
13 return i + 1
14}
And realize - well, you can’t make it concurrent, because one function depends on the result of the other, and besides - it is fast enough - instead of being stuck with Frankenstein’s monster:
1func task() int {
2 xy := make(chan int)
3 result := make(chan int)
4
5 go subX(xy)
6 go subY(xy, result)
7
8 return <-result
9}
10
11func subX(out chan<- int) {
12 out <- 1
13}
14
15func subY(in <-chan int, out chan<- int) {
16 out <- <-in + 1
17}
which is slower than the first example.
And it is also so much easier instead of guessing what should be concurrent up front to start with synchronous code:
1func task() (int, error) {
2 s1, err := sub1()
3 if err != nil {
4 return 0, err
5 }
6
7 s2, err := sub2()
8 if err != nil {
9 return 0, err
10 }
11
12 return s1 + s2, nil
13}
14
15func sub1() (int, error) {
16 return 1, nil
17}
18
19func sub2() (int, error) {
20 return 1, nil
21}
and should you find out that executing sub1 and sub2 concurrently could speeds thing up, transform it to:
1func task() (int, error) {
2 var s1, s2 int
3 var g errgroup.Group
4
5 g.Go(func() (err error) {
6 s1, err = sub1()
7 return err
8 })
9
10 g.Go(func() (err error) {
11 s2, err = sub2()
12 return err
13 })
14
15 err := g.Wait()
16 if err != nil {
17 return 0, err
18 }
19
20 return s1 + s2, nil
21}
22
23func sub1() (int, error) {
24 return 1, nil
25}
26
27func sub2() (int, error) {
28 return 1, nil
29}
What makes things much better here is that sub1 and sub2 still have unchanged, synchronous APIs, which means that
all tests you’ve written are still valid and things are much easier to test, since you don’t have to deal with
concurrency.
The transformation only happens in task, and at that scope concurrency is better to understand.
Be Considerate
I do not believe everything should be written using structured concurrency. But seeing common Go bugs, I think that a lot of code written would benefit.
Summary
Most of the code should be written synchronously first and should keep synchronous APIs as much as possible. Function literals are a great way to separate concurrency from subtasks.
… to be continued.