Concurrency Model: Goroutines and the GMP Scheduler
Module 3: Concurrency | Lesson: m03-l01 | Level: Intermediate After this lesson you will be able to: understand the design philosophy behind Go's lightweight goroutine, explain how the GMP scheduler works, and control the program's parallelism with GOMAXPROCS.
Learning Objectives
- Explain the difference between a goroutine and an operating-system thread, and describe what makes goroutines lightweight.
- Describe the roles of the three core components G, M and P and how they cooperate.
- Explain the work-stealing mechanism and the hand-off scenario in the GMP scheduler.
- Use
GOMAXPROCSto control concurrency, and understand the Go 1.22 loop-variable semantics change.
Core Concepts
1.1 Concurrency vs Parallelism: Goroutines' Lightweight Design
Concurrency is a program's ability to handle multiple tasks; parallelism is its ability to execute multiple tasks at the same time. Go achieves efficient concurrency through goroutines.
A goroutine is a lightweight thread managed by the Go runtime. Unlike an OS thread, a goroutine starts with an initial stack of only 2 KB and grows dynamically as needed. On 64-bit platforms the default stack limit for a single goroutine is 1 GB; on 32-bit platforms it is 250 MB. This design lets us easily create millions of goroutines without exhausting system memory.
Why this design? In the traditional thread model, creating a thread is expensive (it usually involves kernel-mode switches and large stack allocations). Go moves thread scheduling out of the kernel into user space (scheduled by the Go runtime), greatly lowering the cost of concurrency.
1.2 The GMP Scheduling Model
Go's runtime scheduler uses the GMP model to manage concurrent tasks. Understanding GMP is the key to mastering Go concurrency.
- G (Goroutine): represents a goroutine, including its execution state, stack and instruction pointer. It is the basic unit of scheduling.
- M (Machine): represents an OS thread. M is the actual carrier that executes Go code.
- P (Processor): represents a logical processor; think of it as an execution permit. An M must hold a P before it can execute Go code. The number of Ps is set by
GOMAXPROCS.
P's core roles:
1. Execution permit: an M can run Gs only after it binds a P. Therefore the number of Gs truly running in parallel at any moment never exceeds GOMAXPROCS.
2. Resource management: P holds a local run queue (lock-free, capacity 256) and runtime resources such as mcache.
3. Scheduling optimization: when a G makes a blocking system call (such as I/O or cgo), the M hands off the P (called hand off) so another M can take it over and keep running Gs from the queue — preventing the whole scheduler from being stuck on one system call. By contrast, user-space blocking such as channels or Mutexes only parks the G; the M does not give up the P.
Work stealing: When a P's local queue is empty, it tries to "steal" half of the Gs from another P's local queue, or takes Gs from the global queue. This load-balancing mechanism is called work stealing and effectively improves utilization of multi-core CPUs.
1.3 GOMAXPROCS and Parallelism
GOMAXPROCS sets the number of logical processors P, i.e. the maximum number of Ms that can execute Go code at the same time. The total number of Ms is not bounded by this (for example, many more Ms are created when they block on system calls, cgo or LockOSThread).
Since Go 1.5, GOMAXPROCS defaults to runtime.NumCPU(), so it usually needs no explicit setting. Set it explicitly only when the program or a dependency changed it, or when the deployment environment needs a CPU cap (such as a container CPU quota).
Note: GOMAXPROCS limits the number of Go code pieces running in parallel, not the total number of goroutines. A parked G does not occupy a P, so time.Sleep does not affect parallelism.
1.4 The Go 1.22 Loop-Variable Semantics Change
As of Go 1.22, a for loop (both the three-clause loop and the range loop) creates a fresh loop variable on each iteration. This means closures capture the new variable of each iteration rather than a shared old one. The change takes effect based on the go directive in the module's go.mod. In Go 1.21 and earlier, there was only one instance of the loop variable for the whole loop, and closures captured the same variable — a classic concurrency pitfall.
Example Code
Example 1: Goroutine Basics and Lifecycle
This example shows how to start goroutines, and that when the main goroutine exits, all goroutines are terminated.
package main
import (
"fmt"
"time"
)
func worker(id int) {
fmt.Printf("Worker %d started\n", id)
// simulate work
time.Sleep(100 * time.Millisecond)
fmt.Printf("Worker %d done\n", id)
}
func main() {
// start 3 goroutines
for i := 0; i < 3; i++ {
go worker(i)
}
// The main goroutine must wait; otherwise all goroutines are
// forcefully terminated when main exits. In real code, use sync.WaitGroup.
time.Sleep(200 * time.Millisecond)
fmt.Println("All tasks finished, exiting normally")
}
Expected output:
Worker 0 started
Worker 1 started
Worker 2 started
Worker 0 done
Worker 1 done
Worker 2 done
All tasks finished, exiting normally
(Note: the order in which workers start and finish may vary with scheduling, but every worker runs.)
Example 2: GOMAXPROCS and Parallelism in Practice
This example shows how GOMAXPROCS affects the parallelism of CPU-bound tasks. Note that it uses busy-wait CPU work rather than time.Sleep, because sleeping does not occupy a P.
package main
import (
"fmt"
"runtime"
"time"
)
// cpuWork simulates a CPU-bound task
func cpuWork(id int, duration time.Duration) {
start := time.Now()
// a simple CPU busy-wait to make sure a P is occupied
for i := 0; i < 100000000; i++ {
_ = i * i
}
elapsed := time.Since(start)
fmt.Printf("Task %d done (elapsed: %v, GOMAXPROCS: %d)\n", id, elapsed, runtime.GOMAXPROCS(0))
}
func main() {
// set GOMAXPROCS to 2
runtime.GOMAXPROCS(2)
fmt.Printf("Logical CPUs on this machine: %d, setting GOMAXPROCS: 2\n", runtime.NumCPU())
// start 4 CPU-bound tasks
for i := 0; i < 4; i++ {
go cpuWork(i, 0)
}
// wait for all tasks to finish.
// Without a WaitGroup we use a simple sleep here; in real code use WaitGroup.
time.Sleep(2 * time.Second)
}
Expected output:
Logical CPUs on this machine: 4, setting GOMAXPROCS: 2
Task 0 done (elapsed: 150ms, GOMAXPROCS: 2)
Task 1 done (elapsed: 150ms, GOMAXPROCS: 2)
Task 2 done (elapsed: 150ms, GOMAXPROCS: 2)
Task 3 done (elapsed: 150ms, GOMAXPROCS: 2)
(Note: the order tasks finish in is not fixed; elapsed time varies by machine, but total time is about ceil(4/2)=2 times the single-task time.)
Common Mistakes & Pitfalls
- Wrong assumption: a goroutine is just an OS thread
- Why: a goroutine is a user-space task managed by the Go runtime, and there are far more goroutines than OS threads.
-
Correct approach: understand the GMP model — goroutines are logical tasks; OS threads are the physical execution carriers.
-
Bad code: relying on
time.Sleepto wait for goroutines - Why:
time.Sleeptiming is hard to control precisely and can cause incomplete output or a hung program. -
Correct approach: synchronize with
sync.WaitGroupor channels. -
Bad code: closures capturing the loop variable in Go 1.21 and earlier
- Why: in Go 1.21 and earlier,
iinfor i := 0; i < 10; i++has a single instance shared by all goroutines, producing scrambled output. - Correct approach: this is fixed in Go 1.22. To support older versions, pass the variable in as a parameter:
go func(i int) { ... }(i).
Best Practices
- Wait for goroutines with
sync.WaitGroup: avoidtime.Sleepand make sure the program synchronizes correctly. - Set
GOMAXPROCSsensibly: the default is alreadyruntime.NumCPU(), so usually no setting is needed; set it explicitly only for container CPU limits or special cases. - Avoid capturing loop variables in goroutines: although Go 1.22 fixed this, explicit parameter passing is still clearer and safe across versions.
- Watch for goroutine leaks: make sure goroutines can exit normally and avoid resource leaks.
Exercises
- Concept question: briefly describe P's role in the GMP scheduling model, and explain why GOMAXPROCS limits parallelism rather than the number of goroutines.
- Coding exercise: write a program that starts 10 goroutines, each printing its own number (0–9), and uses
sync.WaitGroupso the main goroutine waits for all of them before exiting. Output must include every goroutine's number, in any order. - Advanced/debugging exercise: the following code may not produce the expected output when run on Go 1.21 and earlier. Identify the problem and give fixed code that prints 0 through 9 exactly once each (order not guaranteed) on all versions. ```go package main
import ( "fmt" "time" )
func main() { for i := 0; i < 10; i++ { go func() { fmt.Println(i) }() } time.Sleep(1 * time.Second) } ```
Reference Answers
Reference Answers (click to expand / collapse)
Question 1
P is the execution permit; an M must hold a P to execute Go code. GOMAXPROCS sets the number of Ps, so it limits the number of Ms that can run Go code at once, i.e. the parallelism. The number of goroutines can far exceed GOMAXPROCS, because they timeshare on the same M.
Question 2
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println(id)
}(i)
}
wg.Wait()
}
Explanation: sync.WaitGroup waits for all goroutines to finish. Passing i as a parameter ensures each goroutine captures its own number.
Question 3
Problem analysis: in Go 1.21 and earlier, i in the for loop has a single instance shared by all goroutines. When a goroutine runs fmt.Println(i), i may already have become 10, so the output is all 10s or scrambled.
Fixed code:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
// pass i as a parameter so each goroutine captures its own copy
go func(id int) {
defer wg.Done()
fmt.Println(id)
}(i)
}
wg.Wait()
}
Explanation: by explicitly passing func(id int), each goroutine gets its own copy of id. In Go 1.22 and later, i is a fresh variable each iteration even without passing it in, but parameter passing remains the cross-version-safe best practice. sync.WaitGroup also ensures complete output.
Lesson Summary
- A goroutine is a lightweight concurrency primitive: initial stack 2 KB, growing dynamically.
- In the GMP model, P is the execution permit, M is the OS thread, and G is the goroutine.
GOMAXPROCScontrols parallelism and defaults toruntime.NumCPU().- As of Go 1.22, each
foriteration creates a fresh variable, solving the classic loop-variable closure pitfall.
Next lesson preview: channels — the safe way for goroutines to communicate, including buffering, closing and direction.