Skip to main content

03 - Goroutine and Channel in Go: Practical Competition with Communicating Sequential Processes

Goroutines, channels, select, context and WaitGroup in Go: how to start, stop and wait for concurrent work, with failure cases and the race detector.

What do you think about this article?

At a Glance

Goroutines, channels, select, context and WaitGroup in Go: how to start, stop and wait for concurrent work, with failure cases and the race detector.

Concurrency 18 min

Go implements Communicating Sequential Processes: ultra-light goroutines, channels as typed pipes for safe communication, select for multiplexing, WaitGroup for synchronization and context.Context for propagated cancellation - all without explicit mutexes in most cases.

goroutine channel go go concurrency CSP pattern select go

Goroutines and channels: a restaurant kitchen

In Go you launch a concurrent task by putting go in front of a function call, and you make tasks talk to each other with channels. The simplest way to remember it is a kitchen: the goroutines are the cooks, each at their own station, and the channel is the serving hatch where one cook puts the plate down and another picks it up. Cooks do not hand ingredients from hand to hand while both of them are using them: the plate passes from one to one, and while it sits on the hatch it belongs to nobody.

This is the CSP model (Communicating Sequential Processes), proposed by Tony Hoare in a 1978 paper in Communications of the ACM. The motto that Go drew from it, in Effective Go, is: "Do not communicate by sharing memory; instead, share memory by communicating". It works if you treat the value sent on a channel as handed over: after sending it you no longer touch it.

The compiler does not check this: if you send a pointer and keep using it, you have shared memory again, and race conditions are back. Effective Go itself warns that the approach "can be taken too far": for a shared counter a mutex is often simpler than a channel.

The listings below are complete programs. Warning: on the machine where I wrote this article the Go compiler is not installed, so I did not run them: the outputs I give are deduced from the semantics of the language (and I say so each time), not observed. Compile them and run them before trusting them; for the comparison with the other concurrency models see the first article of the series.

Goroutines: starting a task and waiting for it

The smallest problem: five independent jobs to start together, and a program that has to know when they are finished. An empty channel of type struct{} is the cheapest completion signal, because it carries no data.

package main

import (
	"fmt"
	"time"
)

func worker(id int, done chan<- struct{}) {
	fmt.Printf("worker %d started\n", id)
	time.Sleep(100 * time.Millisecond) // simulates the work
	fmt.Printf("worker %d finished\n", id)
	done <- struct{}{} // signals: "I'm done"
}

func main() {
	done := make(chan struct{}, 5) // capacity 5: no worker stays blocked on the send

	for i := 0; i < 5; i++ {
		go worker(i, done) // "go" starts the function in a new goroutine
	}

	for i := 0; i < 5; i++ {
		<-done // main waits for five signals; without this loop it would exit immediately
	}
	fmt.Println("all workers have finished")
}

The line that starts everything is go worker(i, done): the function runs in a new goroutine and main goes on without waiting for it. The second loop, with five <-done, is what keeps the program alive: the language specification says that when main returns the program ends and does not wait for the other goroutines, so without that loop the workers might print nothing. The buffer of 5 is there so that every worker can send its own signal even if main is not yet receiving.

What to look for when you run it (output not verified by me): the "started" and "finished" lines appear in an order that changes from one run to the next, because the scheduler decides who starts first; the only guaranteed line is the last one, after all five signals. If you find yourself counting signals by hand like this, you are usually reimplementing a sync.WaitGroup, which you see further below.

On the cost of a goroutine: Effective Go says they cost little more than the space for the stack, and that stacks start small and grow by allocating heap memory when needed. I do not give a figure because I did not measure it and it changes with the Go version; if you need a number for your machine, measure it with runtime.MemStats by starting ten thousand idle goroutines.

Channels: buffered, unbuffered, and with a direction

An unbuffered channel is a hand-off: whoever sends waits for someone to receive, and vice versa, so the two meet. With a buffer of capacity N the send does not wait as long as the queue has room. The arrows in the signatures (chan<- and <-chan) declare that inside the function the channel can only be written to or only be read from, and the compiler enforces it.

package main

import "fmt"

// chan<- int: inside this function the channel can only be written to
func producer(out chan<- int) {
	out <- 42
}

// <-chan int: inside this function the channel can only be read from
func consumer(in <-chan int) {
	fmt.Println("received", <-in)
}

func main() {
	// Unbuffered: the send waits for someone to receive
	ch := make(chan int)
	go producer(ch)
	consumer(ch)

	// Buffered: two sends go through without a receiver
	buffered := make(chan int, 2)
	buffered <- 1
	buffered <- 2
	fmt.Println(len(buffered), cap(buffered)) // elements in the queue, capacity
	fmt.Println(<-buffered, <-buffered)       // the order is FIFO
}

In main the first channel has no buffer: producer starts in a goroutine and stays stuck on the send until consumer, called from main, reads. The second has capacity 2, so the two consecutive sends do not block even though there is no receiver yet; len tells how many elements are in the queue and cap how many fit.

Expected output, deduced and not observed: received 42, then 2 2, then 1 2 (the specification guarantees that the two receives in the same call happen from left to right, and the buffer is FIFO).

If you removed a receiver and tried to send a third value on the full buffered channel, no goroutine could make progress anymore: the Go runtime has a check that ends the program with the error "all goroutines are asleep - deadlock!" (the message is in the source of runtime/proc.go). How to choose the capacity:

  • Unbuffered: when you want sender and receiver to proceed at the same pace, and the send to also be a synchronization.
  • Small, justified buffer: when you know how many sends will happen (like the 5 signals before, or the buffer of 1 in the timeout below).
  • Large buffer "just in case": almost always hides a producer faster than the consumer, and moves the problem into memory instead of solving it.

The order does not matter, but the criterion does: the capacity of a channel is a design decision and must be justified with a number you know. An oversized buffer makes a system that is piling up work look healthy; the topic, that is what to do when the consumer is slower than the producer, is the subject of backpressure and flow control.

Pipeline and fan-in: chains of goroutines

When a job breaks down into stages (read, transform, write), each stage can be a goroutine connected to the next by a channel. The example is deliberately minimal: one stage emits numbers, the other squares them.

package main

import "fmt"

// generate emits the numbers it receives and then closes the channel.
func generate(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out) // whoever sends closes, always
		for _, n := range nums {
			out <- n
		}
	}()
	return out
}

// square reads until the input channel is closed.
func square(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			out <- n * n
		}
	}()
	return out
}

func main() {
	for v := range square(generate(2, 3, 4, 5)) {
		fmt.Println(v)
	}
}

Two conventions hold everything up. The first: whoever sends closes, with defer close(out). The specification says that sending on a closed channel, or closing it twice, causes a panic, so closing falls to the only one who knows that nothing more will arrive. The second: whoever receives uses for n := range in, which ends by itself when the channel is closed and drained.

If you remove the close calls, the range in main waits forever. Here the output is deterministic, because there is a single path from start to end, and deducing it from the semantics it is 4, 9, 16, 25 on separate lines.

The trade-off is the case in which the consumer stops reading before the end: the upstream stages stay blocked on the send and never terminate. The official Go blog devotes a section of its pipelines guide to this case ("Stopping short"), and the solution goes through a cancellation signal, that is through the context below.

Fan-in: merging several inputs into one

If two stages produce in parallel and another has to consume everything, you need a function that merges the channels. That is fan-in.

// To be added to the pipeline file (same package main, uses generate).
// Add "sync" to the imports.
func merge(inputs ...<-chan int) <-chan int {
	merged := make(chan int)
	var wg sync.WaitGroup

	wg.Add(len(inputs)) // before starting the goroutines, not inside
	for _, in := range inputs {
		go func(c <-chan int) {
			defer wg.Done()
			for v := range c {
				merged <- v
			}
		}(in)
	}

	go func() {
		wg.Wait()     // when all the inputs are exhausted...
		close(merged) // ...and only then, close the output
	}()
	return merged
}

// in main:
//   sum := 0
//   for v := range merge(generate(1, 2, 3), generate(10, 20, 30)) { sum += v }
//   fmt.Println(sum)

For each input a goroutine starts that copies the values onto merged. The delicate point is closing: merged can be closed only when all the copying goroutines have finished, which is why there is a WaitGroup and a dedicated goroutine that waits and closes. wg.Add must precede the start of the goroutines; the pipelines guide stresses it for the same reason. With the inputs of the example you expect a sum of 66 (1+2+3+10+20+30), output deduced and not observed, while the order of the individual values in merged is not guaranteed.

select: waiting for several things and picking the first that arrives

select is a switch over channels: it waits at the same time for several operations and executes one that can proceed. The specification says that, if more than one is ready, one is chosen by uniform pseudo-random selection: you cannot count on a priority order. The everyday use case is the timeout on a slow call.

package main

import (
	"errors"
	"fmt"
	"time"
)

var ErrTimeout = errors.New("timeout")

// slowFetch pretends to be a network call that takes `delay`.
func slowFetch(delay time.Duration) (string, error) {
	time.Sleep(delay)
	return "response", nil
}

func fetchWithTimeout(delay, timeout time.Duration) (string, error) {
	resultCh := make(chan string, 1) // capacity 1: the goroutine can send and terminate
	errCh := make(chan error, 1)     // even if nobody is listening anymore

	go func() {
		result, err := slowFetch(delay)
		if err != nil {
			errCh <- err
			return
		}
		resultCh <- result
	}()

	select {
	case result := <-resultCh:
		return result, nil
	case err := <-errCh:
		return "", err
	case <-time.After(timeout):
		return "", ErrTimeout
	}
}

func main() {
	fmt.Println(fetchWithTimeout(50*time.Millisecond, 200*time.Millisecond))
	fmt.Println(fetchWithTimeout(300*time.Millisecond, 100*time.Millisecond))
}

fetchWithTimeout launches the work in a goroutine and then waits in the select for three events: a result, an error, or the expiry of time.After(timeout). The two channels have capacity 1 for a precise reason: if the timeout wins and the function returns, the work goroutine, when it finishes, must be able to send its result and terminate. With an unbuffered channel it would stay blocked on the send forever: that is a goroutine leak.

The main tries two cases: with a delay of 50 ms and a timeout of 200 ms you expect "response" and nil, with a delay of 300 ms and a timeout of 100 ms the error "timeout" (outcome deduced from the timings, with a margin of 150 ms and 200 ms respectively; not observed).

Two limits not to forget. The timeout does not stop the work: in the second case slowFetch goes on for another 200 ms and wastes CPU or a connection. And time.After creates a timer: the Go 1.23 release notes say that from that version timers that are no longer referenced are collected by the garbage collector even if not stopped, whereas in earlier versions they stayed alive until expiry. In a very tight loop, on an older version, it is better to use time.NewTimer and stop it.

context.Context: cancellation that crosses the chain

The problem that the previous timeout left open is really stopping the work, and doing it at every level: the HTTP handler, the service, the query. The context package is Go's standard way: a value that carries a deadline and a Done() channel that is closed when the work must be cancelled. The documentation indicates two conventions: pass the context explicitly to every function that needs it, as the first parameter named ctx.

package main

import (
	"context"
	"fmt"
	"time"
)

func longOperation(ctx context.Context) error {
	for i := 1; i <= 10; i++ {
		select {
		case <-ctx.Done(): // the context was cancelled or has expired
			return ctx.Err()
		case <-time.After(100 * time.Millisecond): // one step of work
			fmt.Printf("step %d/10\n", i)
		}
	}
	return nil
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 350*time.Millisecond)
	defer cancel() // releases the context's resources even if it does not expire

	if err := longOperation(ctx); err != nil {
		fmt.Println("interrupted:", err)
	}
}

Inside longOperation the select waits for two things: the closing of ctx.Done() or the passing of 100 ms (one step of work). A common alternative is a default with time.Sleep, which has a flaw: during the sleep the cancellation is not seen, so the cancellation arrives with a delay of up to 100 ms. Waiting for the two events in the same select avoids the delay. The context has a deadline of 350 ms, so, from the timings, you expect to see "step 1/10", "2/10", "3/10" and then "interrupted: context deadline exceeded" (not observed).

defer cancel() must always be written: the documentation says that cancelling the context releases the associated resources, so calling cancel as soon as the work ends avoids holding them until the deadline. In an HTTP request you do not create the root context yourself: the net/http documentation says that for incoming requests the context of r.Context() is cancelled when the client's connection closes or when ServeHTTP returns, and it is the one you pass down to the database.

The limit: the context asks to stop, it does not force; a function that does not check Done() carries on.

WaitGroup: waiting for N goroutines without counting by hand

sync.WaitGroup replaces the signal counting seen at the beginning: Add says how many goroutines to wait for, Done marks one as finished, Wait blocks until zero.

package main

import (
	"fmt"
	"strings"
	"sync"
)

func process(item string) string { return strings.ToUpper(item) }

func main() {
	items := []string{"apple", "pear", "kiwi"}
	results := make([]string, len(items))
	var wg sync.WaitGroup

	for i, item := range items {
		wg.Add(1) // before "go", never inside the goroutine
		go func() {
			defer wg.Done()
			results[i] = process(item) // each goroutine writes a different index
		}()
	}

	wg.Wait() // blocks until Done() has been called three times
	fmt.Println(results)
}

wg.Add(1) sits outside the goroutine: if you put it inside, Wait might run before the Add and return immediately. defer wg.Done() guarantees the count on any exit path of the closure, even an early return. Each goroutine writes to results[i], an index different from the others, so there is no contention on the same element; if all of them wrote the same variable, you would need a mutex. The expected output is [APPLE PEAR KIWI] (deduced).

There is a historical trap. The closure uses i and item from the loop without passing them as arguments: since Go version 1.22 each iteration has its own variables, and the release notes specify that this holds when the module's go.mod requires Go 1.22 or newer. With an older go.mod the old behavior remains, and the goroutines would all see the last value. Check the go line of your go.mod before removing the parameters explicitly passed to the closure.

Race detector: finding the races you do not see

Even with channels, a single shared variable is enough to have a data race: two goroutines access the same memory, at least one writes, with no synchronization. Go includes a detector: add -race to go run, go test or go build. A thousand goroutines incrementing the same counter are the textbook case.

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var racy int
	var safe atomic.Int64
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			racy++      // read, add, write: three steps, not one
			safe.Add(1) // a single atomic operation
		}()
	}

	wg.Wait()
	fmt.Println("racy:", racy, "safe:", safe.Load())
}

racy++ is not a single operation: it reads the value, adds one, writes it back, and two goroutines can read the same value and lose an increment. safe.Add(1) uses the atomic.Int64 type (introduced with Go 1.19) and loses nothing. What you can expect: safe is always 1000, whereas racy can be 1000 or less, and with go run -race the detector reports the data race on the line of racy++, with the stacks of the two conflicting goroutines (the shape of the report is described in the official documentation; I did not produce it).

A limit that the same documentation declares: the detector only finds races that happen during execution, not those in code paths the tests do not go through. That is why it is used in tests and, if coverage is incomplete, on a binary compiled with -race under realistic load. For prevention and diagnosis techniques in general there is race conditions and deadlocks.

Goroutine leak: the silent failure

A goroutine that stays blocked forever makes no noise: it throws no errors, it occupies memory and, multiplied by every request, makes the process grow until it is interrupted. The causes are almost always the same three you have already met in this article: a send on a channel that nobody will read anymore (the timeout without buffer), a range over a channel that nobody will close (the pipeline without close), a goroutine that ignores ctx.Done().

The remedy is to design every goroutine with the question "who makes it end?", and a concrete way to notice the problem is to compare runtime.NumGoroutine() before and after an operation that should have ended them all.

When NOT to use channels

Channels serve to transfer a value from one goroutine to another, to coordinate stages and to signal events. To protect a small shared state (a counter, a cache, a reference) a sync.Mutex or an atomic type is simpler and quicker to read, as Effective Go itself admits with the example of the reference counters. A practical rule: if you are using a channel only to act as a lock, you have chosen the wrong tool.

What you can do now

You can start and wait for goroutines with WaitGroup, choose between buffered and unbuffered channels justifying the capacity, build pipelines that close by themselves, stop work with context instead of a blind timeout, and spot data races and leaks with the language's tools. Remember that the outputs of this article are deduced, not observed: the first useful step is to compile the programs and compare them.

The next article moves on to Python: asyncio.TaskGroup and structured concurrency, which brings a similar discipline on cancellation into async/await. If you come from the comparison with the other platforms, also look at the Java virtual threads. For bounded channels and cancellation in Rust, see the article on Rust async and Tokio.

Sources

The sources are all official documentation of the language; for CSP itself the source is Tony Hoare's paper, "Communicating Sequential Processes", Communications of the ACM 21(8), 1978, which I did not link because I could not reach the publisher's page. All the outputs of the programs are deduced from these pages, not from executions.

Previous article ← JavaScript Event Loop
Next in the series Advanced Python asyncio →

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects