Lesson 16/41 · Concurrency Core m03-l01

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 GOMAXPROCS to 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.Sleep to wait for goroutines

  • Why: time.Sleep timing is hard to control precisely and can cause incomplete output or a hung program.
  • Correct approach: synchronize with sync.WaitGroup or channels.

  • Bad code: closures capturing the loop variable in Go 1.21 and earlier

  • Why: in Go 1.21 and earlier, i in for 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

  1. Wait for goroutines with sync.WaitGroup: avoid time.Sleep and make sure the program synchronizes correctly.
  2. Set GOMAXPROCS sensibly: the default is already runtime.NumCPU(), so usually no setting is needed; set it explicitly only for container CPU limits or special cases.
  3. Avoid capturing loop variables in goroutines: although Go 1.22 fixed this, explicit parameter passing is still clearer and safe across versions.
  4. Watch for goroutine leaks: make sure goroutines can exit normally and avoid resource leaks.

Exercises

  1. Concept question: briefly describe P's role in the GMP scheduling model, and explain why GOMAXPROCS limits parallelism rather than the number of goroutines.
  2. Coding exercise: write a program that starts 10 goroutines, each printing its own number (0–9), and uses sync.WaitGroup so the main goroutine waits for all of them before exiting. Output must include every goroutine's number, in any order.
  3. 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.
  • GOMAXPROCS controls parallelism and defaults to runtime.NumCPU().
  • As of Go 1.22, each for iteration 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.

Feedback

This site is front-end only — no backend, no accounts — so feedback goes to GitHub Issues, Discussions, or email.

Paste it into the issue or email to help track it down — it never includes anything you typed

Also:Browse existing issues · Email feedback (no GitHub account needed):278975598@qq.com