// lesson: anatomy-of-a-goroutine
The Anatomy of a Goroutine
You have written go f() a thousand times. This course is about what happens
next โ and the best way to understand it is to build the machinery yourself,
in ordinary Go, one piece at a time.
Start with the object itself. A goroutine is not a thread. It is a small heap
structure the runtime calls a g, defined in runtime/runtime2.go:
// Heavily abridged from runtime/runtime2.go.
type g struct {
stack stack // [stack.lo, stack.hi) โ the goroutine's stack bounds
stackguard0 uintptr // stack-growth (and preemption!) check value
sched gobuf // saved SP, PC, etc. โ the "context" in context switch
atomicstatus atomic.Uint32 // _Grunnable, _Grunning, _Gwaiting, ...
goid uint64
waitreason waitReason // why it's parked ("chan receive", "sleep", ...)
}
Three things make a g cheap where an OS thread is expensive:
- Tiny, growable stacks. A goroutine starts with a ~2 KB stack (since Go
1.19 the runtime may pick a larger start size based on the historical
average). Every function prologue compares the stack pointer against
stackguard0; on overflow,morestackallocates a bigger stack and copies the old one over โ contiguous stacks. An OS thread reserves megabytes of virtual address space up front and can never shrink it. - Userspace context switches. Switching goroutines means saving a handful
of registers into
g.sched(agobuf: SP, PC, and a pointer to theg) and loading another goroutine's. No syscall, no kernel scheduler, roughly the cost of a function call. The assembly routines aregogoandmcall. - M:N scheduling. Millions of Gs are multiplexed onto a few OS threads.
The runtime's scheduler is described by three letters you'll see constantly
in runtime/proc.go:
- G โ a goroutine: stack + saved registers + status.
- M โ a machine: an actual OS thread that executes Gs.
- P โ a processor: a scheduling context holding a local run queue of
runnable Gs (plus allocation caches). There are exactly
GOMAXPROCSPs. An M must hold a P to run Go code; an M without a P is parked or sitting in a syscall.
go f() compiles to a call to runtime.newproc, which allocates (or
recycles) a g, seeds its gobuf so it will "return into" f, marks it
_Grunnable, and drops it on the current P's run queue. That's all โ the
statement returns immediately, and f runs whenever a P picks it up. This is
also why main returning kills the program without waiting for anyone:
nothing counts outstanding goroutines. The runtime deliberately has no
"join"; if you want one, you build it from synchronization primitives โ
which is exactly your first challenge.
โบ A WaitGroup from Scratch
20 ptsBuild the "join" primitive the runtime doesn't give you. Implement two package-level functions:
func Spawn(f func()) // start f concurrently; return immediately
func WaitAll() // block until every spawned function has returned
Semantics your implementation must satisfy:
WaitAllreturns immediately when nothing is in flight (including before the firstSpawnever happens).WaitAllblocks until every spawned function has returned โ including functions spawned by spawned functions. As long as a running spawned function callsSpawnbefore it returns, the count never touches zero, so transitive work is covered automatically.- The package is reusable: after
WaitAllreturns you canSpawnagain andWaitAllagain.
sync.WaitGroup is banned โ using it would be building a WaitGroup out
of a WaitGroup. (The grader can't truly detect it; this is between you and
the duck.) The intended shape is an atomic counter plus a channel used as a
one-shot park/unpark signal: Spawn increments before starting the
goroutine, a deferred decrement detects the drop to zero and wakes waiters.
A sync.Mutex guarding the counter alongside a "currently idle" channel is
also a fine design. Think hard about the classic bug: incrementing inside
the new goroutine instead of before it starts โ a WaitAll racing that
increment sees zero and returns early.
Log in to submit a solution and earn points.