Structured concurrency in Go

Anton Okolelov
5 min readMay 31, 2024

Many of you might have encountered this: Go’s syntax seems simple, almost primitive. Creating Goroutines is straightforward. But writing a robust, more or less complicated, concurrent program? That’s a different story.

To bring order to this chaos, the concept of structured concurrency was invented. And yes, you can apply it in Go too.

Marketing Bullshit

You’ve probably heard it before: Go concurrency and channels make writing multithreaded programs easier than in other languages. But what you don’t hear as often is that it’s still quite complicated. The syntax is verbose, and it’s easy to get confused.

Structured Programming

Let’s start with a simple synchronous program. Once upon a time, long ago, people rarely used procedures and functions. A typical program was a sheet of code sprinkled with if [condition] goto [label] commands. The program could arbitrarily jump to different parts of itself, making it incredibly hard to keep track of variable states at any given moment. This led to confusion and plenty of bugs.

Structured Programming

In 1968, Dijkstra published his groundbreaking paper “Go To Statement Considered Harmful,” marking the dawn of structured programming. Programs became hierarchical structures of blocks and subroutines, and everyone avoided using goto.

Today, this seems obvious, and it’s how everyone writes code. We use many different languages, but the approach is always the same: functions call other functions, with clear inputs and outputs, and almost no gotos (though there are rare exceptions). Programs are readable. But it wasn’t always this way.

For example, Fortran had a truly monstrous construct:

IF (z) 10, 20, 30

This means: if z < 0, go to label 10; if z = 0, go to label 20; if z > 0, go to label 30. Reading such conditional jumps is extremely difficult — you had to keep half the program in your head.

Structured Concurrency

Structured concurrency is built on the same principles as structured programming, but from a different angle. Let’s explore this concept in the context of Go.

Waiting for Goroutines to Finish

The go statement is essentially a modern-day goto. It sends you off elsewhere, leaving you to deal with the consequences. Just like in structured programming, it’s best to organize your program within the scope of functions.

In Go, if you create one or more goroutines within a function, it’s best to wait for their completion in that same function before exiting. If you don’t, your application can turn into a chaotic collection of uncontrolled threads, running who knows when or by whom. This makes understanding, debugging, and testing your application nearly impossible.

// bad
func DoSomething() {
go func() {
// do something
}
}

// better
func DoSomething() {
var wg sync.WaitGroup
wg.Add(1)

go func(wg *sync.WaitGroup) {
// do something
wg.Done()
}(&wg)

wg.Wait()
}

All the fuss with goroutines and such should be encapsulated within a function, and from the outside, it should appear as a synchronous call.

DoSomething() // contains goroutines internally
// by this point, goroutines have completed

In Go, you don’t need to write async/await like in other languages. Therefore, the code reads entirely linearly, and you might not even think about what’s happening inside — whether it’s one thread or a million. However, the magic breaks if this function spawns a goroutine inside and returns execution without waiting. In general, the fewer side effects, the better.

This is evident in the standard library and popular packages. You can write a highly concurrent HTTP server with a database and have no idea what a goroutine or channel is — everything is hidden under the hood. Just mindlessly shove JSON into the database and tell everyone you’re a great Gopher.

err := http.ListenAndServe(“:8080”, nil)

Here, ListenAndServe under the hood creates countless goroutines, channels, and whatnot, but we don’t see it when we use it: we simply call the function, and when the server stops accepting requests, we move on. That is, the function blocks execution until it completes everything. After the function finishes, no goroutines remain.

If you need a call to be parallel with something else, you can handle it on the caller’s side. When there are no side effects, every layer of goroutine hierarchy becomes completely understandable. You don’t have to keep track of what’s happening inside each function, making the code overall simpler to write.

Where you write to a channel is where you should close it

A typical question in an interview is, “What happens if you write to a closed channel?” However, this question becomes irrelevant if you don’t follow the practice of writing to a channel in one part of your program and closing it in a completely different part, attempting to keep track of all the cases and states where and how execution might occur.

So, suppose there’s a certain data source for the channel. As long as the data flows, write to the channel; when the source dries up, close the channel right there.

Of course, situations vary. But if you close the channel after writing everything to it, in the same goroutine, understanding such code becomes much simpler.

Encapsulating Mutexes

Shared variables are complex. In any code, in any language. The fewer cases where different parts need to write to the same variable, the better. Especially in concurrent code.

And if it still needs to be done, it’s better, once again, to encapsulate — to create a structure where you can put the mutable field and the mutex to protect it. And mutate it through methods that will perform Lock and Unlock.

type SafeCounter struct {
mu sync.Mutex
v map[string]int
}

func (c *SafeCounter) Inc(key string) {
c.mu.Lock()
c.v[key]++
c.mu.Unlock()
}

func (c *SafeCounter) Value(key string) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.v[key]
}

(In this example, it may be better to replace the entire construct with sync.Map)

Another point. There’s a well-known phrase among every Gopher: “Do not communicate by sharing memory; instead, share memory by communicating.” It’s a useful maxim, but it doesn’t mean you should forget about mutexes entirely and do everything with channels. You can implement the same counter using channels, but it will look much more complicated.

Use the method that is most expressive and/or simple in your case.

Furthermore, there was a study that showed the number of errors is roughly the same, whether you do it this way or that way.

In conclusion,

A thoughtful approach is necessary across the board. Letting goroutines and data management run wild will quickly lead to trouble. However, with minimal effort, you can significantly enhance code clarity — reducing the mental burden of juggling numerous state combinations when writing programs.

I also suggest taking a look at the library https://github.com/sourcegraph/conc. Despite not reaching version 1.0, it showcases an approach to structuring concurrent Go code. Additionally, it simplifies panic handling and helps alleviate the verbosity often associated with the language.

--

--