Gist of Go: Goroutines
This is a chapter from my book on Go concurrency, which teaches the topic from the ground up through interactive examples.
Let's skip the long talk about concurrency and parallelism and jump right into writing a concurrent program in Go!
Go-routine
Suppose we have a function that speaks a phrase word by word with some pauses:
// say prints each word of a phrase.
func say(phrase string) {
for _, word := range strings.Fields(phrase) {
fmt.Printf("Simon says: %s...\n", word)
dur := time.Duration(rand.Intn(100)) * time.Millisecond
time.Sleep(dur)
}
}
We call it from the main function:
func main() {
say("go is awesome")
}
Simon says: go...
Simon says: is...
Simon says: awesome...
Now let's create two talkers, each saying their own phrase:
// say prints each word of a phrase.
func say(id int, phrase string) {
for _, word := range strings.Fields(phrase) {
fmt.Printf("Worker #%d says: %s...\n", id, word)
dur := time.Duration(rand.Intn(100)) * time.Millisecond
time.Sleep(dur)
}
}
Run the program:
func main() {
say(1, "go is awesome")
say(2, "cats are cute")
}
Worker #1 says: go...
Worker #1 says: is...
Worker #1 says: awesome...
Worker #2 says: cats...
Worker #2 says: are...
Worker #2 says: cute...
Not bad, but the functions speak one after the other. To make them speak at the same time, let's add go
before calling the say()
function:
func main() {
go say(1, "go is awesome")
go say(2, "cats are cute")
time.Sleep(500 * time.Millisecond)
}
Worker #2 says: cats...
Worker #1 says: go...
Worker #2 says: are...
Worker #1 says: is...
Worker #2 says: cute...
Worker #1 says: awesome...
Now they really compete for our attention! When we write go f()
, the function f()
runs independently of the others.
If you're familiar with concurrency in Python, JavaScript, or other languages with async/await, don't try to apply that experience to Go. Go has a very different approach to concurrency. Try to look at it with fresh eyes.
Functions that run with go
are called goroutines. The Go runtime juggles these goroutines and distributes them among operating system threads running on CPU cores. Compared to OS threads, goroutines are lightweight, so you can create hundreds or thousands of them.
You may be wondering why we need time.Sleep()
in the main
function. Let's clarify that.
Dependent and independent goroutines
Goroutines are completely independent. When we call go say()
, the function runs on its own. main
doesn't wait for it. So if we write main
like this:
func main() {
go say(1, "go is awesome")
go say(2, "cats are cute")
}
— the program won't print anything. main
finishes before our goroutines speak, and since the main is done, the whole program terminates.
main
is also a goroutine, but it starts implicitly when the program starts. So we have three goroutines: main
, say(1)
, and say(2)
, all of which are independent. The only catch is that when main
ends, everything else ends too.
Using time.Sleep()
to wait for goroutines is a bad idea because we can't predict how long they will take. A better approach is to use a wait group:
func main() {
var wg sync.WaitGroup // (1)
wg.Add(1) // (2)
go say(&wg, 1, "go is awesome")
wg.Add(1) // (2)
go say(&wg, 2, "cats are cute")
wg.Wait() // (3)
}
// say prints each word of a phrase.
func say(wg *sync.WaitGroup, id int, phrase string) {
for _, word := range strings.Fields(phrase) {
fmt.Printf("Worker #%d says: %s...\n", id, word)
dur := time.Duration(rand.Intn(100)) * time.Millisecond
time.Sleep(dur)
}
wg.Done() // (4)
}
Worker #2 says: cats...
Worker #1 says: go...
Worker #2 says: are...
Worker #1 says: is...
Worker #2 says: cute...
Worker #1 says: awesome...
wg
➊ has a counter inside. Calling wg.Add(1)
➋ increments it by one, while wg.Done()
➍ decrements it. wg.Wait()
➌ blocks the goroutine (in this case, main
) until the counter reaches zero. This way, main
waits for say(1)
and say(2)
to finish before it exits.
However, this approach mixes business logic (say
) with concurrency logic (wg
). As a result, we can't easily run say
in regular, non-concurrent code.
In Go, it's common to separate concurrency logic from business logic. This is usually done with separate functions. In simple cases like ours, even anonymous functions will do:
func main() {
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
say(1, "go is awesome")
}()
go func() {
defer wg.Done()
say(2, "cats are cute")
}()
wg.Wait()
}
Worker #2 says: cats...
Worker #1 says: go...
Worker #2 says: are...
Worker #1 says: is...
Worker #2 says: cute...
Worker #1 says: awesome...
Here's how it works:
- We know there will be two goroutines, so we call
wg.Add(2)
right away. - Anonymous functions are started with
go
just like regular ones. defer wg.Done()
ensures the goroutine decrements the counter before exiting, even ifsay
panics.say
itself knows nothing about concurrency and just runs happily.
✎ Counting digits in words
The ✎ symbol indicates exercises. They are an essential part of the book, so try not to skip them. Half of what you learn comes from the exercises.
Here's a function that counts the digits in a word:
// countDigits returns the number of digits in a string.
func countDigits(str string) int {
count := 0
for _, char := range str {
if unicode.IsDigit(char) {
count++
}
}
return count
}
Create a function countDigitsInWords
that takes an input string, splits it into words, and counts the digits in each word using countDigits
. Be sure to do the counting for each word in a separate goroutine.
We haven't discussed how to modify shared data from different goroutines yet, so there is a ready-to-use variable called syncStats
that you can safely access from goroutines.
Important: When submitting your solution, send only the code fragment marked with "solution start" and "solution end" comments. The full source code is available via the "Playground" link below.
// solution start
// countDigitsInWords counts the number of digits in the words of a phrase.
func countDigitsInWords(phrase string) counter {
var wg sync.WaitGroup
syncStats := new(sync.Map)
words := strings.Fields(phrase)
// Count the number of digits in words,
// using a separate goroutine for each word.
// To store the results of the count,
// use syncStats.Store(word, count)
// As a result, syncStats should contain words
// and the number of digits in each.
return asStats(syncStats)
}
// solution end
✓ Counting digits in words
Here's what we do:
- Set the wait group counter to the number of goroutines (= number of words) with
wg.Add()
. - For each word, start a new goroutine to count the digits and store the results in
syncStats
. - Decrement the wait group counter when a goroutine finishes with
wg.Done()
. - Wait for all goroutines to finish with
wg.Wait()
.
func countDigitsInWords(phrase string) counter {
var wg sync.WaitGroup
syncStats := new(sync.Map)
words := strings.Fields(phrase)
wg.Add(len(words))
for _, word := range words {
go func() {
defer wg.Done()
count := countDigits(word)
syncStats.Store(word, count)
}()
}
wg.Wait()
return asStats(syncStats)
}
Channels
Starting a bunch of goroutines is great, but how do they exchange data? In Go, goroutines can pass values to each other through channels. A channel is like a window where one goroutine can throw something and another can catch it.
┌─────────────┐ ┌─────────────┐
│ goroutine A │ │ goroutine B │
│ └────┘ │
│ X <- chan <- X │
│ ┌────┐ │
│ │ │ │
└─────────────┘ └─────────────┘
Goroutine B sends value X to goroutine A. Canvas, oil, circa 2024.
Here's how it works:
func main() {
// To create a channel, use `make(chan type)`.
// Channel can only accept values of the specified type:
messages := make(chan string)
// To send a value to a channel,
// use the `channel <-` syntax.
// Let's send "ping":
go func() { messages <- "ping" }()
// To receive a value from a channel,
// use the `<-channel` syntax.
// Let's receive "ping" and print it:
msg := <-messages
fmt.Println(msg)
}
ping
When the program runs, the first goroutine (anonymous) sends a message to the second (main
) through the messages
channel.
Sending a value through a channel is a synchronous operation. When the sending goroutine does messages <- "ping"
, it gets blocked and waits for someone to receive that value with <-messages
. Only then does it continue:
func main() {
messages := make(chan string)
go func() {
fmt.Println("B: Sending message...")
messages <- "ping" // (1)
fmt.Println("B: Message sent!") // (2)
}()
fmt.Println("A: Doing some work...")
time.Sleep(500 * time.Millisecond)
fmt.Println("A: Ready to receive a message...")
<-messages // (3)
fmt.Println("A: Messege received!")
time.Sleep(100 * time.Millisecond)
}
A: Doing some work...
B: Sending message...
A: Ready to receive a message...
A: Messege received!
B: Message sent!
After sending the message to the channel ➊, goroutine B gets blocked. Only when goroutine A receives the message ➌ does goroutine B continue and print "message sent" ➋.
So, channels not only transfer data, but also help to synchronize independent goroutines. This will come in handy later.
✎ Result channel
We often see the "producer-consumer" pattern in programming:
- The producer supplies data.
- The consumer receives and processes it.
In this and the following exercises, we'll explore how producers and consumers can interact through channels.
We are working with a function that counts digits in words:
// counter stores the number of digits in each word.
// The key is the word, and the value is the number of digits.
type counter map[string]int
// countDigitsInWords counts the number of digits
// in the words of a phrase.
func countDigitsInWords(phrase string) counter {
words := strings.Fields(phrase)
// ...
return stats
}
Do the following:
- Start a goroutine.
- In this goroutine, loop through the words, count the digits in each, and write to the
counted
channel (producer). - In the outer function, read values from the channel and fill the
stats
counter (consumer).
Submit only the code fragment marked with "solution start" and "solution end" comments. The full source code is available via the "Playground" link below.
// solution start
// countDigitsInWords counts the number of digits in the words of a phrase.
func countDigitsInWords(phrase string) counter {
words := strings.Fields(phrase)
counted := make(chan int)
go func() {
// Loop through the words,
// count the number of digits in each,
// and write it to the counted channel.
}()
// Read values from the counted channel
// and fill stats.
// As a result, stats should contain words
// and the number of digits in each.
return stats
}
// solution end
✓ Result channel
Here's what we do:
- In the goroutine, loop through the words, count the digits in each, and write to the
counted
channel. - In the outer function, loop through the words, read the counts from the
counted
channel, and fill in thestats
.
func countDigitsInWords(phrase string) counter {
words := strings.Fields(phrase)
counted := make(chan int)
// count digits in words
go func() {
for _, word := range words {
count := countDigits(word)
counted <- count
}
}()
// fill stats by words
stats := counter{}
for _, word := range words {
count := <-counted
stats[word] = count
}
return stats
}
If you've worked with concurrency in Go, the solutions in this chapter may seem a bit... unorthodox, if not clunky. It's designed this way to avoid overwhelming readers who are new to concurrency. In a few chapters, we'll cover the core concepts, and the code will start to look idiomatic.
Generator
So far, we've assumed that the countDigitsInWords
function knows all the words in advance.
But we can't expect that luxury in real life. Data can come from a database or over the network, and the function has no idea how many words there will be.
Let's simulate this situation by passing a generator function next
instead of a phrase. Each call to next()
gives us the next word from the source. When there are no more words, it returns an empty string.
A sequential program would look like this:
// counter stores the number of digits in each word.
// The key is the word, and the value is the number of digits.
type counter map[string]int
// countDigitsInWords counts the number of digits in words,
// fetching the next word with next().
func countDigitsInWords(next func() string) counter {
stats := counter{}
for {
word := next()
if word == "" {
break
}
count := countDigits(word)
stats[word] = count
}
return stats
}
func main() {
phrase := "0ne 1wo thr33 4068"
next := wordGenerator(phrase)
stats := countDigitsInWords(next)
printStats(stats)
}
thr33: 2
4068: 4
0ne: 1
1wo: 1
Now let's add some concurrency.
✎ Generator with goroutines
We are working with a function that counts digits in words:
// countDigitsInWords counts the number of digits in words,
// fetching the next word with next().
func countDigitsInWords(next func() string) counter {
// ...
return stats
}
Do the following:
- Start a goroutine.
- In this goroutine, fetch words from the generator, count the digits in each, and write to the
counted
channel. - In the outer function, read values from the channel and fill
stats
.
If you try to solve the exercise like the previous one, you'll run into a couple of problems:
func countDigitsInWords(next func() string) counter {
counted := make(chan int)
// count digits in words
go func() {
for {
// should return when
// there are no more words
word := next()
count := countDigits(word)
counted <- count
}
}()
// fill stats by words
stats := counter{}
for {
count := <-counted
// how to break from the loop
// when there are no more words?
if ... {
break
}
// where should the word come from?
stats[...] = count
}
}
Think about what to send to the counted
channel to solve both problems. Note the pair
type.
Submit only the code fragment marked with "solution start" and "solution end" comments. The full source code is available via the "Playground" link below.
// solution start
// countDigitsInWords counts the number of digits in words,
// fetching the next word with next().
func countDigitsInWords(next func() string) counter {
counted := make(chan ...)
go func() {
// Fetch words from the generator,
// count the number of digits in each,
// and write it to the counted channel.
}()
// Read values from the counted channel
// and fill stats.
// As a result, stats should contain words
// and the number of digits in each.
return stats
}
// solution end
✓ Generator with goroutines
Declare a pair
type (word + count):
type pair struct {
word string
count int
}
Create a channel of the appropriate type:
func countDigitsInWords(next func() string) counter {
counted := make(chan pair)
// ...
}
In the goroutine, count the digits for each word and send the (word, count) pairs to the counted
channel:
func countDigitsInWords(next func() string) counter {
counted := make(chan pair)
// count digits in words
go func() {
for {
word := next()
count := countDigits(word)
counted <- pair{word, count}
if word == "" {
break
}
}
}()
// ...
}
In the outer function, read the pairs from the channel and fill stats
:
func countDigitsInWords(next func() string) counter {
// count digits in words
// ...
// fill stats by words
stats := counter{}
for {
p := <-counted
if p.word == "" {
break
}
stats[p.word] = p.count
}
return stats
}
That's it!
✎ Reader and worker
We are working with a function that counts digits in words:
// countDigitsInWords counts the number of digits in words,
// fetching the next word with next().
func countDigitsInWords(next func() string) counter {
// ...
return stats
}
In the previous exercise, the goroutine went through the words and counted the digits, while the outer function received the counts and updated the counter:
┌───────────────┐
│ loops through │ ┌────────────────┐
│ words and │ → (counted) → │ fills stats │
│ counts digits │ └────────────────┘
└───────────────┘
goroutine channel outer function
For more complex tasks, it's useful to have a goroutine for reading data (reader) and another for processing data (worker). Let's use this approach in our function:
┌───────────────┐ ┌───────────────┐
│ sends words │ │ counts digits │ ┌────────────────┐
│ to be counted │ → (pending) → │ in words │ → (counted) → │ fills stats │
│ │ │ │ └────────────────┘
└───────────────┘ └───────────────┘
reader channel worker channel outer function
Do the following:
- Start a goroutine that fetches words from the generator and sends them to the
pending
channel (reader). - Start a second goroutine that reads from
pending
, counts the digits, and writes to thecounted
channel (worker). - In the outer function, read from
counted
and update the finalstats
counter.
Submit only the code fragment marked with "solution start" and "solution end" comments. The full source code is available via the "Playground" link below.
// solution start
// countDigitsInWords counts the number of digits in words,
// fetching the next word with next().
func countDigitsInWords(next func() string) counter {
pending := make(chan string)
counted := make(chan pair)
// sends words to be counted
go func() {
// Fetch words from the generator
// and send them to the pending channel.
}()
// counts digits in words
go func() {
// Read the words from the pending channel,
// count the number of digits in each word,
// and send the results to the counted channel.
}()
// Read values from the counted channel
// and fill stats.
// As a result, stats should contain words
// and the number of digits in each.
return stats
}
// solution end
✓ Reader and worker
The reader goroutine fetches words from the generator and sends them to the pending
channel:
go func() {
for {
word := next()
pending <- word
if word == "" {
break
}
}
}()
The worker goroutine reads words from the pending
channel, counts digits, and sends the results to the counted
channel:
go func() {
for {
word := <-pending
count := countDigits(word)
counted <- pair{word, count}
if word == "" {
break
}
}
}()
The outer function reads from the counted
channel and prepares the final results:
stats := counter{}
for {
p := <-counted
if p.word == "" {
break
}
stats[p.word] = p.count
}
✎ Named goroutines
We are working with a function that counts digits in words:
// countDigitsInWords counts the number of digits in words,
// fetching the next word with next().
func countDigitsInWords(next func() string) counter {
// ...
return stats
}
After splitting the logic between the reader and the worker, the function became quite hefty:
func countDigitsInWords(next func() string) counter {
// ...
// sends words to be counted
go func() {
// ...
}()
// counts digits in words
go func() {
// ...
}()
// fills stats
// ...
return stats
}
There are clearly three logical blocks:
- Send words to be counted.
- Count digits in words.
- Fill in the final results.
It would be convenient to extract these blocks into separate functions that exchange data through channels:
func countDigitsInWords(next func() string) counter {
pending := make(chan string)
go submitWords(next, pending)
counted := make(chan pair)
go countWords(pending, counted)
return fillStats(counted)
}
Refactor the program to achieve this.
Submit only the code fragment marked with "solution start" and "solution end" comments. The full source code is available via the "Playground" link below.
// solution start
// submitWords sends words to be counted.
// countWords counts digits in words.
// fillStats prepares the final statistics.
// solution end
✓ Named goroutines
submitWords()
fills the input channel with words from the next
function:
func submitWords(next func() string, out chan string) {
for {
word := next()
out <- word
if word == "" {
break
}
}
}
countWords()
reads data from the input channel, counts digits, and writes results to the output channel:
func countWords(in chan string, out chan pair) {
for {
word := <-in
count := countDigits(word)
out <- pair{word, count}
if word == "" {
break
}
}
}
fillStats()
reads from the result channel and prepares the final statistics:
func fillStats(in chan pair) counter {
stats := counter{}
for {
p := <-in
if p.word == "" {
break
}
stats[p.word] = p.count
}
return stats
}
Output channel
Here is the function we ended up with:
// countDigitsInWords counts the number of digits in words,
// fetching the next word with next().
func countDigitsInWords(next func() string) counter {
pending := make(chan string)
go submitWords(next, pending)
counted := make(chan pair)
go countWords(pending, counted)
return fillStats(counted)
}
It looks fine, but there's still one thing I'd like to change.
The pending
channel is created in the main
function only to be passed to the submitWords
function. It'd be better to create the channel in submitWords
and return it to main
, so that submitWords
owns it completely. The same goes for the counted
channel and countWords
.
Then countDigitsInWords
will look like this:
func countDigitsInWords(next func() string) counter {
pending := submitWords(next)
counted := countWords(pending)
return fillStats(counted)
}
Now the ownership is clear, and the program is easier to reason about. But where did all the goroutines go? We start them inside submitWords
and countWords
like this:
// submitWords sends words to be counted.
func submitWords(next func() string) chan string {
out := make(chan string)
go func() {
for {
word := next()
out <- word
if word == "" {
break
}
}
}()
return out
}
// countWords counts digits in words.
func countWords(in chan string) chan pair {
out := make(chan pair)
go func() {
for {
word := <-in
count := countDigits(word)
out <- pair{word, count}
if word == "" {
break
}
}
}()
return out
}
Let's make sure the program still works as expected:
func main() {
phrase := "0ne 1wo thr33 4068"
next := wordGenerator(phrase)
stats := countDigitsInWords(next)
printStats(stats)
}
thr33: 2
4068: 4
0ne: 1
1wo: 1
Returning an output channel from a function and filling it within an internal goroutine is a common pattern in Go.
Keep it up
We learned about goroutines and channels in Go. There is still a long way to go, but we've made a start! In the next chapter we'll dive into channels.
Pre-order for $10 or read online
★ Subscribe to keep up with new posts.