System Calls and the Scheduler
A goroutine that makes a blocking operating-system call presents the scheduler with a problem: the underlying OS thread (the M) is stuck inside the kernel and cannot run Go code, yet a P — the scarce permit that grants the right to run Go code — must not be wasted sitting idle behind it. Go’s solution is the pair
entersyscall/exitsyscall: before a syscall the runtime moves the goroutine to the_Gsyscallstate while leaving the P loosely attached to the M, so that if the syscall blocks for long, the background monitorsysmoncan take the P away and hand it off to another thread, keepingGOMAXPROCS-worth of parallelism alive. On return, the goroutine tries to re-acquire a P; if none is free it parks until one is. This is why a Go program with 8 Ps can have thousands of goroutines blocked inread()and still keep all 8 cores busy (src/runtime/proc.go).One subtlety worth stating up front, because older write-ups (and older Go releases) get it wrong: in current Go (verified against Go 1.26.3) the P does not change to a special “syscall” status while the M is in the kernel. The P remains
_Prunning; the goroutine’s_Gsyscallstate is the sole marker that the P is “in a syscall.” The runtime’sruntime2.gois explicit — the former_Psyscallconstant is now named_Psyscall_unusedand documented as “a now-defunct state … A P is identified as ‘in a system call’ by looking at the goroutine’s state” (runtime2.go).
Mental Model
Recall the G-M-P model (GMP Scheduler Model): a G is a goroutine, an M is an OS thread, a P is a “processor” — one of exactly GOMAXPROCS permits, and an M needs a P to execute Go code. The HACKING.md comment is precise: an M “can have an associated P to execute Go code, however it can be blocked or in a syscall w/o an associated P.” A syscall is exactly the case where the M leaves Go code. The runtime therefore distinguishes two situations and treats them differently:
flowchart TD G["Goroutine calls a syscall"] --> E["entersyscall:<br/>G -> _Gsyscall<br/>P stays _Prunning, M keeps it (loosely)<br/>save m.syscalltick = p.syscalltick"] E --> SC["M blocks in the kernel"] SC --> FAST{"Syscall returns fast<br/>(before sysmon notices)?"} FAST -->|yes| EX1["exitsyscall fast path:<br/>M still holds its P (m.p != nil)<br/>-> reacquire same P, keep running"] FAST -->|"no — sysmon's retake()<br/>sees G in _Gsyscall too long"| RT["sysmon retake:<br/>pin thread, thread.takeP(), P released<br/>handoffp -> start/wake another M"] RT --> EX2["exitsyscall slow path:<br/>M's P was taken (m.p == nil)<br/>-> try to grab an idle P (prefer oldp)<br/>-> else G -> _Grunnable, M parks"] G2["Known-blocking syscall<br/>(entersyscallblock)"] --> HB["Immediately handoffp(releasep())<br/>before the _Gsyscall switch"]
Figure: the two ways a P is freed during a syscall. The insight: for a fast syscall the runtime gambles that it will return before sysmon intervenes, so it does the cheap thing (keep the P loosely attached, P still _Prunning); for a known-blocking syscall it skips the gamble and hands the P off up front. sysmon is the safety net that retakes the P when the gamble loses. Note that nowhere does the P enter a distinct “syscall” status — the goroutine’s _Gsyscall state and the syscalltick counter are what the runtime keys off of.
Why This Is Hard
If Go simply let an M block in the kernel while holding a P, then GOMAXPROCS blocking syscalls would consume all the Ps and the program would have zero parallelism even on a many-core machine — every other goroutine would starve. The naive fix, “spawn a fresh M for every syscall,” thrashes: thread creation is expensive and you would end up with thousands of threads. Go’s design threads the needle: it keeps the common, fast syscall cheap (no thread creation, no P handoff in the typical case) while guaranteeing that a slow syscall cannot strand a P. The two halves are entersyscall/exitsyscall (run by the goroutine) and retake (run by sysmon, the background monitor).
Mechanical Walk-through
entersyscall — handing the M into the kernel
When Go’s syscall package (or a cgo call) is about to invoke a real OS call, it calls entersyscall, which delegates to reentersyscall (proc.go). The function is marked //go:nosplit and its comment states the binding constraint: “Entersyscall cannot split the stack: the save must make g->sched refer to the caller’s stack segment, because entersyscall is going to return immediately after.” Nothing it calls may grow the stack either, “because we do not know which of the uintptr arguments are really pointers (back into the stack)” — a moving stack (Goroutine Stacks) during an active syscall would silently corrupt pointer arguments the kernel still holds. To enforce this, reentersyscall sets gp.throwsplit = true and poisons gp.stackguard0 = stackPreempt so any accidental stack-growth attempt crashes loudly.
reentersyscall then:
- Increments
gp.m.locksto disable preemption — the comment says “during this function g is in Gsyscall status, but can have inconsistentg->sched, do not let GC observe it.” - Records
gp.m.syscalltick = gp.m.p.ptr().syscalltick— copying the P’s tick counter so that, on the way out, a mismatch reveals the P was stolen (“Copy the syscalltick over so we can identify if the P got stolen later”). - Saves the P into
gp.m.oldpsoexitsyscallcan prefer to re-acquire the same P for cache locality. - Saves
gp.syscallsp,gp.syscallpc,gp.syscallbpso a traceback or the GC can still walk the stack of a goroutine sitting in a syscall, and double-checks they fall within the stack bounds. - If a stop-the-world is already pending (
sched.gcwaiting), it short-circuits to the equivalent ofentersyscallblockviaentersyscallHandleGCWait, giving away the P immediately rather than making the STW wait on this syscall. - Transitions the goroutine: a fast atomic CAS
_Grunning → _Gsyscall, falling back tocasgstatusonly if that CAS fails or the goroutine is inside a synctest “bubble.” The comment marks the danger: “As soon as we switch to_Gsyscall, we are in danger of losing our P. We must not touch it after this point.” - Finally, if
sysmonis in deep sleep (sched.sysmonwait), it wakes it viaentersyscallWakeSysmonso the monitor resumes its retake duties promptly, then dropsgp.m.locks.
Crucially, in the fast path entersyscall does not release the P. The M and P stay loosely associated, and — verified against Go 1.26.3 — the P’s status stays _Prunning; only the goroutine moves to _Gsyscall. This is the gamble: most syscalls (a non-blocking read on ready data, a clock_gettime) return in microseconds, and for those the cheapest thing is to do nothing — keep the P, and reclaim it instantly on return.
exitsyscall — the goroutine comes back
On return, exitsyscall (proc.go) tries to resume cheaply:
- Fast path. It optimistically transitions
_Gsyscall -> _Grunning(again a fast CAS, falling back tocasgstatus), clearsgp.m.oldp, then checks the single decisive condition: does the M still hold a P, i.e. isgp.m.p.ptr() != nil? If yes, the P was never taken — the goroutine just keeps running on the same P, incrementspp.syscalltick, restores its real stack guard, clearsthrowsplit, and returns. No locks, no scheduler entry. This is the overwhelmingly common case. (The savedm.syscalltickvspp.syscalltickcomparison still appears here, but in Go 1.26 it only chooses which trace events to emit — modelling a “lost P” for the tracer whendropmtrashed the tick — not whether to take the fast path.) - Slow path. If the M’s P is gone (
gp.m.p.ptr() == nil—sysmontook it),exitsyscallcallsexitsyscallTryGetP(oldp)on the system stack to grab an idle P, preferring the goroutine’s originaloldpfor cache locality. If it gets one, it installs it (acquirepNoTrace) and resumes. If no P is free, the goroutine cannot run: it falls through toexitsyscallNoP, transitions to_Grunnable, is placed on a run queue, and the M parks. The goroutine will be picked up later by whatever M next finds it.
entersyscallblock — skipping the gamble
Some syscalls are known to block — a blocking accept, a read on an empty pipe with no deadline, anything the runtime calls deliberately to wait. For these the runtime calls entersyscallblock instead. The difference is decisive: entersyscallblock hands the P off immediately, before the goroutine actually enters _Gsyscall. It bumps gp.m.p.ptr().syscalltick++ itself (so a concurrent sysmon already sees a fresh tick), marks the M as giving up its P with addGSyscallNoP, and its comment lays out the binding ordering — “the order here must be (1) trace, (2) handoff, (3) _Gsyscall switch” — running handoffp(releasep()) on the system stack. By releasing the P up front, entersyscallblock ensures a known-slow syscall never even briefly strands a P; the cost (a handoffp call, possibly starting an M) is paid eagerly because it would have to be paid anyway.
handoffp — giving the P to someone else
handoffp (proc.go) decides what to do with a P that has been freed from a syscall. Its comment states the invariant tying it to work-stealing: “handoffp must start an M in any situation where findRunnable would return a G to run on pp.” The logic, in order: if the P has local run-queue work or global-queue work (!runqempty(pp) || !sched.runq.empty()), or trace work, or GC work, immediately startm(pp, false, false) — wake or create an M to use the P. If there is no work but no spinning/idle M exists to discover future work, start a spinning M anyway (startm(pp, true, false)). If the GC wants the world stopped, park the P in _Pgcstop. There is one more wrinkle: if this is the last running P and nobody is polling the network, it starts an M to keep the poller alive. Otherwise the P genuinely has nothing to do: it goes onto the idle-P list (pidleput), and if it owns a soon-to-fire timer, wakeNetPoller is nudged. The point is that a freed P is never silently lost — it is always either matched to work or explicitly idled.
sysmon and retake — the safety net
The fast path’s gamble loses when a syscall the runtime thought was fast turns out to block for milliseconds. The recovery is sysmon, the runtime’s P-less background monitor thread (see Sysmon System Monitor), which periodically calls retake(now) (proc.go).
retake iterates allp, but immediately skips any P that is not _Prunning — if pp == nil || atomic.Load(&pp.status) != _Prunning { continue }. This is the crux of the modern model: a P with a goroutine in a syscall is still _Prunning (it is owned by the syscalling M), so retake does not filter on a separate syscall status — there is no _Psyscall to filter on. For each running P, retake does two things off a single pass.
Time-slice preemption. Each P carries a sysmontick recording its schedtick. If schedtick is unchanged since the last observation, the same goroutine (or a runnext chain sharing one slice) has run uninterrupted; once that exceeds forcePreemptNS — const forcePreemptNS = 10 * 1000 * 1000, i.e. 10 ms (verified in Go 1.26.3 proc.go) — retake calls preemptone(pp) (see Goroutine Preemption). It also sets a local sysretake = true flag, because as the comment notes, “If pp is in a syscall, preemptone doesn’t work … so we need to take the P ourselves.”
Stealing a P stuck in a syscall. After the preemption check, retake drops allpLock, calls incidlelocked(-1) (to keep the deadlock detector honest while it borrows the P), and then attempts thread, ok := setBlockOnExitSyscall(pp). This is the modern mechanism, confirmed in Go 1.26.3: setBlockOnExitSyscall performs intentionally racy reads, and on success acquires the goroutine’s scan bit, which pins the syscalling thread so it cannot advance out of exitsyscall while the P is being inspected. If the P is not in a syscall (!ok), retake jumps to done and moves on. If it is, retake reads pp.syscalltick: if the tick has advanced since the last observation (!sysretake && int64(pd.syscalltick) != syst), this is a different, fresh syscall — record it, call thread.resume(), and wait at least one more sysmon cycle before acting. This is what the comment means by “Retake the P if it’s there for more than 1 sysmon tick (at least 20us)”: a P must be seen in the same syscall across two consecutive observations before it is stolen, and since sysmon’s minimum poll is 20 µs, the floor on the delay is ~20 µs.
If the same syscall has now persisted across observations, retake applies one restraint: if runqempty(pp) && sched.nmspinning.Load()+sched.npidle.Load() > 0 && pd.syscallwhen+10*1000*1000 > now { thread.resume(); goto done } — i.e. if the P has no local work, there is already a spinning or idle P to absorb future work, and the syscall is younger than 10 ms, leave it alone to avoid pointless thread churn. Otherwise it commits the steal: thread.takeP() detaches the P from the blocked thread, thread.resume() lets the thread continue (it will discover in exitsyscall that gp.m.p is now nil and take the slow path), and handoffp(pp) gives the freed P to a fresh or woken M. The eventual-retake clause exists because a P held in a long syscall keeps npidle != gomaxprocs, which would otherwise prevent sysmon from entering deep sleep.
Code: How a Blocking Read Looks
package main
import (
"fmt"
"os"
)
func main() {
buf := make([]byte, 64)
// os.Stdin.Read -> internal/poll.FD.Read -> syscall.Read.
// For a regular file the syscall package wraps the call in
// entersyscall()/exitsyscall(): the M may keep its P (fast path) or,
// if the read blocks long enough, sysmon hands the P to another M.
n, err := os.Stdin.Read(buf)
fmt.Printf("read %d bytes, err=%v\n", n, err)
}For a regular file (or a device that the poller cannot handle), Read ends in a true blocking syscall wrapped by entersyscall/exitsyscall exactly as described above — and if the read stalls, sysmon retakes the P. For a socket or pipe, the path is different: internal/poll first attempts a non-blocking syscall, and if it would block, the goroutine parks on the Network Poller instead of holding an M in a blocking syscall at all. That distinction — file syscalls go through entersyscall, network I/O goes through the poller — is the single most important thing to internalise about Go’s I/O model.
# Watch P/M/syscall transitions:
GODEBUG=schedtrace=1000,scheddetail=1 ./prog
# scheddetail prints each P's numeric status and syscalltick, and each M/G state.
# A goroutine in a syscall shows G status _Gsyscall (3) while its P stays
# _Prunning (1) -- the P is NOT in a distinct syscall status. A handoffp shows
# up as an extra M appearing to run the freed P, plus a bump in the P's
# syscalltick when the original M finally returns through exitsyscall.Failure Modes and Common Misunderstandings
Thread explosion from many blocking syscalls. If a program has thousands of goroutines all making genuinely blocking file syscalls (not socket I/O — see below), the runtime must back each blocked M with a thread, because the P gets handed off and a new M is started to use it. The result is thousands of OS threads. runtime/debug.SetMaxThreads caps this with a hard crash. The real fix is to bound concurrency (a worker pool, context cancellation) or to use file I/O sparingly. This is the most common production surprise: “my Go server spawned 5,000 threads” almost always means many concurrent blocking file/DNS/cgo calls.
“All I/O goes through the poller.” False. Only file descriptors the OS poller can watch — sockets, pipes, some character devices — use the Network Poller and park the goroutine without an M. Regular file reads/writes on Linux are not pollable in the classic epoll sense and go through blocking entersyscall. So heavy file I/O does cause M growth, while heavy socket I/O does not.
“cgo calls are syscalls.” Mechanically similar but distinct. A cgo call also goes through entersyscall-like machinery (the comment in reentersyscall notes cgo callbacks use it) because, like a syscall, it leaves Go’s control. A long-running C function therefore also triggers retake and P handoff — which is why a cgo call into slow C code costs an M and is a classic source of thread growth. See cgo Performance and Pitfalls.
“A syscall preempts my goroutine.” A blocking syscall is itself a blocked safe-point — the goroutine is descheduled and the GC can scan it freely. But the goroutine inside the syscall cannot respond to a SIGURG async-preemption request, because it is not running Go code. That is exactly why retake exists for syscalls: as the retake comment says, “preemptone doesn’t work” on a P in a syscall, so sysmon must take the P itself.
Alternatives and Design Trade-offs
The competing designs: one M per syscall, always (simple, but thread-thrashing); all syscalls non-blocking + an event loop (Node.js, Rust async — no thread growth, but every blocking primitive must be re-implemented non-blockingly, and CPU-bound work still needs a thread pool); block the worker and accept reduced parallelism (early cooperative-threading libraries). Go’s hybrid — don’t release the P for fast syscalls, do release it for known-blocking ones, and let sysmon retake it for the ones that fooled you — keeps the fast path genuinely free while bounding the worst case. The price is that file/cgo blocking really does spawn threads; the benefit is that programmers write ordinary blocking-style code and the runtime makes it scale. For network I/O specifically, Go does use the event-loop approach via the Network Poller, getting the best of both: blocking-style source code, event-loop-style scalability.
Production Notes
The metrics that matter: runtime.NumGoroutine() and the OS thread count (visible as /proc/<pid>/status Threads: on Linux, or runtime/pprof’s threadcreate profile). A thread count far above GOMAXPROCS signals many goroutines in concurrent blocking syscalls or cgo calls — investigate with the threadcreate profile and the execution tracer’s syscall events. GODEBUG=schedtrace=…,scheddetail=1 shows Ps in syscall state and is the direct way to confirm syscall-induced handoffs. The Go 1.14 async-preemption change (Go 1.14 release notes) also made syscalls more likely to fail with EINTR because SIGURG can interrupt them — robust syscall code must retry on EINTR. The standard advice: prefer socket/pipe I/O (poller-backed, no thread growth) over file I/O on hot paths; bound concurrency around any blocking-file or cgo work; and treat a runaway thread count as a syscall-concurrency problem, not a goroutine-count problem.
See Also
- GMP Scheduler Model — the G-M-P abstraction
entersyscallmanipulates - Network Poller — the other I/O path; socket I/O parks here instead of blocking an M
- Work Stealing Scheduler —
handoffp’s “start an M if findRunnable would return a G” invariant - Sysmon System Monitor — runs
retake, the safety net that reclaims stranded Ps - Goroutine Preemption —
retakealso handles 10 ms time-slice preemption; syscalls can’t beSIGURG-preempted - Goroutine Stacks — why
entersyscallis//go:nosplit: the stack must not move during a syscall - Goroutine Lifecycle and States —
_Gsyscalland the other goroutine statuses - cgo Performance and Pitfalls — cgo calls use the same machinery and cause the same thread growth
- Go Internals MOC — parent map of content