Go: A Documentary

Scheduler and concurrency

Part I · 31 threads in 2 era slices, from Go 1.0 to Go 1.28. Each thread gives its design, then a per-release timeline of what landed (Done) and what was still open. Release labels in a timeline link to that release's page.

● = the thread had substantive commits in that cycle; hover for the stage summary, click to jump to it in the release page.

Thread map, Go 1.0 – 1.17

Thread1.01.11.21.31.41.51.61.71.81.91.101.111.121.131.141.151.161.17Status at go1.17 (or at the end of its era slice)
Scheduler and concurrency runtime (Go 1.0–1.17)
The G-M-P scheduler: from global-lock M:N to per-P run queues, spinning Ms and work stealing●●●●●●●●●●●●●●●●●still the design at go1.17 (findrunnable had just been split into helpers for 1.18) → continues as Scheduler core: spinning Ms, work conservation, stealing, STW interaction (1.18+)
Preemption: cooperative stack-check preemption (1.2) to signal-based asynchronous preemption (1.14)●●●●●●●●both mechanisms active (async preemption on by default, GODEBUG=asyncpreemptoff=1 to disable) → continues as Asynchronous preemption: extended register state (1.18+)
Network poller: from the netFD select loop to the runtime-integrated epoll/kqueue/IOCP poller●●●●●●●●●●●●●●still the design at go1.17 → continues as Netpoller (1.18+)
Timers: timerproc goroutine → per-bucket heaps → timers on Ps●●●●●●●●●●●still the design at go1.17 (timers in per-P heaps, driven by findrunnable/checkTimers) → continues as Timer rewrite: per-P timer heap, timer.lock, and GC-able synchronous timer channels (1.18+)
Channels and select●●●●●●●●●●●●●●●●still the design at go1.17 (hchan with a mutex, sudog queues, selectgo with lock-ordered cases) → continues as Scheduler core: spinning Ms, work conservation, stealing, STW interaction (1.18+)
Semaphores, runtime locks and Notes●●●●●●●●●still the design at go1.17 (futex/sema lock, note, semaRoot treap) → continues as Runtime mutex: tri-state futex/sema locks → "spinbit" mutex (1.23+)
sync.Mutex, RWMutex, WaitGroup, Cond, Once●●●●●●●●●●●●●still the design at go1.17 → continues as sync package API evolution (1.18+)
sync.Pool●●●●●●still the design at go1.17 (per-P poolLocal with private slot plus poolChain of lock-free dequeues, victim cache) → continues as sync package API evolution (1.18+)
sync.Map, atomic.Value and sync/atomic●●●●●●●●●●●●●●still the design at go1.17 → continues as sync/atomic and runtime atomics (1.18+)
Runtime lock ranking (static lock order checking)●●●opt-in via GOEXPERIMENT=staticlockranking; hand-maintained partial order → continues as Runtime lock ranking (1.18+)
Runtime from C to Go: scheduler, channels, semaphores, timers, netpoll●●●completed in Go 1.5; no C in the runtime scheduler
Goroutine and M lifecycle: caches, ids, LockOSThread, Goexit, cgo extra Ms, deadlock detection●●●●●●●●●●●●●●●●still the design at go1.17 → continues as Goroutine lifecycle and identity (1.20+)
Stop-the-world, safe points and GC–scheduler interaction●●●●●●●●●●●●●●●still the design at go1.17 → continues as Scheduler core: spinning Ms, work conservation, stealing, STW interaction (1.18+)
Scheduler diagnostics: schedtrace, GODEBUG, execution tracer, block/mutex profiles●●●●●●●●●●still the design at go1.17
Fast random numbers for the scheduler●●●●●still the design at go1.17 (xorshift64+ fastrand) → continues as Fast random numbers for the scheduler (1.18+)
GOMAXPROCS, thread and resource limits●●●●●still the design at go1.17 → continues as GOMAXPROCS container awareness and dynamic updates (1.25+)

Thread map, Go 1.18 – 1.28

Thread1.181.191.201.211.221.231.241.251.261.271.28Status at HEAD
Runtime: scheduler, timers, channels, locks and synchronization (Go 1.18–1.28)
Timer rewrite: per-P timer heap, timer.lock, and GC-able synchronous timer channels●●●●●●●●●synchronous (unbuffered-semantics) timer channels are the only mode since asynctimerchan GODEBUG was removed in 1.27; micro-optimisation of channel-timer polling continues in 1.28
Runtime mutex: tri-state futex/sema locks → "spinbit" mutex●●●only the spinbit implementation exists (all platforms except wasm); sched.lock is exempt from some behaviours
Contended runtime-lock profiling (mutex profile for runtime-internal locks)●●●●●runtime locks appear in the mutex profile blaming the unlocker; call stacks gated behind runtimecontentionstacks
Scheduler core: spinning Ms, work conservation, stealing, STW interaction●●●●●●●●●ongoing tightening of races in P handoff, STW and safe-point handling
Syscall path: eliminating _Psyscall, _Gdeadextra, and goroutine states●shipped
GOMAXPROCS container awareness and dynamic updates●●●default behaviour, under containermaxprocs/updatemaxprocs GODEBUGs
testing/synctest bubbles●●●●●GOEXPERIMENT removed in 1.26; all bubble violations are fatal; Test-style API
Coroutines (iter.Pull runtime support)●●●stable runtime primitive (newcoro/coroswitch)
Asynchronous preemption: extended register state●●●●●●●AVX2/AVX-512/Neon/LSX/LASX state saved and conservatively scanned
sync package API evolution●●●●●●●●●●●stable; micro-optimisation continues
sync/atomic and runtime atomics●●●●●●typed atomics standard; runtime-internal atomics under internal/runtime/atomic
Runtime lock ranking●●●●●●generated from DAG; includes rwmutex modelling
Netpoller●●●●●●●epoll/kqueue/IOCP/wasip1 pollers with tagged pollDesc pointers
Goroutine lifecycle and identity●●●●goroutine leak profile on by default (experiment removed 1.27)
Fast random numbers for the scheduler●●●●cheaprand, wyrand-based

Scheduler and concurrency runtime (Go 1.0–1.17)

Reader: Claude Sonnet. Cites 1,127 of 1,521 commits in this slice; every non-noise commit is cited, plus 15 larger commits the noise filter had set aside, which a separate gap reader described. Read the opening lines of the design-heavy messages and about 18 full messages. The other commits were placed from their subject lines, and some later-release bullets are short.

Release digest for this slice
  • Go 1.0 — The pre-release runtime: global-lock M:N scheduler, chans/select, Notes and unified OS mutexes, first sync and sync/atomic packages, timers for package time, deadlock detection and the earliest GOMAXPROCS semantics; threads: early scheduler, channels, semaphores/locks, sync, atomics.
  • Go 1.1 — The per-P scheduler (779c45a507) with local run queues, and the runtime-integrated network poller; threads: G-M-P scheduler, network poller, goroutine lifecycle.
  • Go 1.2 — Cooperative preemption of long-running goroutines (10ms), GODEBUG/schedtrace, stack and thread limits, Windows and Solaris pollers; threads: preemption, netpoll, diagnostics.
  • Go 1.3 — sync.Pool, lock-free local run-queue ring and parallel root marking, precise stacks; threads: Pool, scheduler.
  • Go 1.4 — Conversion of the scheduler, channels, select, semaphores, timers and netpoll from C to Go, plus atomic.Value; threads: C→Go conversion, channels.
  • Go 1.5 — The concurrent collector’s scheduling machinery (ragged barrier, background workers, forEachP), GOMAXPROCS = NumCPU, runnext, mutex spinning, bounded caches, execution tracer; threads: scheduler core, STW/GC, Mutex, diagnostics.
  • Go 1.6 — Simplified buffered channels, fewer thread wakeups, GC-work handoff; threads: channels, scheduler.
  • Go 1.7 — Concurrent stack shrinking support for channels, new Cond, better randomized stealing; threads: channels, Cond, scheduler.
  • Go 1.8 — Mutex profile, atomic intrinsics, select/stack-growth race fix; threads: diagnostics, channels, atomics.
  • Go 1.9 — Fairer Mutex, sync.Map, semaphore treap, runtime rwmutex, os poller and internal/poll; threads: Mutex, semaphores, Map, netpoll.
  • Go 1.10 — Per-P timer buckets, nested LockOSThread, no GOMAXPROCS limit, xorshift fastrand; threads: timers, lifecycle, random.
  • Go 1.11 — atomic redirected to runtime, RWMutex profiling, ancestor tracebacks, js/wasm callbacks; threads: atomics, diagnostics.
  • Go 1.12 — gList, gchelper removed, netpoll timer merging, scheduling by class; threads: scheduler, netpoll.
  • Go 1.13 — Pool victim cache and lock-free queues, inlinable sync fast paths, preemption on many short syscalls, poller error reporting; threads: Pool, Mutex, preemption.
  • Go 1.14 — Timers on Ps and asynchronous (signal) preemption, suspendG, starvation handoff; threads: timers, preemption.
  • Go 1.15 — Static lock ranking, Ticker.Reset, Map.LoadAndDelete, timer stealing and poller-local queueing; threads: lock ranking, timers, Map.
  • Go 1.16 — Timer latency fixes, no stealing from idle Ps, timerpMask, lock-rank assertions, mark-worker pool; threads: timers, scheduler, lock ranking.
  • Go 1.17 — Value.Swap/CompareAndSwap, findrunnable refactors and spinning documentation ahead of 1.18, runqdrain, Pool random stealing; threads: scheduler core, atomics, Pool.

The G-M-P scheduler: from global-lock M:N to per-P run queues, spinning Ms and work stealing

Span: Go 1.0 → Go 1.17 · Status at go1.17: still the design at go1.17 (findrunnable had just been split into helpers for 1.18) → continues as Scheduler core: spinning Ms, work conservation, stealing, STW interaction (1.18+)

Paths not taken: NUMA-aware scheduler (never landed)

Design docs & discussion

Design. The 2008 scheduler (e7d549fbd7, “more fifo scheduling algorithm, newproc will reuse dead procs”) ran all goroutines (G) on a single global run queue protected by one sched lock. Multithreading arrived with d28acc42ec (“first cut at multithreading, works on Linux”): worker threads (M) took Gs from the global queue and an mcpu count limited how many ran Go code at once. $GOMAXPROCS was redefined as “number of CPUs to use, not number of threads” (efc86a74e4) and exposed as runtime.GOMAXPROCS (7955490de2). Every transition (ready, block, syscall) went through the global lock; by 2011 the work was shaving that lock off the syscall path (entersyscall/exitsyscall via atomics, 025abd530e, then xadd instead of cas, ba134539c5).

Go 1.1 replaced this with Dmitry Vyukov’s design: the processor P owns a local run queue, a memory cache and a cache of dead Gs; an M must hold a P to run Go code, and GOMAXPROCS is now the number of Ps. The preparation was done in steps: f20fd87384 introduced park() to atomically unlock a mutex and park a goroutine (removing the racy readyonstop flag that needed the global mutex), e25f19a638 added entersyscallblock, 353ce60f6e local work queues, 6cdfb00f4e per-P dead-G cache and the global runnable queue, and 779c45a507 (“improved scheduler”, a ~1,800 line rewrite of proc.c) made the switch: per-P runnable queues, per-P mcache and G cache, faster non-blocking syscall enter/exit and “more conservative worker thread blocking/unblocking” (idle Ms park, only a bounded number spin looking for work).

From there the work is steady refinement of fairness, wakeup policy and stealing. The invariants that matter: a P with runnable work must have an M looking at it (spinning-M accounting, nmspinning); ready() only wakes another M when no M is already spinning; a P entering syscall is retaken by sysmon after 20us/one sysmon tick and handed off with handoffp; idle Ps live on sched.pidle; runnable Gs overflow from a P’s 256-slot ring to the global queue.

Timeline.

Preemption: cooperative stack-check preemption (1.2) to signal-based asynchronous preemption (1.14)

Span: Go 1.0 → Go 1.17 · Status at go1.17: both mechanisms active (async preemption on by default, GODEBUG=asyncpreemptoff=1 to disable) → continues as Asynchronous preemption: extended register state (1.18+)

Paths not taken: Dmitry Vyukov’s 2013–14 scheduler experiments (partially landed) · Loop preemption by compiler-inserted checks and page faults (superseded)

Design docs & discussion

Design. Until 1.2 a goroutine only gave up its M voluntarily (channel, lock, syscall, Gosched) or at GC checks in expensive runtime routines (5328df6534). Go 1.2 added cooperative preemption by reusing the stack-split prologue: setting g->stackguard0 = StackPreempt makes the next function call enter morestack, which turns it into a reschedule. The pieces: a preemptone function (354ec51666), sysmon issuing requests after 10ms of running one G (bc31bcccd3), STW issuing requests every 100us (1e112cd59f), and m->locks reset only after the real unlock so a G holding a runtime lock is never preempted (764bb36ea2). Reliability fixes were needed because the flag could be overwritten by stack growth or lost (5887f142a3, f9066fe1c0). The prologue approach cannot interrupt tight loops without calls, and in Go 1.14 Austin Clements added signal-based preemption: suspendG/resumeG generalized stack-scan suspension (3f834114ab), preemptone sends SIGURG (177a36a5dc), and an asyncPreempt assembly stub spills all user-visible registers (a3ffb0d9eb, generated by mkpreempt.go). A PC is async-safe only if it is not in the runtime, not in an assembly function, and has no “unsafe point” marker; unsafe-point marking was switched from stack map index -2 to the register map so calls can be marked (c3f149250e).

Timeline.

Network poller: from the netFD select loop to the runtime-integrated epoll/kqueue/IOCP poller

Span: Go 1.1 → Go 1.17 · Status at go1.17: still the design at go1.17 → continues as Netpoller (1.18+)

Design. Before 1.1 package net ran its own poller goroutine and Windows used a separate IOCP loop. Dmitry Vyukov moved polling into the scheduler: netpoll(block) returns a list of ready Gs; pollDesc objects (one per fd) hold rg/wg pointers that are either nil, pdReady, pdWait or a G pointer, updated by CAS; deadlines use runtime timers. The scheduler polls non-blockingly in findrunnable, sysmon polls if nobody has for 10ms (6857264457), and an idle M can block in epoll_wait/kevent instead of a futex. The invariant “there must be at least one running P or a thread polling the network” (0b5d55984f) prevents netpoll deadlocks. internal/poll (1.9) then factored the fd lifecycle (fdMutex) out of net and the os package began using the poller for pipes/files (c05b06a12d).

Timeline.

Timers: timerproc goroutine → per-bucket heaps → timers on Ps

Span: Go 1.0 → Go 1.17 · Status at go1.17: still the design at go1.17 (timers in per-P heaps, driven by findrunnable/checkTimers) → continues as Timer rewrite: per-P timer heap, timer.lock, and GC-able synchronous timer channels (1.18+)

Paths not taken: Dmitry Vyukov’s 2013–14 scheduler experiments (partially landed)

Design docs & discussion

Design. Timers began as a single timerproc goroutine with one global heap (3b860269ee “add timer support, use for package time”; bug fixes dc6726b37f, a899a467f2). Go 1.2 made the heap 4-ary (fcf6a7e5ce), used a monotonic clock for timers on linux (86c976ffd0) and added tsleep overflow handling (3548ab5ebb). In Go 1.10 Aliaksandr Valialkin’s 76f4fd8a52 (“improve timers scalability on multi-CPU systems”) sharded the heap into timersBuckets (one per P, timersLen buckets), each with its own goroutine; goroutines still ran timerproc and had to be woken to run timer functions.

Go 1.14 (Ian Lance Taylor, tracked as #6239 and #27707) rewrote this to put timers on Ps. Each P owns a heap of *timer, a status word timerNoStatus/timerWaiting/timerRunning/timerDeleted/timerRemoving/timerRemoved/timerModifying/timerModifiedEarlier/timerModifiedLater/timerMoving is advanced by CAS and timer.pp points to the owning P. The scheduler calls checkTimers from schedule and findrunnable, which runs expired timers inline on the P (no goroutine), adjusttimers applies modified timers, cleantimers/clearDeletedTimers drops deleted ones lazily, and idle Ps wait in netpoll with a deadline set to the next timer so that time.Sleep and netpoll share one wakeup mechanism. The new code was landed as ~15 bisectable commits that were added but unused until the switch (2e0aa581b4 addtimer, 7416315e33 deltimer, 48eb79ec21 modtimer, eff3c1e426 resettimer, 9093b1def0 dodeltimer, 4665470147 cleantimers, 7d891d5e4d movetimers, 220150ff3c adjusttimers, 432ca0ea83 runtimer), then 6becb03334 switched (“AfterFunc 1.57ms → 0.07ms, After 1.63ms → 0.11ms”) and 580337e268 removed the old code. Later stages were stealing timers from running Ps, avoiding scans and cutting latency.

Timeline.

Channels and select

Span: Go 1.0 → Go 1.17 · Status at go1.17: still the design at go1.17 (hchan with a mutex, sudog queues, selectgo with lock-ordered cases) → continues as Scheduler core: spinning Ms, work conservation, stealing, STW interaction (1.18+)

Paths not taken: Lock-free channels and finer-grained select locking (never landed)

Design docs & discussion

Design. Channels date from July 2008 (594175d0b5 “chan”, 526200345c synch chan, 2d9ff40774 asynch), select from e963cba8a1, select default from b69e80d8dd, and close in 4523ee9ac8. In the C runtime the channel had a single lock per chan (global chanlock removed, d1740bb3a6), a circular buffer (replacing a circular linked list, 507df959e4) and SudoG wait queues. 2011 performance work (Vyukov): SudoG always points to the element and sits on the stack, copying and wakeups moved outside critical sections 90f3cb13fb; chan creation without finalizer on futex platforms (66% faster) d770aadee5; specialized type algorithms 54e9406ffb.

Select picks a random permutation (pollorder) to be fair (4f269d3060) and locks channels in address order (lockorder). A blocked select queues a sudog on every channel with a shared selectdone word that waking goroutines CAS (the stack-growth race in this protocol was fixed in 1.8 b902a63ade). Go 1.4 moved chan and select into Go; Go 1.5 made sudog linked lists doubly linked, giving O(1) dequeue instead of O(n^2) on popular channels (BenchmarkChanPopular 2.56ms → 0.63ms, 8eb8b40a49); Go 1.6 simplified buffered operations so that a waker completes the whole protocol with its counterpart and the sleeper never re-locks (single copy sender→receiver, 8e496f1d69, relanded as e410a527b2 after a revert); Go 1.7 made sudog.elem protected by hchan lock and recorded the channel in the sudog to support concurrent stack shrinking (e4a95b6343, db72b41bcd, 276b177771).

Timeline.

Semaphores, runtime locks and Notes

Span: Go 1.0 → Go 1.17 · Status at go1.17: still the design at go1.17 (futex/sema lock, note, semaRoot treap) → continues as Runtime mutex: tri-state futex/sema locks → “spinbit” mutex (1.23+)

Design. The runtime had two primitives: Lock (spin then sleep) and Note (one-shot event with notesleep/notewakeup), plus sync semaphores (semacquire/semrelease, published for sync in 5bbd4c2f1b). Early on each OS had its own mutex; ee24bfc058 (Go 1.0) unified them into two generic implementations, futex-based and semaphore-based, each OS supplying a few callbacks (futexsleep/futexwakeup or semasleep). That removed chan finalizers. Notes gained timed sleep (notetsleep, user-goroutine notetsleepg) for preemptive scheduling (Go 1.2: e97d677b4e, e932c2035f) and extra debugging for double wakeups (4380fa6d99, 98d94b589c).

User-level semaphores live in sema.go as a hash table of semaRoots indexed by address. 1.9 fixed scaling for pathological hashing: Russ Cox first made each root a two-level list (O(n²) → O(n) when n goroutines wait on two locks hashing to one root, 45c6f59e1f), then a balanced treap keyed by address (O(n log n) with n/2 colliding locks, 990124da2a), recorded the mutex-event timestamp before readying (b067ad939d) and moved mutexevent profiling out from under the root lock (58d762176a). cansemacquire/semrelease handoff for starving mutexes (1.14) lets Unlock yield to the waiter (a8f57f4ada, relanded 7148478f1b; trace event a4c579e8f7). Go 1.9 also added a runtime rwmutex type (09ebbf4085) working on Ms instead of Gs (80832974ac), used for execLock (9745e88b22) and requiring that Ms holding a read lock not be descheduled (f3b5a2bc19).

Timeline.

sync.Mutex, RWMutex, WaitGroup, Cond, Once

Span: Go 1.0 → Go 1.17 · Status at go1.17: still the design at go1.17 → continues as sync package API evolution (1.18+)

Paths not taken: Per-CPU sharded values: sync.Cache, distributed RWMutex, percpu.Sharded (still open)

Design docs & discussion

Design. bf3dd3f0ef added mutex.Mutex in 2008 (first on sys.cas, 84ded32817), 91ceda5c18 a stub RWLock, and 12206f64dd the first real sync.RWMutex (“support for concurrent readers”). sync.Once was added in 895c5db6df, sync.Cond in 05b1dbd0a6, sync/atomic in 22eab1f5c7 with sync rebased on it 12b7875bf2.

sync.Mutex evolution: 2011 RWMutex with a wait-free fast path for readers (BenchmarkRWMutexUncontended 179 → 96.6ns, daaf29cf93); WaitGroup fast paths (−64% uncontended, ee6e1a3ff7). Go 1.5 added active spinning: “limited active spinning” if running multi-core, GOMAXPROCS>1 and at least one other running P with an empty local run queue (edcad8639a). Go 1.9’s 0556e26273 made the mutex fairer: normal mode (new arrivals can steal the lock) vs starvation mode (ownership handed off directly after a waiter waits more than 1ms, new arrivals queue at the tail), and fixed requeueing at the tail of the wait list (waiters that were woken but lost go to the head). Go 1.13 made the Lock/Unlock/Once.Do/RUnlock fast paths inlinable (41cb0aedff, 4c3f26076b, ca8354843e, 05051b56a0), and Go 1.14 added the runtime-level yield to the waiter.

WaitGroup was simplified to a single 64-bit state word (03a48ebe1c removed dynamic semaphores; 9ff7df003d made it more space-efficient in 1.11). Cond went from semaphores-per-waiter (5a20b4a6a9: no allocation, 30–50% faster, copy check) to a notification list (8e7072ca83, 1.7) fixing Broadcast missing waiters.

Timeline.

sync.Pool

Span: Go 1.3 → Go 1.17 · Status at go1.17: still the design at go1.17 (per-P poolLocal with private slot plus poolChain of lock-free dequeues, victim cache) → continues as sync package API evolution (1.18+)

Paths not taken: Per-CPU sharded values: sync.Cache, distributed RWMutex, percpu.Sharded (still open)

Design docs & discussion

Design. Added as a stop-gap by Brad Fitzpatrick (8c6ef061e3, “temporary implementation, until Dmitry makes it fast”) with the registered cleanup in the GC (mgc0.c). A week later f8e0057bb7 made it scalable: P-local caches via procPin (introduced in 90653d7864), batches moved to/from a mutex-protected shared list (BenchmarkPool-16 403311 → 1598 ns); 8fc6ed4c89 limited the local cache to 1 element per P because with GOMAXPROCS=32 and 2MB objects Pool could waste 960MB. Go 1.13 rewrote the shared part: poolDequeue (fixed-size lock-free ring, 2b60567002) chained into poolChain (57bb7be4b1) so that Put pushes at the head and other Ps steal from the tail without locks (d5fd2dd6a1), and poolCleanup was changed from “clear everything on each GC” to a victim cache (2dcbf8b369), so objects survive one more GC. Go 1.17 randomizes the stealing start via randomOrder copied from the scheduler (d5d24dbe41).

Timeline.

sync.Map, atomic.Value and sync/atomic

Span: Go 1.0 → Go 1.17 · Status at go1.17: still the design at go1.17 → continues as sync/atomic and runtime atomics (1.18+)

Design docs & discussion

Design. sync/atomic was added in 2011 (22eab1f5c7) with LoadUintptr, Store and 64-bit Load/Store added later (ea23ba3e2d, 1fc676332f, cf0d8c0941), MFENCE replaced by LOCK XADD for 386 compatibility (ab596cae9e), a Linux/ARM kernel cas64 helper for 64-bit ops (090f9fc3ef, 4d724f4c5b), and run-time checks for 64-bit alignment on 386 and ARM (14a295c817, 94b7853924, and docs 64a0017d6e). Swap functions came in 1.2 (0e15b03f93).

atomic.Value (1.4, 98a1e207e2) stores an interface with the type word written first as a sentinel ^uintptr(0) and published atomically (copy-on-write idiom). sync.Map (1.9) was imported from x/sync/syncmap (959025c0ac): a read-only atomic map with a dirty map and miss counter, expunged entries; 1.15 added LoadAndDelete (2e8dbae85c, fix 94953d3e59) and 1.17 added Value.Swap/CompareAndSwap (2422c5eae5). In the compiler, atomics were intrinsified on amd64 (1.8, d6098e4277) and many sync/atomic functions redirected to runtime/internal/atomic (1.11, 1b6fec862c).

Timeline.

Runtime lock ranking (static lock order checking)

Span: Go 1.15 → Go 1.17 · Status at go1.17: opt-in via GOEXPERIMENT=staticlockranking; hand-maintained partial order → continues as Runtime lock ranking (1.18+)

Design docs & discussion

Design. Dan Scales added lockrank.go (0a820007e7), a documented total order of runtime lock classes. With the experiment enabled, lock2 calls lockWithRank/acquireLockRank which keeps the M’s held-lock list and throws when a lower-rank lock is acquired while holding a higher one; a partial-order table (lockPartialOrder) allows incomparable pairs. It was made explicit that “ordering direction” runs from lower to higher rank (646b4ac065) and that Gscan acquire/release participates (f9640b88c7). Michael Pratt (Go 1.16) added assertions that required locks are held (assertLockHeld, assertRankHeld, 53c9b9588a), sched.lock assertions (d42b32e321) and in 1.17 a check that the partial order itself is consistent (c41bf9ee81). Edges were added incrementally whenever a real nesting was found (1067ab0808, 300ff5d8ac, a81bc8e825, 0f8fecaba7, f474e9e549, e05695ed4d; 1.16 2013f70256, 2754d91164, c68745b130, 0e0a872a76, a69cda9a65; 1.17 e8e425cb23, d75fbac54d).

Timeline.

Runtime from C to Go: scheduler, channels, semaphores, timers, netpoll

Span: Go 1.3 → Go 1.5 · Status at go1.17: completed in Go 1.5; no C in the runtime scheduler

Design docs & discussion

Design. Before 1.4 proc.c, chan.goc, sema.goc, time.goc, netpoll.goc, select.goc were Plan 9 C. Starting with 1.3’s precise stacks the runtime prepared to drop C stacks, in 1.4 Dmitry Vyukov and Russ Cox converted pieces by hand into Go (.go with //go:nosplit, systemstack/onM calls to jump to g0), and in dev.cc Russ ran an automated C-to-Go translator. Consequences visible in the history: functions that park must run on the G stack (da8cf5438a, 71de7e7539); sudogs stopped living on the stack, so a sudog cache was introduced and leaked stale pointers until b3932baba4; write barriers appear in scheduler code so guintptr/muintptr pointer-free types were introduced (eafc482d4f, 239273d963, 0ca4488cc1, 165c15afa3, 12ec54724b) plus //go:nowritebarrier(rec) checks (402e37d4a9, d2c81ad847, 9897e40811).

Timeline.

Goroutine and M lifecycle: caches, ids, LockOSThread, Goexit, cgo extra Ms, deadlock detection

Span: Go 1.0 → Go 1.17 · Status at go1.17: still the design at go1.17 → continues as Goroutine lifecycle and identity (1.20+)

Paths not taken: Main-thread APIs: RunWithMainThreadOnly to mainthread.Do (still open)

Design docs & discussion

Design. G structs are recycled via per-P gfree lists (6cdfb00f4e, 779c45a507); 1.7 split the free list into with-stacks and without-stacks (1a2cf91f5e); 1.17 moves them in batches between P and global (1f7a01459b). Goroutine ids were widened to 64 bits (320df44f04), allocated in batches per P (98b50b89a8), start at 1 (aa76377423). Bounded caches: defer pool and sudog cache have fixed-size local pools plus a central pool because “unbounded local caches are bad and grow to infinite size” (8059071153, 5ef145c809, b759e225f5).

LockOSThread was a single bit; in 1.10 it nests (c85b12b579, #20458), with locked threads terminated if the goroutine exits locked (4f34a52913), no thread creation from locked threads (2595fe7fb6, delegated to a template thread) and lockedExt preserved (d0f8a7517a). runtime.Goexit from main: program continues until other goroutines finish, then crashes (55e0f36fb4, ade6bc68b0). Cgo callbacks from non-Go threads use “extra Ms” (needm/dropm); 1.7 allocates as many extra Ms as needed (50048a4e8e), and 1.10 marks the extra G dead when unused (4e7067cde4). Deadlock detection (checkdead) works from mcount - nmidle - nmidlelocked, handles cgo (34c67eb24e) and Goexit-in-main.

Timeline.

Stop-the-world, safe points and GC–scheduler interaction

Span: Go 1.0 → Go 1.17 · Status at go1.17: still the design at go1.17 → continues as Scheduler core: spinning Ms, work conservation, stealing, STW interaction (1.18+)

Design. In the early runtime STW was stoptheworld via sched.gcwaiting and ms parking at the next scheduler entry; 1e112cd59f made STW issue preemption requests every 100us. The concurrent collector of 1.5 brought several scheduler primitives: forEachP runs a safe-point function on each P (f0dd002895 separated its counters from STW’s stopwait/stopnote; edfc979725 runs the function before entering _Psyscall), ragged barriers replace a STW for enabling write barriers (57afa76471, bb6320535d), utilization-scheduled background mark workers with gcBgMarkWorker Gs (8d03acce54, e0c3d85f08, e09dbaa1de), and 4b956ae317 starts GC promptly when the trigger is hit. The G status gained _Gscan bits and states are documented (b1a6e07919). Stack shrinking was made safe concurrently with channel operations by locking channels and tagging sudogs (276b177771, 8fb182d020, 36a432f27b), stack rescan was O(# dirty stacks) (2a889b9d93) until the hybrid barrier made rescan unnecessary (c5ebcd2c8a). Go 1.14 introduced suspendG/resumeG (3f834114ab) replacing scang/restartg, adding _Gpreempted, and removed old stack scanning and gcscanvalid (1b79afe460, 8c5861576a). 1.14/1.15 worldsema is no longer held across mark phase (7b294cdd8d; relanded f1f947af28), with GOMAXPROCS, StartTrace and StopTrace still excluded. In 1.16 the GC workers are managed by a global pool rather than attached to each P (e1faebe7b4).

Timeline.

Scheduler diagnostics: schedtrace, GODEBUG, execution tracer, block/mutex profiles

Span: Go 1.1 → Go 1.17 · Status at go1.17: still the design at go1.17

Design docs & discussion

Design. GODEBUG was introduced in Go 1.2 (4b536a550f: replaces GOGCTRACE; “the plan is to extend”) and gained schedtrace=N (ms period) with scheddetail=1 for per-P/M/G dump (4f2e382c9f; e.g. SCHED 1001ms: gomaxprocs=4 idleprocs=3 threads=6 ...), later with spinning-thread counts (a7186dc303) and delay-overflow fix (f939ee13ae). Block profiling (pprof “goroutine blocking profile”) came in 1.1 (4cc7bf326a) and was extended to channel/select/close (c92287686d). Go 1.5 added the execution tracer (f30a2b9ca7, 5288fadbdc) with explicit per-event sequence numbers (80c98fa901), switched in 1.7 to per-goroutine sequence numbers because a global sequencer slowed parallel machines “up to 10x” (a3703618ea). 1.8 added the mutex profile (ca922b6d36) and 1.11 included RWMutex (88ba645827). Tracebacks show creator PCs (324cc3d040, c33d490020), blocked durations (c0b9e6218c), and ancestors (d9b006a705).

Timeline.

Fast random numbers for the scheduler

Span: Go 1.0 → Go 1.17 · Status at go1.17: still the design at go1.17 (xorshift64+ fastrand) → continues as Fast random numbers for the scheduler (1.18+)

Design docs & discussion

Design. fastrand (per-M) first used a trivial LFSR; it was seeded from cputicks to blunt hash-table complexity attacks (8e765da941, RDTSC on x86). Per-M generators avoided false sharing (909f31872a). The LSFR had strong correlation in higher bits (visible in fastrandn, and in stealing, which missed Ps with high probability), so Go 1.9 widened to 32 bits (663226d8e1), added a multiply-shift fastrandn (46a75870ad, ~2x faster), and Go 1.10 moved to xorshift64+ (e7e4a4ffa3, passes Diehard/SmallCrush); 9d9722a2e1 fixed use inside sema.go. Race mode additionally randomizes scheduling (2028077899) and package init order (8515d9cf65).

Timeline.

GOMAXPROCS, thread and resource limits

Span: Go 1.0 → Go 1.17 · Status at go1.17: still the design at go1.17 → continues as GOMAXPROCS container awareness and dynamic updates (1.25+)

Design docs & discussion

Design. GOMAXPROCS counted CPUs rather than threads from 2008 (efc86a74e4), made callable at runtime (7955490de2, with a hack to return the old value eb48bfbbda) and needed to wait for the main goroutine in init (aa1aaee7fd). Go 1.1 made it the P count and procresize resizes Ps; Go 1.2 added limits: stack size 757e0de89f (default 1GB/250MB) and thread count (665feeedcb, default 10000), later SetMaxThreads overflow fix (10560afb54). Go 1.5 defaulted to NumCPU (3ffcbb633e) and 1.6 documented that NumCPU doesn’t change (c5a94ba24f), 1.7 clamps the OS value to _MaxGomaxprocs (31cf1c1779), 1.9 raised the cap to 1024 (fcb45b9c61) and 1.10 removed it (ee55000f6c).

Timeline.

Changes in this slice that are not part of a thread are listed on the release pages: 1.0 · 1.1 · 1.2 · 1.3 · 1.4 · 1.5 · 1.6 · 1.7 · 1.8 · 1.9 · 1.10 · 1.11 · 1.12 · 1.13 · 1.14 · 1.15 · 1.16 · 1.17

Runtime: scheduler, timers, channels, locks and synchronization (Go 1.18–1.28)

Reader: Claude Sonnet. Cites 530 of 688 commits; nothing non-noise is left uncited. Read the design-heavy messages plus about 70 others; the remaining commits were placed from their subject lines.

Timer rewrite: per-P timer heap, timer.lock, and GC-able synchronous timer channels

Span: Go 1.18 → Go 1.28 · Status at HEAD: synchronous (unbuffered-semantics) timer channels are the only mode since asynctimerchan GODEBUG was removed in 1.27; micro-optimisation of channel-timer polling continues in 1.28

Paths not taken: Concurrent and global timer heaps (2024) (never landed)

Earlier history: Timers: timerproc goroutine → per-bucket heaps → timers on Ps (Go 1.0 → Go 1.17)

Design docs & discussion

Design. Each P owns a timers struct (heap of {timer, when} pairs plus a minWhenHeap/minWhenModified hint used by fast paths) and the scheduler checks it from findRunnable/schedule (runtimer, adjusttimers, cleanHead). Until 1.22 every timer carried a CAS-driven status word with many states (timerNoStatus, timerWaiting, timerRunning, timerDeleted, timerRemoving, timerRemoved, timerModifying, timerModifiedEarlier/Later, timerMoving), so deletion was lazy (“zombies” left in the heap and a deletedTimers count). In Go 1.23 Russ Cox replaced this with a real per-timer mutex plus a few state bits: real deadline always in t.when; timerHeaped if in a heap; timerModified if the heap copy is stale; heap deadline lives in the heap slice itself (bb7a29991c). timers.adjust was rewritten from an O(n) loop plus n O(log n) re-insertions into “mark dirty then re-heapify” in O(n) with no allocation and no multi-timer locking (0728e2b139).

On top of that, the user-visible semantics changed: timers and tickers whose channel is unreferenced become collectable even if still running, because a channel-timer is only in the heap while some goroutine is blocked on its channel (508bb17edd); and Stop/Reset guarantee that no stale value is received afterwards. The implementation models “unbuffered channel fed by a coordinating goroutine”: the real 1-element buffer is hidden (len/cap(t.C) report 0), and Stop/Reset take timer.sendLock, discard a pending buffered send, and report “was active” correctly via an isSending marker (966609ad9e). GODEBUG=asynctimerchan=1 restored the old behaviour until its removal (a5bc606a68). Receives on an idle channel-timer run the timer inline (maybeRunChan) instead of needing a heap entry.

Timeline.

Runtime mutex: tri-state futex/sema locks → “spinbit” mutex

Span: Go 1.23 → Go 1.25 · Status at HEAD: only the spinbit implementation exists (all platforms except wasm); sched.lock is exempt from some behaviours

Paths not taken: Deeper-sleep futex mutex (superseded)

Earlier history: Semaphores, runtime locks and Notes (Go 1.0 → Go 1.17)

Design docs & discussion

Design. The old lock2 was tri-state (unlocked/locked/sleeping) with separate futex and semaphore implementations; with many threads contending for a short critical section every unlock2 saw “sleeping” and woke another thread, which then joined the spin loop on the same cache line (throughput collapse). The final design (fd050b3c6d) unifies futex and sema platforms behind a semaphore abstraction (futexsleep/wakeup implemented as sema ops, 18c2461af3) and adds a “spinning” bit to the mutex word: when a waiter is already awake and spinning, other lockers sleep immediately and unlock2 skips the wakeup syscall. Sleeping Ms form a LIFO stack linked through the word; to bound starvation the bottom of the stack is periodically woken to spin. sched.lock is exempt from most of the new behaviour because Ms use it in sequence for thread-specific work and want low-latency handoff. The most efficient layout needs atomic.Xchg8, so that intrinsic was added for amd64, 386, arm, riscv64, mips64x, mipsx, s390x, wasm (841bb62f19, a8e2ecc8b1, 060bd25310, 58083b57d4, 266b0cff18, e15d14873f, 3c3b8dd4f0). Benchmarks in the commit: geomean 74.3ns→30.3ns (-59%) on one contention microbenchmark and -14.8% on another (13th-gen i7).

Timeline.

Contended runtime-lock profiling (mutex profile for runtime-internal locks)

Span: Go 1.22 → Go 1.27 · Status at HEAD: runtime locks appear in the mutex profile blaming the unlocker; call stacks gated behind runtimecontentionstacks

Design docs & discussion

Design. Samples for contended runtime.mutex values are stored one-deep in the M (call stack + cycles waited) until it is safe to insert into the mprof bucket table (no runtime mutex held, not in a gscan state), because inserting itself takes the profInsert lock; unrecordable samples are folded into a sentinel _LostContendedRuntimeLock frame. In 1.25 the semantics were made to match sync.Mutex: unlock2 decides up front whether to sample; if so it uses a slower unlock path, releases the lock, walks the O(N) stack of waiting Ms to total the delay its critical section caused, and reports that with the unlocker’s stack (4d0f955a5e). Separately sync.Mutex profile semantic changed in 1.22 to count every blocked goroutine, matching Abseil (100 goroutines blocked 10ms = 1000ms; 1c00354013).

Timeline.

Scheduler core: spinning Ms, work conservation, stealing, STW interaction

Span: Go 1.18 → Go 1.28 · Status at HEAD: ongoing tightening of races in P handoff, STW and safe-point handling

Earlier history: The G-M-P scheduler: from global-lock M:N to per-P run queues, spinning Ms and work stealing (Go 1.0 → Go 1.17) · Channels and select (Go 1.0 → Go 1.17) · Stop-the-world, safe points and GC–scheduler interaction (Go 1.0 → Go 1.17)

Design docs & discussion

Design. Standard G/M/P: per-P local runq plus runnext, global runq, spinning Ms (sched.nmspinning) that steal from random victims, idle P list (pidleget), idle M list. Invariant being defended across these commits is work conservation: when work is submitted and an idle P exists, a spinning M must exist or be created. Racing windows (“delicate dance” where a spinning M drops its P, rechecks queues, parks) produce lost wakeups.

Timeline.

Syscall path: eliminating _Psyscall, _Gdeadextra, and goroutine states

Span: Go 1.26 · Status at HEAD: shipped

Design docs & discussion

Design. Previously a P in a syscall sat in _Psyscall and entersyscall/exitsyscall did an atomic store and CAS on the P status. _Psyscall was eliminated: the P stays _Prunning and syncronisation uses CAS on the G’s _Gsyscall status (7244e9221f, authored 2025-02, landed in the 1.26 cycle). The price is a newly possible combination—brief windows with a goroutine _Grunning but without a P—hidden from the execution tracer and flagged as risky in the message. Supporting pieces: _Gdeadextra status for goroutines on extra Ms (distinct from _Gdead since it may take a scan bit; df33c17091); no scheduling-latency tracking on _Grunning<->_Gsyscall (5b8e850340); direct optimistic CAS of atomicstatus in enter/exitsyscall (8683bb846d); nGsyscallNoP accounting fixes (745349712e, 81283ad339).

Timeline.

GOMAXPROCS container awareness and dynamic updates

Span: Go 1.25 · Status at HEAD: default behaviour, under containermaxprocs/updatemaxprocs GODEBUGs

Earlier history: GOMAXPROCS, thread and resource limits (Go 1.0 → Go 1.17)

Design docs & discussion

Design. internal/runtime/cgroup parses /proc/self/cgroup and mountinfo to find the leaf cgroup’s CPU quota/period (cgroup v1 and v2) using a single-scratch-buffer line reader (f12c66fbed, 06450a82b0). containermaxprocs (Linux) makes the default GOMAXPROCS the lower of the cgroup limit and the logical CPU count; updatemaxprocs (all OSes) makes sysmon periodically recompute the default and update GOMAXPROCS via a helper goroutine (updateMaxProcsG) if the CPU mask or cgroup limit changed (e6dacf91ff). Departure from the proposal: only the leaf cgroup is considered, with cached fds periodically reread; parent cgroup limits and migration are ignored, to avoid fd-leak-detection issues. Any explicit GOMAXPROCS env or call disables updates.

Timeline.

testing/synctest bubbles

Span: Go 1.24 → Go 1.28 · Status at HEAD: GOEXPERIMENT removed in 1.26; all bubble violations are fatal; Test-style API

Design docs & discussion

Design. A bubble is a tree of goroutines with a private fake clock. The runtime tracks bubble membership per g, marks goroutines blocked on channels, sync.Cond, sync.WaitGroup, timers, select{} as durably blocked; when every bubbled goroutine is durably blocked, the bubble’s clock jumps to the next timer; if no timer exists a deadlock panic is raised. Channels record the creating bubble (hchan.bubble), so cross-bubble operations are detectable. Timers inside a bubble live in a per-bubble heap and run when the bubble is idle. WaitGroups are associated with a bubble at first Add and only durably block Wait while associated (b170c7e94c), and are disassociated on Wait (9ae38be302).

Timeline.

Coroutines (iter.Pull runtime support)

Span: Go 1.22 → Go 1.24 · Status at HEAD: stable runtime primitive (newcoro/coroswitch)

Paths not taken: Detecting coroutines in channel code (superseded)

Design docs & discussion

Design. coro lets one goroutine hand its M/P directly to another without going through the scheduler queues: coroswitch marks the caller waiting, makes the target running, so iter.Pull costs roughly a function call plus switch rather than channel handoff. casgstatus is bypassed with an optimistic CAS fast path (reused later for syscalls, 8683bb846d).

Timeline.

Asynchronous preemption: extended register state

Span: Go 1.18 → Go 1.28 · Status at HEAD: AVX2/AVX-512/Neon/LSX/LASX state saved and conservatively scanned

Earlier history: Preemption: cooperative stack-check preemption (1.2) to signal-based asynchronous preemption (1.14) (Go 1.0 → Go 1.17)

Design docs & discussion

Design. mkpreempt.go generates per-arch asyncPreempt stubs. For vector code (simd) the preempted goroutine’s extended registers must be saved off-stack in a block (gp.xRegs) published after fully overwritten and scanned conservatively by GC so pointers living only in vector registers are kept alive.

Timeline.

sync package API evolution

Span: Go 1.18 → Go 1.28 · Status at HEAD: stable; micro-optimisation continues

Paths not taken: Per-CPU sharded values: sync.Cache, distributed RWMutex, percpu.Sharded (still open)

Earlier history: sync.Mutex, RWMutex, WaitGroup, Cond, Once (Go 1.0 → Go 1.17) · sync.Pool (Go 1.3 → Go 1.17)

Design docs & discussion

Design. The package is kept small: Mutex (now in internal/sync), RWMutex, WaitGroup, Cond, Once (+ OnceFunc/OnceValue/OnceValues), Pool, Map. Fast paths use atomics; hooks into runtime_Semacquire and notifyList.

Timeline.

sync/atomic and runtime atomics

Span: Go 1.18 → Go 1.28 · Status at HEAD: typed atomics standard; runtime-internal atomics under internal/runtime/atomic

Paths not taken: Splitting the runtime package with sockdrawer (superseded) · 64-bit field alignment on 32-bit platforms (dormant)

Earlier history: sync.Map, atomic.Value and sync/atomic (Go 1.0 → Go 1.17)

Design docs & discussion

Design. Go 1.19 added typed atomic values (Int32, Uint64, Bool, Pointer[T] …) that inline to primitives, hide the raw word so every access must be atomic, and guarantee 64-bit alignment (ffe48e00ad). Go 1.20 converted the runtime wholesale to these types (a ~50-commit series: schedt.*, p.*, g.atomicstatus, netpollWaiters, timeHistogram …), dropping padding and alignment hacks (1aa5730f49, bcd1ac7120); inline-cost tweaks (9dd9174fa5, 638c9aad5f) keep methods inlinable, and conversions of Pointer[T] are disallowed (6bead8f77a). Go 1.23 added public And/Or (192d65e46b) with arch-native runtime versions.

Timeline.

Runtime lock ranking

Span: Go 1.18 → Go 1.27 · Status at HEAD: generated from DAG; includes rwmutex modelling

Earlier history: Runtime lock ranking (static lock order checking) (Go 1.15 → Go 1.17)

Design docs & discussion

Design. mklockrank.go consumes a DAG in the internal/dag language and generates lockrank.go with the transitive-closure partial order (2b8a9a484f); previously a hand-maintained array not even transitively closed. GOEXPERIMENT=staticlockranking checks acquire order per M. In 1.22 rwmutex got per-instance ranks with proper modelling of reader paths (9b4b3e5acc, dcbe772469).

Timeline.

Netpoller

Span: Go 1.18 → Go 1.28 · Status at HEAD: epoll/kqueue/IOCP/wasip1 pollers with tagged pollDesc pointers

Earlier history: Network poller: from the netFD select loop to the runtime-integrated epoll/kqueue/IOCP poller (Go 1.1 → Go 1.17)

Design. pollDesc holds read/write waiter G pointers (rg, wg) and flags. Races fixed over time: in 1.18 closing/errors flags were read racily and relied on x86 store barriers; on arm64 this deadlocked net/http tests, so accesses were made atomic (1b072b3ed5, 17b2fb1b65). In 1.21 a fdseq sequence number is packed into a taggedPointer stored in the kernel event, so events for a closed fd whose pollDesc was reused are ignored (not on 32-bit) (d5514013b6, a1674f3ee3).

Timeline.

Goroutine lifecycle and identity

Span: Go 1.20 → Go 1.27 · Status at HEAD: goroutine leak profile on by default (experiment removed 1.27)

Earlier history: Goroutine and M lifecycle: caches, ids, LockOSThread, Goexit, cgo extra Ms, deadlock detection (Go 1.0 → Go 1.17)

Design docs & discussion

Design. g.goid became uint64 with atomic goidgen (dd8cb66d0b, b464708b46); parent goroutine ID is recorded and printed in stack traces (“in goroutine N”) (51225f6fc6). Goroutine leak detection uses the GC itself: goroutines blocked on unreachable sync primitives are reported via a new profile (8c68a1c1ab, 94 files). New goroutines inherit DIT state without OS thread locking (af14f67911) and secret state (e26a373785).

Timeline.

Fast random numbers for the scheduler

Span: Go 1.18 → Go 1.27 · Status at HEAD: cheaprand, wyrand-based

Earlier history: Fast random numbers for the scheduler (Go 1.0 → Go 1.17)

Timeline.

Changes in this slice that are not part of a thread are listed on the release pages: 1.18 · 1.19 · 1.20 · 1.21 · 1.22 · 1.23 · 1.24 · 1.25 · 1.26 · 1.27 · 1.28