This intention of this series is to teach something about concurrency and synchronization in the Golang ecosystem. As always, I'd like to explain it with a simple to understand, but still tough example.
Assume we'd like to create a counter, which just counts from zero to infinity. Very quick implementation which would satisfy our needs already.
func main() {
i := 0
for {
i++
fmt.Println(i)
}
}
So, how to scale this solution? Yes of course by using concurrent computation - but in Go we do not talk about threads, we talk about Goroutines. You have to imagine, that there is more work to do than just counting numbers. It could be anything, but we'd like the results in a specific order. This is the challenging part of this example. We would like to have the exact same output but provided via different goroutines.
// current and desired state would be
0, 1, 2, 3, 4, 5, 6, 7, ...
// but when just introducing concurrency,
// we could maybe end up like this
0, 2, 1, 3, 5, 4, 6, 7, ...
When we just spawn two Goroutines and let them count (in two-steps now) we may expect the output like above. We can't tell the order here, so the Goroutines have to know it somehow.
Goroutine communication
There are different approaches available for doing this. We may share some parts of memory (a variable) and let the other oroutine wait until it's time to print the number. The other solution would be to tell the other Goroutine directly, that the number is printed and it is now time to continue.
These are of course two core principles and it would go far beyond the scope of this post now to explain them in detail, but I guess you may already know them.
Since we are talking about Go and Go is all about keeping the programming paradigms and rules in mind, I am going to use approach 2.
Share memory by Communicating - A Golang core principle read more here
I've summed up the above architectural explanations in a graphic.
The tricky part here is the communication between the two printing Goroutines, since the ordering has very high priority (if not this post would be useless).
The solution
When we talk about communication, we always talk about channels.
Below is already the whole solution to solve the problem.
func main() {
// initialize all channels
printOdd := make(chan struct{})
printEven := make(chan struct{})
closer := make(chan struct{})
// spawn Goroutine A
go func() {
start := 0
// infinte looping
for {
// block until some data arrives from either channel
select {
case <-printEven:
// simulate the calculation
time.Sleep(time.Second)
// print
fmt.Println(start)
start = start + 2
// notify Goroutine B to print an even number now
printOdd <- struct{}{}
case <-closer:
return
}
}
}()
// spawn Goroutine B
go func() {
start := 1
for {
select {
case <-printOdd:
time.Sleep(time.Second)
fmt.Println(start)
start = start + 2
printEven <- struct{}{}
case <-closer:
return
}
}
}()
reader := bufio.NewReader(os.Stdin)
fmt.Println("Press enter to cancel")
fmt.Println("---------------------")
// trigger the ping-pong
printEven <- struct{}{}
// wait for console input to quit
reader.ReadString('\n')
fmt.Println("finished")
// we would like to let all other goroutines return, but in fact they starve away
// when the main goroutine returns
// closing this channel here is totally useless
close(closer)
}
The empty struct type is used because of memory optimization,
since we don't want to share any other data, just notify the other goroutine.
The danger in this solution is that we cannot clean up our worker goroutines (A and B).
When the main Goroutine returns all other goroutines are killed, as well. In our example it does not matter much.
But there are of course use-cases where we want to clean up something. Think of closing some files in use, closing network connections and so on.
Introducing cleanup
When we'd like to make a clean up possible for your worker Goroutines, we could use one of the standard libraries sync.WaitGroup
.
You may view the original documentation here: https://pkg.go.dev/sync/#WaitGroup
Now we are adding waitgroups to enable cleanup for our worker Goroutines.
func main() {
printOdd := make(chan struct{})
printEven := make(chan struct{})
closer := make(chan struct{})
wg := sync.WaitGroup{}
go func() {
start := 0
wg.Add(1)
for {
select {
case <-printEven:
time.Sleep(time.Second)
fmt.Println(start)
start = start + 2
printOdd <- struct{}{}
case <-closer:
fmt.Println("finished odd printing")
wg.Done()
return
}
}
}()
go func() {
start := 1
wg.Add(1)
for {
select {
case <-printOdd:
time.Sleep(time.Second)
fmt.Println(start)
start = start + 2
printEven <- struct{}{}
case <-closer:
fmt.Println("finished even printing")
wg.Done()
return
}
}
}()
reader := bufio.NewReader(os.Stdin)
fmt.Println("Press enter to cancel")
fmt.Println("---------------------")
// trigger the ping-pong
printEven <- struct{}{}
reader.ReadString('\n')
fmt.Println("finished")
// we would like to let all other goroutines return
close(closer)
// panics, because a close on a channel may only be received once
// and therefore the call to wg.Done() is only called once instead of twice
wg.Wait()
// output: fatal error: all goroutines are asleep - deadlock!
}
There are several questions arising now. Why would one introduce a new channel (the closer
) to make the other ones return? We may just use our other channels to achieve this. This will however introduce another tricky problem which we'll discuss in the follow-up post.
Edit: I mixed up even and odd - as @prateek_reddy pointed out in the comments
Top comments (3)
Nice article, do checkout my site when you have time covid-dashboard.herokuapp.com/ :)
Great article 👍 don't you think signalling printEven should print even numbers and likewise 🤔
Oh damn... thanks for you information