Deep Engineering
Advanced·Published·30 MIN

Goroutines and the Go scheduler: two thousand bytes, preemption, and a GOMAXPROCS that limits the wrong thing

The interview climbs a ladder: how a goroutine differs from a thread — what it costs — what GMP is — what GOMAXPROCS limits — whether the scheduler is preemptive — and when a goroutine leaves the CPU. Measured: 2000 bytes of stack plus 500 of runtime structures, a spawn-and-wait 82 times dearer than a call, and yielding the CPU at 106 ns.

Full technical treatment

TL;DR

A goroutine is not an operating-system thread but a unit of work belonging to the Go program itself. One word in front of an ordinary call starts it, it costs incomparably less than a thread, and so a program has thousands of goroutines with only a handful of threads underneath them. Which one is on a CPU, and when, is decided by the program rather than by the kernel.

Hence the main consequence: far fewer goroutines run at once than exist. That is exactly where the most common answer — "the number of goroutines is limited" — breaks. Measured: four goroutines doing compute work take 97.7 ms against 28.4 ms for one — four times longer, because they compute in turn. And main does not wait for the goroutines it launched: returning from it ends the process.

Beyond that come the letters, the numbers and the versions. G is a goroutine, M an OS thread, P the right to execute Go code; the number of Ps is GOMAXPROCS, and what it limits is how many run simultaneously rather than how many exist: at GOMAXPROCS=2 the same four goroutines fit into 52.5 ms. A goroutine can be weighed: 2000 bytes of stack plus ~500 bytes of structures — against megabytes for an OS thread. The scheduler has been preemptive since Go 1.14: a loop without a single call is still taken off the CPU by a signal, while before 1.14 such a loop hung the program together with garbage collection. And the cost: a direct call at 6.48 ns against 531.33 ns with a spawn and a wait — 82 times, but against work of six nanoseconds; runtime.Gosched() adds 106 ns, and what costs there is the parking rather than the operation. The numbers were taken on one machine and one runtime version: what carries meaning is the ratios and their causes, not the nanoseconds themselves.

Where to start
Before this lesson it is enough to understand
  • a program can be doing several things at once, and something has to decide which of them is on a CPU right now;
  • an operating-system thread is an expensive thing: they are created by the handful, not by the thousand;
  • a machine has few cores, and truly at the same time it runs as many pieces of work as it has cores.
You do not need to know in advance
  • what GOMAXPROCS is, the letters G, M, P, the netpoller and runtime.Gosched();
  • how a preemptive scheduler differs from a cooperative one and what changed in Go 1.14;
  • how many bytes a goroutine takes and what spawning one costs.

What is really being asked

The ladder is almost always this one:

  1. "How does a goroutine differ from a thread?" — testing whether you name a number.
  2. "What does it cost?" — a question about honesty: "little" without a number is not an answer.
  3. "What is GMP?" — many know the three letters; few know what P means.
  4. "What does GOMAXPROCS limit?" — most people answer "the number of goroutines", and that is wrong.
  5. "Is the scheduler preemptive or cooperative?" — a question with a date: before Go 1.14 and after.
  6. "When does a goroutine leave the CPU?" — about parking and switch points.

The lesson climbs that ladder. Its spine is one sentence: a goroutine is a runtime object, not an operating-system one.

Base: a goroutine is not an operating-system thread

The place to start is what a goroutine is not: nearly every wrong interview answer grows from the same transfer — from the habit of thinking about it as a thread.

An operating-system thread is a kernel entity, and it is expensive: its own record in the kernel, its own stack of megabytes, every switch going through the kernel. Threads are therefore created by the handful, and each one is thought about in advance.

A goroutine belongs to the Go program itself, and three of its properties are worth naming before any machinery:

  1. It is not a thread. Underneath a thousand goroutines there are not a thousand threads but noticeably fewer — a handful of them.
  2. Starting one costs a single word in the code. go goes in front of an ordinary call, the call starts running on its own, and the program moves on without waiting for it to finish.
  3. There can be very many of them. A thousand goroutines is not a load but an ordinary state for a server, and what bounds their number is memory rather than a prohibition.

Hence the question this lesson is about: if there are thousands of goroutines and only a handful of places to run in, who hands those places out, and how? The answer: the Go program itself does. Inside it lives a scheduler that decides which goroutine is on a CPU right now and lays thousands of goroutines over a few threads — without asking the kernel on every switch.

That is already enough to answer the basic interview question, "how does a goroutine differ from a thread". Everything below is about how much cheaper it actually is, who hands out processor time and by what rules, what happens when a goroutine goes off to wait, and why one goroutine cannot hold a place forever.

Mechanism 1: what problem the scheduler solves

Before working through the letters it is worth saying why this machinery exists at all. The scheduler has exactly one problem, and it can be stated without a single Go term:

There are orders of magnitude more runnable goroutines than there are places to run. Places number as many as there are cores; goroutines, as many as the program created.

Everything else in the lesson answers questions that follow from that picture: who decides which G takes a free place; what to do when the goroutine holding a place goes off to wait; and how to stop one goroutine holding a place forever.

language contractThe problem statement and the language guarantees. The scheduler's construction starts at Mechanism 2, and it has changed between versions.

Go's answer consists of two decisions. First, make the unit of work cheap, so that thousands and millions are normal. Second, schedule them in user space, without going to the kernel on every switch.

Start with the first, because it is measurable.

A goroutine is a runtime structure, and it can be weighed

An OS thread is a kernel entity: its own table, a stack of megabytes, switching through the kernel. A goroutine is a structure on the Go process's heap with its own stack, and all the switching happens inside the process.

Hence the numbers. Measured over a thousand sleeping goroutines (bench/gosched/practice.go), divided by a thousand:

bytes per goroutine
stack (StackInuse)2000
runtime structures (HeapAlloc)~500
measured observationbench/gosched/practice.go, go1.24.7 linux/amd64. Both numbers were taken on one runtime version; what is stable here is that the stack and the structures are accounted separately, not the bytes themselves.

Two kilobytes is _StackMin from runtime/stack.go, the "minimum size of stack for new goroutines". And it is not a cap but a start: the stack grows as needed, copied to a new place, so there is no need to budget for the maximum in advance.

implementation detail · Go 1.24_StackMin is a constant in runtime/stack.go — the construction of one runtime version rather than a language guarantee.

And the boundary of that number is named by the very place it comes from: _StackMin is a runtime constant, not a promise of the language. So the rule is written not from two thousand but from the order of magnitude: a goroutine costs kilobytes where an OS thread costs megabytes — and it is that gap, not the number, that survives a change of version.

These two numbers deserve a note. The first draft of the measurement counted only HeapAlloc and got ~500 bytes — a number that contradicts the common "a goroutine costs two kilobytes". There is no contradiction: a goroutine's stack is not in HeapAlloc at all, it is accounted separately, in StackInuse. One number without the other gives a wrong answer, and in an interview both are worth naming.

The practical point people want to hear: a million goroutines is gigabytes, not madness. A million OS threads is impossible; a million goroutines is a question of memory.

Mechanism 2: G, M and P — and the whole point is the third letter

implementation detail · Go 1.24The three letters come from a comment in runtime/proc.go: this describes the construction of one runtime version, not a language contract. The scheduler has already changed between versions — the most visible example is at Mechanism 4.

The three letters are defined in the scheduler's own comment:

G - goroutine. M - worker thread, or machine. P - processor, a resource that is required to execute Go code. M must have an associated P to execute Go code.

runtime/proc.go

G and M need no explaining. The whole point is P, and it should be explained not as "a processor" but as a right: the right to execute Go code. There are exactly GOMAXPROCS of those rights, and a thread left without one executes no Go code.

Why the extra layer? Because it separates two different limits:

  • how many goroutines may compute at once — the number of Ps;
  • how many OS threads exist — the number of Ms, and it is larger: when a goroutine goes into a system call its M blocks along with it, hands its P to another thread, and the work carries on.

Which is exactly why the documentation says separately:

The GOMAXPROCS limit does not count threads blocked in system calls.

The runtime package — GOMAXPROCS

Queues and work stealing

The queues are not observable from a program, so no numbers here — but the construction is worth naming, because the scheduler's main property follows from it.

A freed P takes from its own local queue; if that is empty, from the global one; if that is empty too, it steals half of a random neighbour's.

Why local queues at all. A single shared queue would need a lock on every scheduling decision — that is, contention growing with the number of cores. A local queue needs no synchronisation in the common case, and the lock is only needed when stealing.

Work stealing is why load spreads without a dispatcher. Nobody hands goroutines out to processors; an idle P finds work for itself. That has an observable consequence: a goroutine may start running on one core and continue on another, and counting on "its" core is not possible.

Mechanism 3: a system call and I/O are two different paths

implementation detail · Go 1.24Handing over a P and the netpoller are runtime construction, not language guarantees: the observable consequence — waiting on the network does not occupy a thread — is stable, while the machinery has changed between versions.

The two ways of "going off to wait" are built differently, and the difference gets asked about.

A system call blocks the thread. The M goes into the kernel with the goroutine — the kernel knows nothing about goroutines. So that the stall does not halt the rest of the work, the scheduler takes the P away from that M and hands it to another thread.

Hence the documentation's caveat about GOMAXPROCS: threads blocked in system calls do not count towards the limit. The number of Ms is larger than the number of Ps, and that is how it grows.

Network I/O does not block the thread. Here the netpoller works: the socket is put into non-blocking mode, the goroutine is parked, and the M keeps its P and picks up the next piece of work. When the data arrives, the kernel notifies the runtime (epoll, kqueue, IOCP) and the goroutine goes back on the runnable queue.

That is why a Go server holds tens of thousands of connections on a handful of threads: waiting on the network does not cost a thread. Reading a file does, because there is no such mechanism for disk operations.

The full list of reasons to leave the CPU

The list is short and worth being able to recite:

  • it blocked itself — a channel, a mutex, a WaitGroup, network I/O;
  • a system call — the M goes into the kernel with the goroutine and hands over its P;
  • waiting on the network — the goroutine is parked while the M keeps its P and works on;
  • runtime.Gosched() — a voluntary yield;
  • preemption by time — roughly every 10 ms, by signal, since Go 1.14;
  • garbage collection — for short stop-the-world pauses.

And separately — main waits for nobody:

The function value and parameters are evaluated as usual in the calling goroutine, but unlike with a regular call, program execution does not wait for the invoked function to complete.

The Go specification — Go statements

Returning from main ends the process, and every launched goroutine dies on the spot — with no deferred calls and no chance to finish writing anything. The wait must be explicit: sync.WaitGroup, a channel or errgroup.

Mechanism 4: the scheduler is preemptive — but only since Go 1.14

A question with a date, and both halves matter.

Before Go 1.14 the scheduler was cooperative: it could only switch where the goroutine gave it an opening — a function call, a channel operation, an allocation. A loop without a single call

GO
for i := 0; i < 1e10; i++ {
    x = x*1664525 + 1013904223
}

gave no such point. At GOMAXPROCS=1 it hung the whole program — including garbage collection, which needs to stop every goroutine, and this one could not be stopped.

Since Go 1.14 goroutines are preempted asynchronously, by a signal:

goroutines are now asynchronously preemptible. As a result, loops without function calls no longer potentially deadlock the scheduler or significantly delay garbage collection.

Go 1.14 release notes

Verified by running it: at GOMAXPROCS=1 the neighbouring goroutine did get the CPU while the compute loop was running — true in the run of bench/gosched/internals.go.

Why this is worth knowing rather than just memorising the date. Because a trace of that era survives in other people's code: runtime.Gosched() sprinkled through loops "so as not to hang the scheduler". Today it is unnecessary there: the scheduler takes the goroutine off without it.

And this is also the model case for how claims in this topic are bounded. The answer "the scheduler is cooperative" was true and stopped being true in a particular version: the scheduler's behaviour is part of the implementation, not a contract of the language. So any claim about it is named together with the version it was checked on.

Mechanism 5: GOMAXPROCS limits the simultaneous, not the existing

This is the most common wrong answer, and a measurement settles it.

GOMAXPROCS sets the maximum number of CPUs that can be executing simultaneously and returns the previous setting.

The runtime package — GOMAXPROCS

The key word is simultaneously. There may be any number of goroutines; what is limited is how many are computing right now. Switch GOMAXPROCS — there are four goroutines in the figure the whole time, and only how many sit on CPUs changes:

The numbers under the figure come from a run of the same compute task:

1 goroutine4 goroutines
GOMAXPROCS=128.4 ms97.7 ms
GOMAXPROCS=224.4 ms52.5 ms
measured observationbench/gosched, go1.24.7 linux/amd64, two cores. What is stable is the ratio — four times with one P and twice with two — rather than the milliseconds: on another machine they will be your own.

With one P, four times the work takes four times the time: the goroutines compute in turn. With two, roughly twice. There are four goroutines throughout.

What to know about the default. It equals the number of available cores. In a container with a CPU limit that is often the host's core count rather than the container's limit — and then the runtime creates noticeably more Ps than it is allowed to compute on. The cure is either an explicit GOMAXPROCS or a library that reads the cgroup limits.

implementation detail · Go 1.24The default and the way the runtime treats cgroup limits are the behaviour of one version rather than a contract: the documentation itself warns that the call will go away when the scheduler improves.

And here the boundary is worth naming, because its absence is what produces the surprise on a move. How many threads actually execute Go code is a property of the runtime version and the environment, not of a line in the code: the documentation states outright that this call will go away when the scheduler improves. So the rule is written from the boundary: the number of simultaneously executing threads is read off the running process on the Go version it runs on, rather than derived from the machine's core count.

Deeper: what costs is the parking, not the operation

measured observationbench/gosched, go1.24.7 linux/amd64, two cores. The numbers were taken on one machine; what carries meaning is the ratio and its cause, not the absolute nanoseconds.

Two measurements here, and together they give a rule.

First — spawning a goroutine. The same work, one atomic increment:

time
direct call6.48 ns
spawn a goroutine and wait for it531.33 ns
ratio×82

Eighty-two times looks frightening right up until you say against what: against work of six nanoseconds. That is the price of the spawn plus two switches — and as soon as there is real work inside the goroutine, the spawn's share falls to nothing.

Second — yielding the CPU. The same work, but the goroutine gives up the processor:

time
atomic increment6.39 ns
the same + runtime.Gosched()112.90 ns
the same + time.Sleep(0)8.80 ns
the price of yielding106.51 ns

The third row matters more than the second: time.Sleep(0) is nearly free because it returns immediately and does not park the goroutine. So what costs is not "talking to the scheduler" but being taken off the CPU and put back.

Hence, too, the price of that runtime.Gosched() sprinkled through loops in old code: about a hundred nanoseconds per call spent on nothing, because since Go 1.14 the scheduler takes the goroutine off without being asked.

Hence a rule that carries over to channels, mutexes and WaitGroup: while a goroutine does not block, the scheduler plays no part in its cost. An uncontended mutex is an atomic operation; a channel with a waiting receiver is a copy. What costs is the waiting, not the operation.

Both measurements share one boundary: they were taken on one machine, two cores and one version of Go. What travels from here is the cause — that coming off a processor and going back on is what costs — rather than "82 times" or "106 ns": your numbers will be your own and the conclusion will be the same.

How to answer in an interview

Short answer: a goroutine is not an operating-system thread but a unit of work belonging to the Go program itself. One word, go, starts it; it costs kilobytes against a thread's megabytes; and which one is on a CPU right now is decided by a scheduler inside the process. That is why a program has thousands of goroutines with a handful of threads, and why GOMAXPROCS limits not how many goroutines exist but how many run at once.

That is enough for a correct answer. What follows is what you add when the interviewer digs.

If the interviewer digs deeper

To "how does it differ from a thread" answer with a number. "A runtime structure with its own stack: two kilobytes of stack plus hundreds of bytes of structures against megabytes for an OS thread; switching inside the process rather than through the kernel."

On GMP, explain P as a right. "A P is the right to execute Go code, and there are exactly GOMAXPROCS of them; a thread without a P executes no Go code. Hence there can be more threads than Ps: one that goes into a system call hands its P to another."

On GOMAXPROCS say "simultaneously". "It limits not how many goroutines exist but how many run at once; with GOMAXPROCS=1 my four goroutines took four times as long as one." "How many goroutines you can create" is the most common wrong answer.

On preemption name the date and the consequence. "Preemptive since 1.14, cooperative before that; a loop without calls used to hang garbage collection."

On cost give both numbers and separate them. "A spawn with a wait is hundreds of nanoseconds, but that is against work of six; what costs is the parking, not the operation: Gosched adds a hundred nanoseconds while Sleep(0) adds almost nothing."

Next they ask

Next they ask

How many goroutines can you launch?

Short answer

The limit is memory: by the measurement, about 2.5 KB per sleeping goroutine, so a million is on the order of two and a half gigabytes plus whatever they refer to. There is no hard limit in the runtime.

But the question is usually about something else. In practice the limit arrives earlier and from another direction: a million goroutines each holding a connection will hit the descriptor limit; a million waiting on a database will hit its pool size. So in real code the number of goroutines is bounded by a semaphore or a worker pool — not for memory but for the resource they compete over.

Next they ask

What happens if you set GOMAXPROCS=1?

Short answer

Go code will run on one thread — but the program will not become single-threaded. Goroutines will keep switching, I/O will keep going in parallel, and system calls will still run on separate OS threads.

What changes: the parallelism of compute work disappears, and with it some races — they become rarer, not absent. Hence the important part: GOMAXPROCS=1 does not make code safe for concurrent access and does not replace synchronisation.

And a note on the boundary: exactly how the runtime manages threads at that setting is part of the implementation, not a contract of the language. What was checked in this lesson holds for go1.24.7; the answer "the scheduler is cooperative", true until Go 1.14, shows what happens to claims about the scheduler made without a version.

Next they ask

What is work stealing?

Short answer

Each P has its own local queue of runnable goroutines, and there is one global queue. A freed P takes from its own first, then from the global one, and if those are empty it steals half the queue of a random neighbour.

The point is the absence of a central dispatcher: a local queue needs no lock on every operation, and stealing is rare. Hence a consequence people sometimes ask about: a goroutine is not pinned to a P and may continue on another processor — so no assumptions about "the same thread" may be made.

Next they ask

Why can a goroutine not be killed from outside?

Short answer

Because the language has no such operation — and that is a decision, not an oversight. Forced termination would leave held mutexes held and unfinished writes unfinished: a goroutine being killed gets no chance to run its deferred calls.

Instead a goroutine is told it is time to finish — through a context or a closed channel — and leaves its own loop. The answer "with runtime.Goexit" is wrong: that ends the current goroutine, not somebody else's.

Next they ask

How do you tell there are too many goroutines?

Short answer

Count them: runtime.NumGoroutine() in your metrics. A number growing for no reason is the classic sign of a leak: goroutines blocked forever are not collected by the garbage collector.

The goroutine profile from net/http/pprof shows not only the count but where they are standing, grouped by stack. That is what answers "what leaked": usually one stack with thousands of goroutines on it.

Next they ask

A goroutine's stack grows — what happens at that moment?

Short answer

The compiler inserts a check at the start of every function: is there enough stack left? If not, the runtime allocates one twice the size, copies the old one into it and fixes up the pointers.

Two things follow. First, growth is not free, and deep recursion pays for it several times. Second, the address of a variable on the stack may change — but that is safe, because the runtime fixes every pointer, and it is exactly why you cannot obtain "a pointer into the old stack" in Go.

Common misconceptions

Claim

a goroutine is a lightweight OS thread

Actually

It is a runtime structure, not a kernel one: its own record, its own growing stack, switching inside the process. Measured over a thousand sleeping ones: 2000 bytes of stack plus about 500 of structures — against megabytes for an OS thread.

Claim

a goroutine costs 500 bytes

Actually

That is only the structures on the heap. A goroutine's stack is not in HeapAlloc at all — it is accounted separately, in StackInuse, and that is another 2000 bytes. One number without the other answers "what does a goroutine cost" wrongly.

Claim

GOMAXPROCS limits the number of goroutines

Actually

What is limited is how many run simultaneously. Measured: at GOMAXPROCS=1 four goroutines take 97.7 ms against 28.4 for one — four times, because they run in turn. There are four goroutines throughout.

Claim

there are exactly as many OS threads as Ps

Actually

There are more. A goroutine that goes into a system call blocks its M with it and hands its P to another thread — otherwise one disk read would stop all parallelism. The documentation says it outright: the GOMAXPROCS limit does not count threads blocked in system calls.

Claim

Go's scheduler is cooperative: a loop without calls hangs it

Actually

That was true before Go 1.14. Since 1.14 goroutines are preempted asynchronously, by signal, and a loop without a single call is still taken off the CPU — verified by a run at GOMAXPROCS=1. The runtime.Gosched() sprinkled through loops is unnecessary today and costs about 106 ns a call.

Claim

goroutines are slow: a spawn is 82 times dearer than a call

Actually

The number is right and the conclusion is not: 82 times is against work of six nanoseconds. What costs is the spawn and two switches; as soon as there is real work inside, the spawn's share vanishes. The same measurement with a payload gives a different picture.

Claim

channels and mutexes are slow because of the scheduler

Actually

The scheduler is involved only when a goroutine parks. An uncontended mutex is an atomic operation, a channel with a waiting receiver is a copy. The price of yielding is measured separately: runtime.Gosched() adds 106 ns, while time.Sleep(0), which does not park, adds about two.

Claim

main will wait for the goroutines it launched

Actually

It will not: the specification says execution "does not wait for the invoked function to complete", and returning from main ends the process. The goroutines die on the spot, with no deferred calls. The wait must be explicit — a WaitGroup, a channel or errgroup.

Practice

Two problems. Answer first, then check against the real output: in both, the correct answer is taken from a run of the script rather than assigned.

Practice · predict the output

How many goroutines run in an empty program, by how many does one go increase that, and how many bytes of stack and of heap does one sleeping goroutine take?
fmt.Println(runtime.NumGoroutine())

before := runtime.NumGoroutine()
go func() {
defer wg.Done()
}()
fmt.Println(runtime.NumGoroutine() - before)

fmt.Println((m2.StackInuse - m1.StackInuse) / n / 100 * 100)
fmt.Println((m2.HeapAlloc - m1.HeapAlloc) / n / 100 * 100)

Practice · estimate

One atomic increment. How many times dearer is it to run it in a new goroutine and wait for it than to call it directly?
times

Knowledge check

Question 1 of 6

GOMAXPROCS=1, four goroutines with compute work are launched. What happens?

Sources & further reading

4 SOURCES

  1. The Go specification — Go statementsOfficial documentation. The definition the main practical trap follows from: «A "go" statement starts the execution of a function call as an independent concurrent thread of control, or goroutine, within the same address space». And then: «The function value and parameters are evaluated as usual in the calling goroutine, but unlike with a regular call, program execution does not wait for the invoked function to complete». The words «does not wait» are why goroutines launched from main die together with it.https://go.dev/ref/spec#Go_statements
  2. The runtime package — GOMAXPROCSOfficial documentation. The precise wording of what is limited: «GOMAXPROCS sets the maximum number of CPUs that can be executing simultaneously and returns the previous setting». And the caveat people forget: «This call will go away when the scheduler improves». Separately, on the limit not being about the number of goroutines: «The GOMAXPROCS limit does not count threads blocked in system calls».https://pkg.go.dev/runtime#GOMAXPROCS
  3. Go 1.14 release notes — asynchronous preemptionOfficial documentation. The change that split the scheduler into a before and an after: «goroutines are now asynchronously preemptible. As a result, loops without function calls no longer potentially deadlock the scheduler or significantly delay garbage collection». Before that, a loop without calls at GOMAXPROCS=1 hung the whole program.https://go.dev/doc/go1.14
  4. runtime/proc.go and runtime/runtime2.go — how the scheduler is builtGo source code. The three letters are defined in the scheduler's own comment: «G - goroutine. M - worker thread, or machine. P - processor, a resource that is required to execute Go code. M must have an associated P to execute Go code». And the starting stack: `_StackMin = 2048` in `runtime/stack.go` — «minimum size of stack for new goroutines».https://go.dev/src/runtime/proc.go