Reading contract: you do not need to memorize G, M, and P. Begin by treating a goroutine as a task card and a thread as a worker who can execute one card. The first half explains why 1,000 goroutines do not mean 1,000 threads, and why “waiting” differs from “ready but not yet scheduled.”

The first half describes the observable scheduling story; the second maps it to Go 1.26.0, primarily runtime/proc.go and runtime/runtime2.go. Conceptual boundaries come from the official runtime HACKING guide; production observation uses runtime/metrics and Diagnostics. This is the current gc runtime, not a required mapping for every Go implementation.

1. Eight Goroutines Are Not Eight Threads

fetchd starts at most eight worker goroutines for eight URLs. Think of them as eight task cards: one may be computing, another waiting for the network, and another ready for CPU time. The scheduler hands cards that can run to a limited number of threads. Eight cards are not eight workers, and they do not guarantee eight simultaneous operations.

Life of the “fetch go.dev” task card:
created → runnable → running → waiting for the network
        → runnable again after readiness → running again → complete

Network readiness does not teleport the task back into its function. It only makes the task runnable again; an executor must still select it. Runnable, running, and waiting below are names for points on this timeline.

Runtime source names the three roles G, M, and P. One sentence is enough to begin: G is the task, M is the operating-system thread, and P supplies the permission and local resources needed to execute Go code. Running code requires the runtime to bring one runnable G, one M, and one P together. The table makes that picture more precise:

SymbolRuntime objectOwnsCount relationship
Gruntime.gGoroutine stack, saved scheduler context, state, ancestryMay far exceed threads and Ps
Mruntime.mOne OS thread, g0, current G, attached PSyscalls/cgo may make M count exceed P count
Pruntime.pResources needed to run Go, local queue, allocator cache, timersExactly GOMAXPROCS

The important relationship is that these roles are not permanently bound. An M can run different Gs, and a G can resume on another M. If an M blocks in a syscall, it can release its P so another M continues Go work. That is the practical value behind “a goroutine is not a thread.”

GOMAXPROCS limits parallel Go execution

Go 1.26 has one P per current GOMAXPROCS. With 1,000 runnable Gs and GOMAXPROCS=8, at most roughly eight threads execute user Go code at once, while the remaining Gs wait in scheduler queues. Total threads may be higher because of syscalls, cgo, runtime work, and locked threads. Since Go 1.25 the default can respond to container CPU limits; explicit environment or API settings affect that automation. Diagnose the observed metric, not only host core count.

2. State Is More Informative Than “Goroutine Count”

runtime2.go separates _Grunnable, _Grunning, _Gwaiting, _Gsyscall, and other states. Equal goroutine counts can describe very different production conditions.

StateMeaningTypical sourceLatency points toward
_GrunnableQueued and able to execute, but not executingCreation, channel wake, I/O readiness, timerCPU supply and scheduling competition
_GrunningMatched with M/P and executing GoexecuteCPU or the running code path
_GwaitingParked at a runtime wait pointChannel, mutex, timer, netpollA resource, not CPU access
_GsyscallInside a system callBlocking syscall/cgo boundaryKernel or foreign code

“Many goroutines” alone is not a failure. A server may intentionally hold a large population waiting on connections; a much smaller population continuously runnable can signal CPU quota, excessive fan-out, a wake-up storm, or GC competition. Start with states and wait reasons, then interpret the total.

3. How go h.fetch Becomes a Runnable G

The compiler lowers a go statement to runtime.newproc. Its source comment is unusually direct: create a new G running fn and put it on the queue of Gs waiting to run.

Before reading fields, compress the source into five actions: prepare one goroutine record; remember the function where it will begin; mark it runnable; place it in a queue; and wake an executor if needed. gfget, stack pointers, goid, and runqput implement those actions rather than introduce four unrelated ideas.

// The compiler turns a go statement into a call to this.
func newproc(fn *funcval) {
    gp := getg()
    pc := sys.GetCallerPC()
    systemstack(func() {
        newg := newproc1(fn, gp, pc, false, waitReasonZero)
        pp := getg().m.p.ptr()
        runqput(pp, newg, true)
        if mainStarted {
            wakep()
        }
    })
}
Pinned source: runtime.newproc.
Source path from a go statement to a runnable G: go h.fetch enters newproc and newproc1, which obtains a G, prepares its stack and goid, changes status to _Grunnable, and lets runqput use runnext, the local run queue, or global overflow

3.1 newproc1 Reuses a G before Allocating One

newproc1 runs on the system stack. It first asks the current P's free-G pool through gfget; only if that fails does it call malg(stackMin) and publish a new G. It clears g.sched, sets the initial SP, a PC returning through goexit, the function entry, parent goid, and creation PC. It then assigns a goid and changes the G from _Gdead to _Grunnable.

newg := gfget(pp)
if newg == nil {
    newg = malg(stackMin)
    allgadd(newg)
}

newg.sched.sp = sp
newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum
gostartcallfn(&newg.sched, fn)
newg.parentGoid = callergp.goid
newg.gopc = callerpc
newg.goid = pp.goidcache
casgstatus(newg, _Gdead, _Grunnable)
Pinned source: runtime.newproc1.

Creation needs G metadata and an initial stack, but it commonly reuses an object and does not create a dedicated OS thread. The function body has not run yet: _Grunnable means eligible, not started.

3.2 Local Run Queue, runnext, and Global Run Queue

A P currently contains a [256]guintptr local queue and a separate runnext slot. The size is a Go 1.26.0 implementation detail; the useful model is that local queues preserve locality while the global queue supports overflow and fairness.

runnext is not unlimited priority

newproc calls runqput with next=true, so the new G first tries to occupy runnext. When selected, it inherits the remainder of the current time slice, reducing latency for communicating Gs. If the slot already has a G, the old one moves to the normal local queue. Platforms without sysmon avoid the optimization so ping-pong work cannot starve everything else.

What happens when the local queue is full

With room, runqput writes at the ring tail. When full, runqputslow takes half the local work and places that batch plus the new G on the global queue. It redistributes a batch rather than merely putting the newest G elsewhere, making work available to other Ps.

if t-h < uint32(len(pp.runq)) {
    pp.runq[t%uint32(len(pp.runq))].set(gp)
    atomic.StoreRel(&pp.runqtail, t+1)
    return
}
runqputslow(pp, gp, h, t) // half the local Gs + new G → global
Pinned source: runqput and runqputslow; queue fields in runtime.p.

3.3 How schedule and findRunnable Locate Work

One scheduler round has a small shell: schedule asks findRunnable for a G, handles special cases such as locked Ms, and enters execute. The complicated part is discovering work.

func schedule() {
    gp, inheritTime, tryWakeP := findRunnable()
    // Spinning, GC worker, and locked-M handling omitted.
    execute(gp, inheritTime)
}
Pinned source: runtime.schedule.
The main Go findRunnable search loop checks timers, trace, and GC plus a global fairness check every 61 ticks, then the local queue, ordinary global queue, netpoll, and stealWork; before idle and park it rechecks work sources

This is not one FIFO

findRunnable checks timers near the top, then trace-reader and GC work. To stop a small set of local Gs from occupying a P indefinitely, it samples the global queue whenever schedtick % 61 == 0. Ordinary business work then checks the local queue followed by the global queue.

With no queued G, the scheduler performs non-blocking netpoll, then allows spinning Ms to use stealWork across other Ps and their timers. If nothing appears, it prepares to release its P and park the M, but rechecks queues, timers, and netpoll around that transition so newly submitted work cannot be left with every worker asleep.

Why work stealing takes a batch

runqsteal asks runqgrab for roughly half of another P's local queue. One stolen G returns immediately and the remainder populates the thief's local queue. Batch balancing reduces repeated cross-P contention; randomized victim order and a cap on spinning Ms avoid turning idle search into excessive CPU use.

3.4 execute Turns Runnable into Running

Once a G is selected, execute points the M's curg at it, points the G's m back to the M, CASes _Grunnable to _Grunning, clears waiting and preemption markers, and finally restores the saved context through gogo(&gp.sched).

mp.curg = gp
gp.m = mp
casgstatus(gp, _Grunnable, _Grunning)
gp.waitsince = 0
gp.preempt = false
// Trace, profiling, and time-slice work omitted.
gogo(&gp.sched)
Pinned source: runtime.execute.

Because G and M are associated here, ordinary application code must not assume a goroutine identity maps to one thread. Use runtime.LockOSThread only for real thread-local, GUI, or cgo constraints, accepting the loss of scheduler flexibility.

4. Waiting, Wake-Up, and Preemption

4.1 Blocking Need Not Block the Thread: gopark and ready

When a goroutine cannot continue on a channel, mutex, timer, or netpoll, runtime code uses gopark to record a wait reason and mcall(park_m) to switch to the M's g0 stack for the state transition. When the resource is ready, goready calls ready on the system stack:

casgstatus(gp, _Gwaiting, _Grunnable)
runqput(mp.p.ptr(), gp, next)
wakep()
Pinned source: gopark and goready, plus ready.

Chapter I's socket path lands here: netpoll changes I/O-ready Gs from waiting to runnable and injects them into scheduling. Some M/P runs them later. “I/O is ready” therefore does not mean “the handler resumed”; runnable latency can still intervene.

4.2 Preemption Offers a Chance, Not a Real-Time Deadline

A G that does not block must not own a P forever. In Go 1.26, sysmon's retake watches each P's schedtick. If one tick persists beyond the internal forcePreemptNS = 10ms, it requests preemptone. That sets gp.preempt and stackguard0 = stackPreempt; supported platforms also request asynchronous preemption through preemptM.

Ten milliseconds is an implementation threshold for requesting intervention, not a maximum run duration or latency SLO. Signal delivery, safe points, runtime critical sections, platform support, cgo/syscalls, and OS scheduling affect the actual handoff. Preemption improves fairness; it does not make Go hard real time.

5. Verify Scheduling with Runnable Latency

Go runnable-latency evidence: a G changes from waiting through ready to runnable, then eventually running; /sched/latencies:seconds, go tool trace, and schedtrace observe the runnable interval rather than I/O wait

What /sched/latencies:seconds measures

The internal sched.timeToRun comment defines a distribution of time from _Grunnable to _Grunning. runtime/metrics exposes it as the cumulative histogram /sched/latencies:seconds. Time waiting for I/O, channels, or mutexes is outside that interval.

samples := []metrics.Sample{
    {Name: "/sched/gomaxprocs:threads"},
    {Name: "/sched/goroutines/runnable:goroutines"},
    {Name: "/sched/latencies:seconds"},
}
metrics.Read(samples)
histogram := samples[2].Value.Float64Histogram()
Metric definitions: scheduler metric descriptions; internal histogram: sched.timeToRun.

Run the burst, trace, and handoff benchmark

scheduler_lab_test.go starts 64 goroutines, waits until all are parked on a release channel, then closes that channel to make a visible runnable burst.

cd go-runtime/examples/fetchd
go test -run TestSchedulerBurst -trace scheduler.trace
go tool trace scheduler.trace

go test -run TestSchedulerMetricsAreAvailable
go test -bench BenchmarkGoroutineHandoff -benchmem
GODEBUG=schedtrace=1000,scheddetail=1 go test -run TestSchedulerBurst
EvidenceAnswersDoes not answer alone
Scheduler metricsWhether runnable population and latency stay abnormalWhich G waited where
Execution traceG/P/M, unblock, syscall, and GC timing in one windowLong-term low-overhead trends
schedtraceCoarse snapshot of P, threads, and run queuesFine causal history
Goroutine profileStacks and wait reasons at sample timePast runnable queuing
CPU profileWhere CPU went while code ranRunnable time without CPU

6. Return to the fetchd Boundaries

  1. Concurrency is not parallelism. Fetch workers can wait on networking concurrently; CPU work remains bounded by P count and container quota.
  2. Bound fan-out before tuning the scheduler. Unbounded G creation amplifies memory, queues, pools, and wake-ups; GOMAXPROCS is not backpressure.
  3. Alert on waiting and runnable separately. Waiting usually points to a dependency; runnable points to execution supply or wake-up pressure.
  4. Do not use runtime.Gosched as a correctness fix. Correctness should come from blocking boundaries, queues, and synchronization protocols.
  5. Move from trend to window to source. Find the interval with metrics, capture a trace, then map state transitions back to source.

Keep one reusable conclusion: creating G is not creating a thread; runnable is not running; wake-up is not immediate resumption. A G must be matched with M/P before latency moves forward. Chapter IV enlarges fetchd's result channel and follows chansend/chanrecv, sudog, semaphores, mutexes, and select to decide when to use channels or locks and where backpressure truly forms.

Source and Documentation References