The first time I used goroutines, the code looked almost too simple:
go handle(connection)
That line does not create an operating-system thread for every call. It creates a goroutine and asks the Go runtime to make it runnable. The runtime then multiplexes many goroutines over a smaller set of OS threads.
I wanted a mental model that could answer more than “goroutines are lightweight.” In particular:
- Where does a newly created goroutine wait?
- What actually runs it?
- Why does a blocked network call not waste a thread?
- What changes when a goroutine enters a blocking system call?
- How does an idle CPU find work?
- How is a CPU-bound goroutine stopped from running forever?
The useful starting point is the G–M–P model. The complete picture also needs run queues, work stealing, the network poller, timers, preemption and the system-monitor goroutine.
Two schedulers are involved
A Go program runs under two schedulers:
Go runtime scheduler maps goroutines onto OS threads ↓Operating-system scheduler maps OS threads onto CPU cores
The Go runtime understands goroutines, channels, timers and Go-aware network operations. The operating system understands threads and processors. The runtime cannot place instructions directly on a CPU; it can only arrange which goroutine an OS thread should execute.
This distinction explains why concurrency and parallelism are different:
- Concurrency means several tasks can make progress over the same period.
- Parallelism means instructions from several tasks execute at the same instant on different processors.
A program can have thousands of concurrent goroutines with GOMAXPROCS=1. Only one of them executes Go code at a time, but another can run whenever the current one blocks or is preempted.
The G–M–P model
The runtime connects three kinds of object:
G: goroutineM: machine, represented by an OS threadP: processor, the resources required to execute Go code
The scheduler's job is to match one runnable G with an M that owns a P.
local runnable Gs │ ▼ [P] ─── attached to ─── [M / OS thread] │ ▼ executes one G
G: a goroutine
A G is the runtime representation of a goroutine. It contains enough state for the runtime to suspend execution and resume it later, including its stack, stack bounds, saved program counter and scheduling status.
A goroutine moves through states such as:
new → runnable → running → waiting ▲ │ │ └──────────┴─────────┘ eventually → dead/reusable
The exact internal status names are runtime details. The durable model is:
- Runnable: ready to execute but waiting for CPU time.
- Running: currently executing on an M with a P.
- Waiting: parked until a channel, lock, timer, I/O event or another condition makes it runnable.
- In a syscall: blocked or executing outside normal Go scheduling.
Goroutine stacks start small and grow when needed. This is one reason creating a goroutine is much cheaper than reserving a large fixed thread stack. Cheap creation does not make an unbounded goroutine count safe: every goroutine still needs stack space, bookkeeping and any resources captured by its closure.
M: an OS thread
M stands for machine. An M is backed by an operating-system thread. It can execute:
- User Go code.
- Runtime code.
- A system call.
- cgo code.
- Nothing while parked.
There can be more Ms than Ps. A program with GOMAXPROCS=4 may have more than four OS threads because some threads can be blocked in system calls or reserved for runtime work.
An M also has a special system goroutine called g0. Runtime operations such as parts of scheduling and stack management run on the system stack rather than an ordinary goroutine stack.
P: permission and per-processor runtime state
P stands for processor, but it is not a physical CPU. It represents the permission and runtime resources needed to execute user Go code.
A P owns or points to resources that are useful to keep local, including:
- A local runnable queue.
- Per-P allocation caches.
- Timer information.
- Scheduler and garbage-collector state.
Sharding this state across Ps avoids making every fast-path operation contend on one global lock.
There are exactly GOMAXPROCS Ps. An M must acquire a P before it can execute user Go code. An M can remain blocked in a system call without a P.
What GOMAXPROCS controls
GOMAXPROCS controls the maximum parallelism available for executing Go code. If it is 8, at most eight Ms can hold Ps and execute goroutines simultaneously.
It does not mean:
- The program has only eight goroutines.
- The runtime creates exactly eight OS threads.
- Every workload becomes faster when it is increased.
- Blocking syscalls count against the same eight-thread total.
Since Go 1.25, the default is container-aware on Linux. When the process has a cgroup CPU bandwidth limit below the available logical CPU count, the runtime can use that limit to choose the default. It can also periodically adjust the default when CPU availability changes. Explicitly setting GOMAXPROCS disables parts of that automatic behavior.
This matters in Kubernetes. A process may see a large host CPU count while its container is allowed only a small CPU quota. An excessively high GOMAXPROCS can cause the container to consume its quota early and be throttled, which often hurts tail latency.
GOMAXPROCS is a capacity setting, not a tuning knob that should be changed without measuring. CPU-bound work normally benefits until useful parallelism matches the CPU actually available. Lock-heavy or memory-bandwidth-bound work may stop scaling earlier.
Creating a goroutine
When code executes:
go process(item)
The compiler turns the go statement into a runtime call that creates a new G. Conceptually, the runtime:
- Obtains or allocates a G structure.
- Creates its initial stack and starting frame.
- Records the function and arguments to execute.
- Marks the G runnable.
- Places it on a runnable queue.
- Wakes or creates scheduler capacity when necessary.
The new goroutine does not necessarily run immediately. Usually it enters the current P's local queue. It may run on the same P later, be moved through the global queue or be stolen by another P.
The language does not promise that the new goroutine starts before the creating goroutine executes its next line. Code that needs ordering must establish it with a channel, lock, WaitGroup or another synchronization operation.
Local run queues
Each P has a local queue of runnable goroutines. This is the normal fast path.
P0 local queue: [G7, G8, G9]P1 local queue: [G2]P2 local queue: [G4, G5]P3 local queue: []
Local queues reduce coordination:
- The P can enqueue and dequeue most work without a global scheduler lock.
- Recently created work stays near the P that created it.
- Per-P state improves cache locality.
The current runtime uses a bounded local queue and a special runnext slot. A goroutine in runnext can run before the ordinary queue and inherit the remainder of the current scheduling time slice. This helps handoff patterns such as waking a goroutine that is likely to continue related work.
The scheduler prevents repeated runnext handoffs from starving every other goroutine. This is one reason the system monitor and preemption still matter even when work stays local.
If a local queue is full, the runtime moves a batch of runnable work toward the global queue instead of allowing the local queue to grow without bound.
The global run queue
The runtime also has a global queue. It handles runnable work that is not tied to one P and provides a path for redistributing work.
global runnable queue [G20, G21, G22] │ ┌────────────┼────────────┐ ▼ ▼ ▼ P0 P1 P2
The global queue is not checked only after every local queue becomes empty. The scheduler samples it periodically so a P with a busy local stream does not ignore global work indefinitely.
A P can take a batch rather than one G at a time. Batching amortizes the cost of the global scheduler lock and gives the P local work for subsequent scheduling decisions.
The exact queue sizes and checking intervals are implementation details. Application correctness must not depend on them.
One scheduling round
At a high level, a scheduling round looks like this:
current G stops running │ ▼runtime schedule() │ ▼findRunnable() │ ├── local runnable work ├── global runnable work ├── ready network operations ├── expired timers ├── garbage-collector work └── work stolen from another P │ ▼execute the chosen G
This is a mental model rather than a strict search order. The real findRunnable path considers scheduler state, fairness, tracing, GC work, timers, netpolling and whether the M should spin or park.
If no work is found, the runtime must decide whether to keep looking, wait in the network poller or park the M. Waking too many Ms wastes CPU through spinning and contention. Parking too aggressively adds latency when new work appears. Much of the scheduler's complexity is managing this balance.
Work stealing
Suppose P3 has no runnable goroutines while P0 has many:
P0: [G1 G2 G3 G4 G5 G6 G7 G8]P1: [G9]P2: [G10 G11]P3: []
P3 should not remain idle. It chooses other Ps as victims and attempts to steal runnable work. The current runtime generally steals a batch so the thief receives enough work to stay busy while the victim keeps some work.
before: P0 [G1 G2 G3 G4 G5 G6 G7 G8] P3 []after: P0 [G1 G2 G3 G4] P3 [G5 G6 G7 G8]
Victim selection is varied to avoid every idle P contending for the same queue. Stealing may also consider timer work. A P does a bounded amount of searching rather than spinning forever.
Work stealing handles uneven creation naturally. A server goroutine running on P0 may create hundreds of child goroutines. They start locally, then idle Ps pull work away as they become available.
Stealing improves utilization, but it is not free. Moving work can reduce cache locality. Very small tasks can spend a meaningful share of their time in creation and scheduling. For tiny CPU operations, processing a batch in one goroutine can be faster than creating a goroutine per item.
What happens when a goroutine blocks?
“Blocked” can describe several very different paths.
Channel, mutex and runtime-aware waiting
When a goroutine cannot continue on a channel operation, mutex, WaitGroup or condition, the runtime parks the G.
G1 runs on M1 + P1G1 waits on a channel ↓G1 becomes waiting ↓M1 + P1 select another runnable G
The OS thread does not need to block with the goroutine. The M normally continues using the same P to execute other Go work.
When another goroutine sends the value, unlocks the mutex or completes the wait condition, the runtime marks the waiting G runnable and places it back into scheduling circulation.
Timers and sleep
time.Sleep, timer channels and deadlines do not require an OS thread to sleep for each goroutine. The runtime records timer deadlines in per-P timer structures. A sleeping G remains waiting until its timer expires, then it becomes runnable.
Timers integrate with the scheduler's decision about how long the network poller may sleep. The runtime needs to wake for whichever event comes first: network readiness, a timer or newly submitted work.
Network I/O and the netpoller
Go's network stack integrates supported file descriptors with the runtime netpoller. Platform implementations use facilities such as epoll, kqueue or Windows I/O completion mechanisms.
G calls Read on a socket │ ▼data is not ready │ ▼runtime registers interest and parks G │ ▼M + P run other goroutines │ ▼OS reports the socket ready │ ▼netpoller makes G runnable
This lets Go expose a blocking Read API while using readiness or completion notification underneath. The code remains synchronous to the caller, but the thread can perform other work while the socket is idle.
Not every file operation is pollable on every platform. Regular file I/O and calls hidden inside external libraries may still block an OS thread.
Blocking system calls
A syscall is different because the OS thread may enter the kernel and remain there.
G1 + M1 + P1 enter syscall │ ▼M1 is blocked in the kernel │ ▼P1 can be detached and assigned to another M │ ▼other Gs continue on M2 + P1
The runtime marks the goroutine as being in a syscall. If the call returns quickly, the original M may reacquire its P. If it stays blocked, the runtime can retake the P and use another M to keep Go work moving.
When the syscall finally returns, its M needs a P before it can resume user Go code. It may acquire an idle P or make the G runnable for another M.
This is why GOMAXPROCS limits threads executing Go code, not the total number of OS threads. A program with many simultaneously blocked syscalls or cgo calls can create many Ms and put pressure on OS thread limits.
The system monitor
The runtime starts a monitor commonly called sysmon. It operates without a normal P and helps with work that cannot depend on an actively scheduled goroutine.
Its responsibilities include helping the runtime:
- Retake Ps held by long-running syscalls.
- Request preemption of long-running goroutines.
- Drive network polling under some conditions.
- Observe timers and runtime housekeeping.
- Trigger forced garbage collection after a long idle period.
sysmon is part of the reason a goroutine that enters a syscall does not permanently strand its P.
Preemption and fairness
A goroutine doing CPU work must eventually let other goroutines run. Earlier Go versions relied more heavily on cooperative safe points such as function calls. A tight loop with no calls could delay scheduling and garbage collection.
Since Go 1.14, goroutines are asynchronously preemptible on supported platforms. The runtime can request that a long-running goroutine stop at an asynchronous safe point. On Unix-like systems, the implementation can use signals to interrupt the thread and arrange preemption safely.
Preemption helps with:
- Scheduler fairness.
- Garbage-collection stop-the-world phases.
- Latency when one goroutine performs long CPU work.
It does not turn Go into a real-time scheduler. The language does not guarantee an exact time slice, execution order or wake-up latency. A goroutine can still experience delays from OS scheduling, cgo, non-preemptible runtime sections, CPU throttling, garbage collection or contention.
Correct code must never use time.Sleep as a substitute for synchronization:
go update()time.Sleep(time.Millisecond) // Does not prove update has completed.readResult()
Use a channel, WaitGroup, mutex or another happens-before relationship.
Scheduler interaction with garbage collection
The scheduler and garbage collector share the Ps. During concurrent marking, the runtime schedules GC worker goroutines alongside application goroutines. Application allocation can also perform GC assist work when it outpaces the collector.
Stop-the-world phases require the runtime to bring goroutines to safe points. Asynchronous preemption reduced the risk that a tight user loop would delay this process significantly.
Per-P allocation caches are another reason P exists as more than an abstract scheduling token. They let common allocations avoid one global allocator lock. When an M loses its P, it also loses access to that P's local runtime resources until it acquires a P again.
Following one HTTP request
Consider a server handler that reads a request, queries a database and writes a response:
func handler(w http.ResponseWriter, r *http.Request) { account, err := database.Load(r.Context(), accountID(r)) if err != nil { http.Error(w, "load failed", http.StatusInternalServerError) return } fmt.Fprintln(w, account.Name)}
One possible execution is:
1. netpoller reports an accepted connection ready2. server connection goroutine becomes runnable3. a P selects it and an M executes it4. handler sends the database request5. goroutine waits for network input6. runtime parks it and runs another G on the same P7. database socket becomes readable8. netpoller marks the handler goroutine runnable9. any suitable M + P may resume it10. handler formats and writes the response
The goroutine can resume on a different OS thread. Goroutine-local logic must not assume thread affinity. Code that truly needs a stable OS thread, such as some GUI or foreign-library integrations, can use runtime.LockOSThread, but that should be a deliberate boundary.
Seeing the scheduler instead of guessing
The runtime provides several ways to inspect behavior.
Scheduler trace
Run a program with:
GODEBUG=schedtrace=1000,scheddetail=1 ./server
The summary includes information such as:
gomaxprocsidle processorsOS thread countspinning and idle threadsglobal run queue lengthper-P local queue lengths
A growing run queue means runnable work is waiting for CPU time. Many idle Ps with expected CPU work may mean the application is blocked, serialized by a lock or simply lacks enough runnable work.
Execution trace
The execution tracer records goroutine scheduling, blocking, syscalls, network waits and garbage-collection events:
file, err := os.Create("trace.out")if err != nil { log.Fatal(err)}defer file.Close() if err := trace.Start(file); err != nil { log.Fatal(err)}defer trace.Stop() runWorkload()
Open the result with:
go tool trace trace.out
Use tracing to answer scheduling and latency questions. Use CPU profiling to find where CPU time is spent. Use block and mutex profiles to find synchronization delays. A trace can show that work is serialized; a CPU profile is usually better for locating an expensive function.
Practical consequences for application design
Goroutines are cheap, not free
Creating one goroutine per independent request is often reasonable. Creating one goroutine for every item in an unbounded input stream is not. Use bounded worker pools, semaphores or backpressure when the input rate can exceed processing capacity.
Blocking is normal when the runtime understands it
Channels, mutexes, timers and pollable network I/O park the goroutine cleanly. A large amount of blocking cgo or file I/O can create many OS threads, so measure thread count as well as goroutine count.
GOMAXPROCS does not repair contention
If all goroutines need one mutex, more Ps may add contention without increasing throughput. If work is mostly waiting for network I/O, thousands of goroutines can still be useful even with a small GOMAXPROCS.
Scheduling order is deliberately unspecified
Do not depend on which ready goroutine wins, whether a new goroutine starts immediately or which P runs it. Test concurrency with actual synchronization and use the race detector:
go test -race ./...
A goroutine leak is a resource leak
A goroutine waiting forever can retain its stack, referenced objects, timers, sockets or channel relationships. Pass cancellation through context.Context, close owned channels correctly and make every long-lived goroutine's exit condition clear.
The compact mental model
G is the work and saved execution state.M is the OS thread that executes instructions.P is the right and local runtime state needed to execute Go code. Runnable Gs wait mainly in per-P queues, with a global queue as another source.An idle P steals work instead of leaving a CPU unused.Runtime-aware waits park G while M + P run something else.Network readiness returns through the netpoller.A blocking syscall may keep M but release P.Preemption stops CPU-heavy Gs from monopolizing execution.GOMAXPROCS limits parallel Go execution, not goroutine or thread count.
The scheduler makes blocking-looking Go code scale across many concurrent operations. Its implementation is sophisticated so application code can stay simple. The important contract is synchronization from the Go memory model, not a particular queue order or time slice.