AnyLearn
All lessons
Programmingintermediate

Goroutines: Concurrency Cheap Enough to Stop Counting

A thread costs enough that programs are architected around not having many. A goroutine costs little enough that the constraint disappears, which changes what designs are available. This lesson explains where the saving comes from, what the runtime scheduler does with them, and what the abundance does not fix.

Updated · AI-authored, review-gated · how lessons are made

Not signed in: your progress and quiz score won't be saved.
Progress1 / 8

The cost that shapes the architecture

An operating system thread is expensive in two ways, and both shape how programs get written.

It has a fixed stack, typically reserved at a megabyte or more, because the kernel cannot grow it later. Ten thousand threads is ten gigabytes of address space reserved before anything runs.

And switching between threads means a kernel transition: save registers, enter the kernel, run the scheduler, restore, return. It is not catastrophic and it is far from free, and it happens constantly.

So languages built on OS threads develop the same defensive architecture. Thread pools exist so threads are reused rather than created. Asynchronous and event-loop designs exist so one thread serves many connections, which the lesson Async, Event Loops, and Futures covers.

Every one of those is a workaround for the same cost. Go's approach is to attack the cost directly, and much of the language's character follows from the attack succeeding.

Full lesson text

All 8 steps on one page, for reading, reference, and search.

Show

1. The cost that shapes the architecture

An operating system thread is expensive in two ways, and both shape how programs get written.

It has a fixed stack, typically reserved at a megabyte or more, because the kernel cannot grow it later. Ten thousand threads is ten gigabytes of address space reserved before anything runs.

And switching between threads means a kernel transition: save registers, enter the kernel, run the scheduler, restore, return. It is not catastrophic and it is far from free, and it happens constantly.

So languages built on OS threads develop the same defensive architecture. Thread pools exist so threads are reused rather than created. Asynchronous and event-loop designs exist so one thread serves many connections, which the lesson Async, Event Loops, and Futures covers.

Every one of those is a workaround for the same cost. Go's approach is to attack the cost directly, and much of the language's character follows from the attack succeeding.

2. What a goroutine is

A goroutine is a function scheduled by Go's runtime rather than by the kernel. Starting one is a keyword.

go doWork(x)        // returns immediately; doWork runs concurrently

Two design decisions make it cheap.

The stack starts tiny and grows. A goroutine begins with a few kilobytes rather than a megabyte. When it needs more, the runtime allocates a larger stack, copies the contents, and continues. Because the runtime controls the pointers, it can relocate a stack, which a kernel cannot do for a thread.

Switching stays in user space. The runtime schedules goroutines onto OS threads itself, so a switch is saving and restoring a small amount of state with no kernel transition at all.

The result is that a goroutine costs roughly what an object costs, and hundreds of thousands are routine. What matters is not the number but the removal of a constraint: you stop designing around how many you can afford, and start writing one goroutine per logical activity because that is the clearest expression of the program.

3. Many goroutines onto few threads

This is M:N scheduling: M goroutines multiplexed onto N OS threads, with N defaulting to the number of cores.

Two layers of scheduling are stacked. The runtime decides which goroutine runs on which thread; the kernel decides which thread runs on which core. Neither layer knows much about the other, and the arrangement works because the expensive layer is used sparingly.

The last box is where the design earns its keep. When a goroutine blocks on a system call, the runtime detaches it and puts another goroutine on that thread. The thread never sits idle waiting.

That is the same benefit an event loop provides, obtained without the programming model an event loop imposes. In an async design, avoiding a blocking call is the programmer's responsibility, and one blocking call stalls everything. Here the runtime handles it, so blocking code is fine: you write straight-line sequential code and the runtime does the multiplexing.

That is the trade in one line. Async languages make the multiplexing visible in the source; Go hides it in the runtime.

flowchart TD
A["Thousands of goroutines"] --> B["Runtime scheduler"]
B --> C["OS thread 1"]
B --> D["OS thread 2"]
B --> E["OS thread N, N = cores"]
C --> F["Kernel schedules threads onto cores"]
D --> F
E --> F
B --> G["Goroutine blocks on I/O: thread reused"]

4. Why blocking code stays readable

The consequence for how code looks is larger than the performance story, and it is the reason people describe Go as boring in a complimentary way.

In an async language, a function that might wait is coloured: it must be marked, its callers must await it, and its callers must be marked too. The distinction propagates up the call graph and splits libraries into synchronous and asynchronous versions of the same thing.

In Go there is no such marking. A function that reads from a network looks like a function that reads from memory, and any function may be run concurrently by prefixing a call with go.

func handle(c net.Conn) {
    data, err := io.ReadAll(c)     // blocks this goroutine only
    if err != nil { return }
    c.Write(process(data))
}

for {
    conn, _ := listener.Accept()
    go handle(conn)                // one goroutine per connection
}

That loop is the canonical Go server, and it is the naive design that other runtimes force you to abandon. One goroutine per connection, blocking reads, sequential logic, and it scales to very large connection counts because the goroutines are cheap and the runtime multiplexes them.

The complexity did not disappear; it moved into the runtime, where it is written once.

5. When a goroutine yields

A cooperative scheduler has a classic failure: a goroutine that never yields starves everything on its thread. Go's handling of this is worth knowing because it changed.

Goroutines yield at natural points: channel operations, system calls, memory allocation, function calls that need stack growth. In ordinary code those occur constantly, so the scheduler gets frequent opportunities.

The gap was a tight loop with no calls, no allocation and no channel work. Such a loop had no yield point and could hold its thread indefinitely, and this was a real and reported problem.

Since Go 1.14 the runtime supports asynchronous preemption: it can signal a running goroutine and force it to yield even without a cooperative point. The pathological loop no longer starves the scheduler.

The general lesson is about where the responsibility sits. Under cooperative scheduling, a badly behaved task is everyone's problem, which is a known weakness of event loops. Preemption moves that guarantee into the runtime, so one function's behaviour cannot degrade unrelated work.

An honest note on GOMAXPROCS, which controls how many goroutines run in parallel: it defaults to the core count, and tuning it is rarely the answer to a performance problem.

6. What cheap concurrency does not fix

Goroutines make concurrency affordable, not correct, and the distinction is where most Go bugs live.

Shared state is still shared. Two goroutines writing the same variable is a data race with exactly the consequences the lesson Threads and Shared State describes. Unlike Rust, whose borrow checker rejects this at compile time, Go permits it and expects discipline.

The practical mitigation is the race detector, enabled with go test -race. It is genuinely good, and it is a runtime tool: it reports races on the interleavings that actually occurred during the run. A race on a path not exercised is not reported, so it is testing rather than proof, exactly as the verification cursus frames it.

Goroutines can leak. A goroutine blocked forever on a channel nobody will use is never collected and holds everything it references. This is the most common Go-specific bug and the third lesson is about it.

Cheapness invites excess. Starting a goroutine per item in a large slice creates real scheduling and memory pressure. Cheap is not free.

The honest summary: Go removed the cost barrier to concurrency and left the correctness problems exactly where they were.

7. Three models, three trades

OS threadsAsync and event loopsGoroutines
Cost of oneMegabyte stack, kernel switchVery lowKilobytes, user-space switch
How manyThousands at mostVery manyHundreds of thousands
Blocking callsFine, but expensive to have manyPoison the loopFine; runtime reassigns the thread
Function colouringNoneYes, propagates through callersNone
Uses all coresYesOne loop per core, manuallyYes, by default
Complexity sitsIn the kernelIn your sourceIn the runtime

The last row is the summary. Every model pays for multiplexing somewhere, and they differ in where the payment is visible.

The fourth row is the one developers feel daily. Function colouring is a genuine tax on library ecosystems: two versions of everything, and a synchronous library unusable from asynchronous code. Go avoids it entirely, and that is arguably a bigger practical win than the performance.

What the table cannot show is how goroutines communicate. Cheap concurrency without a good coordination mechanism just means many opportunities for shared-memory bugs, and Go's answer to that is a fifty-year-old idea, which is the next lesson.

8. What the cheapness unlocks

The designs that become available are the real point, and they are all cases where a goroutine models something that was previously implicit.

One goroutine per connection. The server above. Each connection's state lives in its goroutine's stack and local variables rather than in a state machine you maintain by hand.

One goroutine per pipeline stage. Stages connected by channels, each doing one transformation, with backpressure emerging naturally from a full channel rather than being engineered.

One goroutine per timeout or background task. A supervisory goroutine that waits and acts is affordable enough to be unremarkable.

One goroutine per element of a fan-out. Fire ten requests concurrently and collect them, without a thread pool or an executor.

The unifying observation: a goroutine is the natural unit for an independent sequence of steps, and when they are cheap you can afford one wherever the problem has such a sequence. The program's structure then mirrors the problem's structure.

That only works if coordinating them is straightforward, which returns to the open question. Cheap concurrency plus shared memory is a bug generator; cheap concurrency plus a good communication primitive is a programming model.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. Why can a goroutine's stack start at a few kilobytes when an OS thread's cannot?
    • Because the runtime controls the pointers and can relocate the stack when it grows
    • Because goroutines never recurse deeply
    • Because the kernel allocates goroutine stacks lazily
    • Because goroutines share a single stack between them
  2. What happens when a goroutine blocks on a system call?
    • All goroutines on that thread block until it completes
    • The runtime detaches it and schedules another goroutine on that thread
    • The runtime converts the call to a non-blocking one automatically
    • The goroutine is terminated and restarted
  3. What is 'function colouring' and how does Go avoid it?
    • Marking functions as pure or impure; Go has no side-effect tracking
    • Tagging functions by goroutine affinity; Go pins goroutines to threads
    • Marking functions that may wait, which propagates to all callers; Go has no such marking
    • Annotating functions with their stack size; Go grows stacks dynamically
  4. What problem did asynchronous preemption solve?
    • Goroutines using too much memory in tight loops
    • The kernel scheduling threads unfairly across cores
    • Channel operations blocking indefinitely
    • A tight loop with no calls, allocation or channel work holding its thread indefinitely
  5. What does the race detector establish?
    • That races occurred on the interleavings actually exercised during the run
    • That the program is free of data races on all inputs
    • That the program contains no deadlocks
    • That shared state is protected by a mutex everywhere

Related lessons

Programming
advanced

Refs: A Branch Is a File Containing a Hash

Hashes are unusable by hand, so Git names them. A branch is not a copy of anything, not a directory, and not a container for commits: it is a forty-character file. Once that is clear, checkout, reset, detached HEAD and the reflog stop being separate concepts and become one operation on one kind of file.

8 steps·~12 min
Programming
advanced

Scheduling and Resources: Requests Are Not Limits

Two numbers govern where a pod lands and how it behaves under pressure, and they do completely different jobs. Requests are used for placement and are a promise; limits are enforced at run time and are a ceiling. This lesson separates them, covers the asymmetry between CPU and memory enforcement, and explains what actually happens when a node runs out.

8 steps·~12 min
Programming
advanced

The Runtime Stack, and What Isolation Is Worth

One command hides four layers of software and a set of standards that made them interchangeable. This lesson takes the stack apart, then asks the question the whole path has been building toward: given a shared kernel, how much is container isolation actually worth, and what has to be added before it is a security boundary.

8 steps·~12 min
Programming
advanced

Networking and Storage: Connecting an Isolated Thing

Isolation is the point, and a container that can reach nothing and keep nothing is useless. This lesson covers how a network namespace is wired to the outside, why published ports and container-to-container traffic work completely differently, and where data has to live given that the writable layer disappears.

8 steps·~12 min