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.
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
- Effective Go - the "share memory by communicating" motto, the "can be taken too far" warning, the cost and growth of stacks, consulted on 2026-11-12
- The Go Programming Language Specification - pseudo-random selection in select, panic on a closed channel, main not waiting for the other goroutines, left-to-right evaluation order, consulted on 2026-11-12
- Go Concurrency Patterns: Pipelines and cancellation - closing convention, fan-in with WaitGroup, the "Stopping short" case, consulted on 2026-11-12
- Package context - context as the first parameter, releasing resources with cancel, consulted on 2026-11-12
- Package net/http (Request.Context) - when the context of an incoming request is cancelled, consulted on 2026-11-12
- Data Race Detector - use of the -race flag, shape of the report, limit to executed paths only, consulted on 2026-11-12
- Go 1.22 Release Notes - per-iteration loop variables tied to go.mod, consulted on 2026-11-12
- Go 1.23 Release Notes - timers no longer referenced collected by the GC, consulted on 2026-11-12
- Go runtime source (runtime/proc.go) - the check that ends the program when all goroutines are blocked, consulted on 2026-11-12
- Go 1.19 Release Notes - the atomic.Int64 types and similar, consulted on 2026-11-12
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.
Commenti
Caricamento commenti...
Sign in to comment