Understanding Concurrency Patterns in Go
Comparing sync and channel approach in goroutines
Go is an awesome language with great built-in support for concurrency. However, I feel that this topic isn’t very much in the clear, often discussed lightly at the end of the books, and keeping many away from using Go in production.
sync.WaitGroup (low-level style)
sync is a package providing basic synchronization primitives for goroutines. sync.WaitGroup is very popular, since it’s slightly faster than channels and is easy to understand.
var wg sync.WaitGroupfunc main() {
words := []string{ "foo", "bar", "baz" }
for _, word := range words { // add one backlog to the group
wg.Add(1) go func(word string) { // Block for a second
time.Sleep(1 * time.Second) // remove that backlog from the group
defer wg.Done() fmt.Println(word)
}(word)
}
// Keep on doing things on the main goroutine
fmt.Println(1)
time.Sleep(1 * time.Second)
fmt.Println(2)
time.Sleep(1 * time.Second)
fmt.Println(3) // This waits (block) for all goroutines to finish.
wg.Wait()
}
Run in the playground.
Channels
Channels are a unique primitive in Go, serving as a fruition of the great doctrine from Go’s authors:
Don’t communicate by sharing memory; share memory by communicating.
If you have programmed anything remotely similar to a queue (i.e. a Python queue), channels are not so different.
Here is the equivalent of the above snippet, rewritten using a channel.
func main() { words = []string{ "foo", "bar", "baz" } // Create a channel for communication
done := make(chan bool) // It is nice to close the channels, like files,
// after it's used or you may get leaks
defer close(done) for _, word := range words {
go func(url string) { // block for a sec
time.Sleep(1 * time.Second) fmt.Println(word)
// send signal to the channel
done <- true
}(word)
} // Do what you have to do
fmt.Println(1)
time.Sleep(1 * time.Second)
fmt.Println(2)
time.Sleep(1 * time.Second)
fmt.Println(3) // This blocks and waits
<-done}
Run in the playground.
This is straightforward and produces similar outcome to the sync.WaitGroup approach. The advantages of using channels are
- you don’t have to manage the waitgroup with Add() and Done(), removing one more error-prone step to deadlocking.
- Channel is a better abstraction of synchronizing communication in general (akin event in Javascript) which makes it easy to relate to real-world communications.
Request-Response Mock in Channels
To see how using channels are simpler, check out a simple mock of client-server communication with a channel below
package mainimport (
“fmt”
“time”
)type Client struct { Request string }
type Server struct { Response string }func main() {
conn:= make(chan string, 1)
done:= make(chan bool)
defer close(conn)
defer close(done) client := &Client{ “ping” }
server := &Server{ “ack” } // Client sends request
conn <- client.Request
fmt.Printf(“CLIENT sent %s\n”, client.Request) go func(c chan string) { // receive from conn
request := <-conn fmt.Printf(“SERVER received: %s\n”, request)
fmt.Println(“SERVER sending response…”) // simulate server delay
<-time.Tick(2 * time.Second) // send to conn
conn <- server.Response
fmt.Printf(“SERVER sent: %s\n”, server.Response)
// send a signal to unblock the main loop
done <- true
}(conn) fmt.Println(“CLIENT waiting…”)
// block until value is received from done
<-done
// receive from conn
resp := <-conn
fmt.Printf(“CLIENT received %s\n”, resp)
}
Run in the playground.
This is like how you should be able to send a request and receive a response from a single connection.
Take a look at Code Walk: Share Memory by Communicating if you’re into a few minutes of sitting and reading code.