Lesson 1 of 5
Goroutines: Cheap, Not Free
"Input and output are basic primitives of programming, and parallel composition of communicating sequential processes is a fundamental program structuring method." — C. A. R. Hoare, Communicating Sequential Processes (1978)
Everything in this book so far has happened on one stack. A function called another function, an error travelled back up, a defer fired on the way out — and at every moment there was exactly one place in the program where something was happening. That is a simplifying assumption, and this Part removes it.
Go's whole answer to concurrency starts with two characters and a space. Put go in front of a function call and the call happens somewhere else, while your code carries on. There is no thread object to construct, no pool to size, no executor to hand work to, no Runnable to implement. The keyword is so small that people reach for it before they have thought about what it means, which is exactly why this chapter exists.
The title is the argument. Goroutines really are cheap — cheap enough that a design which would be absurd with operating-system threads is routine in Go, and this chapter demonstrates that with twenty thousand of them at once. But "cheap" is not "free", and the costs are not where beginners look for them. Almost nobody gets into trouble because goroutines used too much memory. They get into trouble because a goroutine was started and never finished, or finished after the thing that needed its answer had gone, or shared a variable with the goroutine next to it.
go f() is the shortest concurrency syntax in any mainstream language. So what did that brevity buy, what did it hide, and where does "cheap" quietly become "not free"?
What it bought is real: a goroutine starts at a couple of kilobytes against roughly a megabyte for an OS thread, and the runtime multiplexes many of them onto few threads, so blocking one costs almost nothing. What it hid is that go makes exactly one promise — the call will happen — and no promise at all about when, whether it finishes, or whether anybody is still listening when it does. Every goroutine bug in this chapter is a caller assuming one of the three.
go: the keyword that starts something and walks away
go takes a function call and runs it in a new goroutine. The statement itself completes immediately — the value of a go statement is nothing, and there is no handle to hold.
Click Run to execute the code.
Round 1 printed all three, because time.Sleep gave the scheduler an opportunity to run them. Round 2 printed nothing from the goroutines at all. They were created, and then main reached its end and the program exited — and when main returns, the process ends and every other goroutine is killed where it stands, with no unwinding, no deferred calls, no warning. Three function calls simply never happened, and the program reported success.
That silence is the single most important thing to understand about go. The second most important is that round 1 is not the fix either. A sleep is a guess about how long the work takes. Guess low and you cut the work off exactly as round 2 did; guess high and every run of your program pays for the margin. It also gets less reliable as the machine gets busier, which means it passes on your laptop and fails in production. Sleep is how you observe that goroutines exist; it is never how you wait for them.
Three small mechanical points, because each one bites somebody. The arguments to a go call are evaluated immediately, in the calling goroutine — go work(name) captures name right now, which is a guarantee worth leaning on. You can go a method (go server.Start()) or a closure (go func() { … }()), and the closure form is by far the most common. And you cannot get a return value out of a goroutine: go compute() discards whatever compute returns, silently. Getting an answer back requires a channel or shared state, which is the whole of the next chapter.
Handing a task to a colleague as you leave the office is a go statement. It takes a second, you do not wait, and you have no idea whether they finished — you did not ask them to report back and you did not stay to see. If the building closes at six, whatever they had not finished simply stops, mid-sentence.
"I'll wait five minutes, they're probably done" is time.Sleep. Sometimes right, unfalsifiable, and worse on the days the office is busy. "Text me when it's done, and I'll wait for three texts" is a WaitGroup. It costs one more line and it is correct on every machine, at every speed, forever.
Waiting properly, and the trap on the way
The tool for "wait until these finish" is sync.WaitGroup: a counter with three operations. Add(n) raises it, Done() lowers it by one, and Wait() blocks until it reaches zero.
Click Run to execute the code.
Four decisions in that loop are load-bearing, and three of them are the answers to real bugs.
wg.Add(1) goes before the go statement, in the launching goroutine. Move it inside the closure and you have a race between Add and Wait: if Wait runs before the goroutine gets scheduled, the counter is still zero, Wait returns immediately, and you have silently waited for nothing.
wg.Done() is deferred. If the body panics, or returns early from one of several branches, the counter still drops. An undecremented WaitGroup does not fail loudly — it hangs forever.
The shared slice is guarded by a mutex. Two goroutines appending to the same slice without one is a data race, and a data race in Go is not "the wrong order", it is undefined behaviour — a lost write, a torn slice header, a crash. Chapter 4 of this Part is about exactly this.
name is passed as a parameter. This is the trap, and until recently it was the most common bug in Go:
// Not runnable in this book's playground — see the note below.
for i := 0; i < 3; i++ {
wg.Add(1)
go func() { defer wg.Done(); fmt.Print(i, " ") }() // captures i itself
}
wg.Wait()
// Go 1.21 and earlier: 3 3 3 — one shared i, already 3 by the time they run
// Go 1.22 and later: a permutation of 0 1 2 — i is per-iterationBefore Go 1.22 there was one i for the whole loop, and every goroutine closed over that same variable. By the time they were scheduled the loop had usually finished, so they all read 3. Go 1.22 changed loop variables to be per-iteration, which fixed it in the language — but the parameter form works on every version, states the intent, and is still what you will see in most code. This playground reproduces the pre-1.22 behaviour (3 3 3), so the fence above is written out rather than run; the runnable block passes name explicitly and is correct everywhere.
The idea is older than most of the languages competing with Go. Tony Hoare's Communicating Sequential Processes (1978) proposed that independent sequential processes exchanging messages should be a basic way to structure programs, rather than an exotic add-on. Rob Pike spent two decades building languages on that foundation at Bell Labs — Newsqueak, then Alef, then Limbo for the Inferno operating system — each one carrying channels and lightweight processes forward. Go is the fourth in that line, and the first to reach a large audience.
The word "goroutine" was coined because none of the existing words fit. It is not a thread, not a coroutine, not a process, not a task — the Go authors said as much when they named it. And the distinction Pike has spent the longest defending is the one people still collapse: "Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once." Concurrency is a way of structuring a program. Parallelism is hardware executing more than one thing simultaneously. A concurrent program on one core is still concurrent — and, as the next section shows, still faster at the thing concurrency is actually for.
What "cheap" actually means
A goroutine starts with a stack of about two kilobytes that grows and shrinks on demand. An operating-system thread typically reserves a megabyte and cannot give it back. That ratio — roughly five hundred to one — is the entire reason Go's concurrency style is possible.
Click Run to execute the code.
Twenty thousand goroutines, and the arithmetic is exactly right. Try that with twenty thousand OS threads and the machine will refuse long before it finishes — twenty gigabytes of reserved stack, and a scheduler doing kernel-level context switches between all of them.
The second half is the more useful lesson. Three sixty-millisecond waits take a hundred and eighty milliseconds in sequence and sixty concurrently — a three-times speed-up on a machine that may only have one core doing the work. Nothing was computed faster. The waits overlapped. That is what almost every real Go concurrency win looks like: not arithmetic done in parallel, but a program that stops idling while it waits on a network call, a disk, a database, or a lock. When a goroutine blocks, Go's scheduler parks it and runs another one on the same OS thread — no kernel involvement, and the blocked goroutine costs only its stack.
This is why the design advice in Go runs opposite to most thread-based languages. There, threads are precious and you build a pool to ration them. In Go, a goroutine per request, per connection, or per item is a normal design, and pooling goroutines to "save resources" is usually a pessimisation that adds a queue, a lifecycle and a set of bugs to save something that was not scarce.
Three questions before writing go, in the order they save you time.
Who waits for this? If the answer is "nothing", you have written round 1 — the work is abandoned at some unpredictable point. Name the WaitGroup, the channel receive, or the Wait call that makes the answer concrete before you write the keyword.
What does it share? Every variable the closure touches from the enclosing scope is shared with whoever else touches it. Passing values as parameters converts sharing into copying, which is why the parameter form survived the language change that made it unnecessary.
How does it end if nobody wants the result any more? A goroutine blocked on a send nobody will receive never returns and is never collected. This is the leak in the next section, and the question that context exists to answer.
What "not free" actually means
The costs that matter are not the ones the size comparison suggests.
The stack is small but it is not nothing. Two kilobytes times a million goroutines is two gigabytes before any of them does anything, and a goroutine that recurses deeply or holds a large local buffer grows well past its starting size and does not always shrink promptly. Goroutines are cheap per unit; they are not free in bulk.
The scheduler has real work to do. Every goroutine is an object the runtime tracks, and creating them faster than they retire makes that set grow without limit. This is the usual shape of the failure: not one expensive goroutine, but an unbounded number of trivial ones, because something in a loop launches work faster than the work completes.
And the expensive one: a leaked goroutine is permanent. Go's garbage collector will never collect a goroutine, however unreachable it looks, because a goroutine is a root — it is a live stack with a program counter, and its own local variables are reachable from it. A blocked goroutine holds everything it references, forever.
Click Run to execute the code.
Two goroutines are stuck on a send that will never complete. Nothing crashed, no error was returned, and the only visible symptom is two lines that never printed. In a long-lived server this is the leak that matters: a handler starts three lookups, takes the fastest answer, returns, and forgets the other two — and every request adds two permanently blocked goroutines and everything they reference.
The one-line fix is a buffered channel with room for every sender (make(chan string, 3)), so a send always completes whether or not anyone reads it. The general fix is cancellation — telling the other goroutines that the answer is no longer wanted — which is the last chapter of this Part.
A note on this playground, because Chapter 4 depends on it. Real Go detects the case where every goroutine is blocked and stops the program with fatal error: all goroutines are asleep - deadlock!, and its race detector (go run -race) reports unsynchronised access. This interpreter does neither: it runs one goroutine at a time and only switches at blocking points, so unsynchronised counters happen to come out right and a total deadlock simply hangs. Both instruments are worth knowing and neither can be demonstrated here — the mutex in the blocks above is correct Go, not decoration, and Chapter 4 explains what it is preventing.
The goroutine leak has a recognisable clinical picture. A service is fine for hours and then, some time after a traffic peak, memory climbs steadily and never comes back down — a straight line, not a sawtooth, because nothing is being collected. Restarting fixes it, which is exactly what makes it survive so long: it looks like a slow memory leak in a cache, and the graph that would identify it is goroutine count, which climbs in lockstep and is almost never on the dashboard.
The cause is usually one handler that starts work and returns early — the client disconnected, a timeout fired, the fastest of three replicas answered — leaving the others sending into a channel nobody will ever read. Under normal load the timeout rarely fires and the leak accumulates at a few goroutines an hour. Under a peak it fires constantly, and the service acquires a permanent floor of blocked goroutines that only a restart clears. The diagnosis is a one-liner once you know to look: /debug/pprof/goroutine?debug=1, which prints every live goroutine grouped by where it is parked. Ten thousand of them stopped on the same line of the same function is not subtle. Not having that endpoint mounted is why the outage lasted a day.
Every goroutine needs an owner
The discipline that prevents all of this is one sentence: whoever starts a goroutine is responsible for knowing how it stops. Not "usually stops" — knowing, by construction, where its last statement is and what guarantees it gets there.
Click Run to execute the code.
The shape is worth memorising, because it is the backbone of most Go concurrency and every part of it is deliberate. The workers take a receive-only jobs channel and a send-only out channel, so the compiler enforces the direction of flow and a worker cannot accidentally close its own input. for j := range jobs terminates when the channel is closed and drained, which makes close(jobs) the stop signal — one statement that reaches every worker at once, with no flag to poll. wg.Wait() returns only when every worker's defer wg.Done() has fired, so afterwards you know nothing is still running. And close(out) happens after the wait, never before, because closing a channel while a sender still holds it panics.
There is a fourth thing here that is easy to miss: out is buffered with room to spare. If it were unbuffered and main waited before draining it, the workers would block on send, wg.Wait() would never return, and the program would hang — the leak from the last section, wearing a suit. Ownership is not just about starting and stopping; it is about making sure the path to the last statement cannot be blocked by the owner itself.
Common Mistakes
Using
time.Sleepto wait for goroutines. It is a guess that fails when the machine is loaded and wastes time when it is not. Use aWaitGroupor a channel receive — something that is correct at every speed.Calling
wg.Add(1)inside the goroutine.Waitcan run before the goroutine is scheduled, see a zero counter and return immediately.Addbelongs in the launching goroutine, before thegostatement.Not deferring
wg.Done(). An early return or a panic skips the decrement, and the symptom is not an error — it isWaitblocking forever with no indication of why.Expecting a return value from
go f(). Whateverfreturns is discarded silently. Results come back through a channel or through shared state under a lock, never from thegostatement.Assuming completion order. Goroutines finish in whatever order the scheduler produces, and it varies between runs. If order matters, impose it — sort the results, index into a preallocated slice, or use a channel per stage.
Capturing the loop variable instead of passing it. Before Go 1.22 every goroutine shares one variable and they all read its final value. Passing it as a parameter is correct on every version and says what you meant.
Sharing a variable without a lock because "it's just an increment".
count++is a read, an add and a write, and two goroutines interleaving there is a data race — undefined behaviour, not merely a wrong number.Sending on a channel nobody is guaranteed to read. The sender blocks forever, the goroutine is never collected, and it holds everything it references. This is the leak, and it is silent.
Launching goroutines in an unbounded loop. One per item over a stream with no limit creates work faster than it retires. Bound it — a fixed pool of workers reading a channel, as in the last section.
Closing a channel from the receiving side, or while senders remain. A send on a closed channel panics. The sender closes, and only after every other sender is provably finished.
Interview Corner
go f() do, and why does this program print nothing?func main() { go fmt.Println("hi") }go f() starts f in a new goroutine and the statement returns immediately, so main carries straight on to its closing brace. When main returns the process exits and every other goroutine is killed instantly — no unwinding, no deferred calls — so the Println never gets a chance to run. The fix is not time.Sleep, which is a guess that breaks on a loaded machine; it is to wait for something real. A sync.WaitGroup is the usual tool: wg.Add(1) before the go, defer wg.Done() as the goroutine's first line, and wg.Wait() before main returns. I would also note that go gives me no return value — if I needed the result I would send it on a channel, because the go statement itself discards whatever the function returns./debug/pprof/goroutine?debug=1 shows them all parked on the same line. There are two fixes at different levels. The cheap one is buffering the channel with room for every sender — make(chan result, 3) — so a send always completes and each goroutine returns whether or not anyone reads it; that is right when the extra work is bounded and harmless. The real one is cancellation: pass a context the handler cancels on return, and have each lookup select on ctx.Done(), so the slow replicas stop doing work rather than merely stop blocking. I would use both — the buffer to guarantee no leak, the context to stop the wasted work.Hands-on
Four exercises, from launching to owning. Exercise 3 is the one that matters — collecting results from concurrent work without a race and without assuming completion order. Exercise 4 is the worker-pool shape you will write for the rest of your career.
Click Run to execute the code.
Summary
go f()startsfin a new goroutine and returns immediately. It promises the call will happen — nothing about when, whether it finishes, or whether anyone is still listening.- When
mainreturns, every other goroutine is killed instantly — no unwinding, no deferred calls, no warning. time.Sleepis not a synchronisation tool. It is a guess that fails on a loaded machine and wastes time on an idle one. Wait on aWaitGroupor a channel.wg.Addbefore thego,wg.Donein adefer.Addinside the goroutine races withWait; a missingDonehangs forever with no error.- Goroutines start at about 2 KB against a thread's ~1 MB, so twenty thousand of them is a normal design and pooling to "save resources" is usually a pessimisation.
- The win is overlapping waits, not faster arithmetic. Three 60 ms waits take 60 ms concurrently on a single core — nothing computed faster, the idling stopped.
- A leaked goroutine is permanent. A goroutine is a GC root, so one blocked on a send nobody will read holds its stack and everything it references until the process dies.
- Pass loop variables as parameters. Go 1.22 made loop variables per-iteration, but the parameter form is correct on every version and states the intent.
- Bound concurrency by the scarcest downstream resource, not by the arrival rate. A fixed pool reading a jobs channel also gives you backpressure for free.
- Whoever starts a goroutine owns its ending.
closethe jobs channel to signal,Waitto confirm, and only thenclosethe output — closing while a sender remains panics.
Goroutines are the easy half. Every one of them in this chapter needed a channel to be useful — to hand back a result, to distribute work, to say "no more is coming" — and channels were used here as though their semantics were obvious. They are not. The next chapter takes the channel apart as a type: buffered against unbuffered and what each one actually guarantees, what close means and who is allowed to do it, why a receive from a closed channel succeeds and a send panics, what a nil channel is for, and why the direction arrows in chan<- int are worth writing. After that, select — the construct that turns waiting on one thing into waiting on any of several — and then the sync primitives, and finally context, which is the real answer to the leak in this chapter.
Go's concurrency is usually praised for being easy, and that praise is subtly wrong in a way worth naming. What Go made easy is starting concurrent work. Two characters, no ceremony, no pool, no executor — a genuine achievement, and a real reduction in the distance between "I could do these at the same time" and code that does. What Go did not make easy, because nobody has, is ending it. Every hard bug in this chapter — the abandoned work, the leaked sender, the unbounded spawn — is a lifecycle bug, and the language offers no help with lifecycles at all. There is no handle, no join, no ownership in the type system, no warning when a goroutine can never be reached again.
You can read that as an oversight, and some of the criticism lands. A go statement whose result nobody can observe is a plausible vet check that does not exist. That context.Context — the actual answer to cancellation — arrived years later as a library, and has to be threaded manually through every function signature in a program, is a retrofit and reads like one.
The other reading is that the asymmetry is the honest part. Making it hard to start concurrent work does not make lifecycles simpler; it just means fewer people attempt concurrency, and the ones who do get the same bugs with more boilerplate around them. Go's bet was that the discipline — who starts it, who waits, how it stops — cannot be supplied by syntax and has to be supplied by the programmer, so the language may as well remove the friction that has nothing to do with the hard part. Two decades of Go code suggests the bet was roughly right and the cost is exactly what this chapter documents: the easiest concurrency in any mainstream language, and a failure mode that is completely silent.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.