Skip to content

GMP 调度与内存模型

Go 并发代码能写得很短,但面试和线上问题经常问到更底层的问题:goroutine 到底怎么跑到 CPU 上,为什么阻塞 IO 不一定阻塞整个进程,为什么并发读写变量会有数据竞争,为什么加锁和 channel 能保证可见性。

这一章把两个核心问题讲清楚:

  1. GMP 调度模型:goroutine 如何被 Go runtime 调度执行。
  2. Go 内存模型:多个 goroutine 之间读写变量时,什么时候能保证可见性和顺序。

学习目标

学完本章你应该能回答:

  1. G、M、P 分别是什么。
  2. goroutine 从创建到运行经历了什么。
  3. 为什么 GOMAXPROCS 影响同时执行 Go 代码的并行度。
  4. 本地队列、全局队列、work stealing 解决什么问题。
  5. sysmon、抢占、网络轮询器大概做什么。
  6. 数据竞争为什么危险。
  7. Mutex、channel、atomic 为什么能建立同步关系。
  8. 线上 goroutine 数暴涨、CPU 高、锁竞争应该怎么查。

为什么需要 GMP

如果每创建一个 goroutine 就创建一个系统线程,Go 就失去了轻量并发的优势。系统线程创建和切换成本高,数量多了以后操作系统调度压力会很大。

Go 的思路是:由 runtime 自己管理大量 goroutine,再把它们调度到少量系统线程上执行。

mermaid
flowchart TD
    A["大量 G: goroutine"] --> B["P: 可运行队列和调度上下文"]
    B --> C["M: OS Thread"]
    C --> D["CPU Core"]

G、M、P 分别是什么

名称全称可以理解为主要职责
Ggoroutine一个待执行任务保存函数、栈、状态、调度信息
Mmachine系统线程真正被操作系统调度到 CPU 上执行
Pprocessor调度上下文持有本地运行队列,提供执行 Go 代码所需资源

关键关系:

  1. G 不是线程,它只是 Go runtime 的任务。
  2. M 是真实系统线程。
  3. P 决定同时有多少 M 可以执行 Go 代码。
  4. GOMAXPROCS 控制 P 的数量,默认通常等于可用 CPU 核数。

goroutine 创建到运行流程

当你写:

go
go doWork()

runtime 大概会做这些事:

  1. 创建一个 G,记录要执行的函数、参数、栈等信息。
  2. 把 G 放入当前 P 的本地运行队列。
  3. 当前 M 执行完手头任务后,从 P 的运行队列取出 G。
  4. M 运行这个 G。
  5. G 阻塞、让出、完成或被抢占时,调度器再切换到其他 G。
mermaid
flowchart TD
    A["go doWork()"] --> B["创建 G"]
    B --> C["放入当前 P 的本地队列"]
    C --> D["M 从 P 队列取 G"]
    D --> E["执行 goroutine"]
    E --> F{"完成、阻塞、让出、抢占"}
    F --> G["调度下一个 G"]

本地队列和全局队列

每个 P 都有自己的本地运行队列。这样做的原因是减少所有线程抢同一个全局队列的锁竞争。

mermaid
flowchart TD
    A["P1 本地队列"] --> B["M1 执行"]
    C["P2 本地队列"] --> D["M2 执行"]
    E["全局队列"] --> A
    E --> C

为什么还需要全局队列:

  1. 本地队列满时,一部分 G 会放到全局队列。
  2. 某些场景下创建的 G 会进入全局队列。
  3. 调度器会定期从全局队列取任务,避免全局队列饥饿。

work stealing

如果某个 P 的本地队列空了,而其他 P 队列里还有很多 G,空闲的 P 会尝试从其他 P 偷取一部分任务来执行。

mermaid
flowchart TD
    A["P1 队列很多 G"] --> B["P2 队列为空"]
    B --> C["P2 从 P1 偷取部分 G"]
    C --> D["两个 P 都有任务执行"]

这样做的好处:

  1. 提高 CPU 利用率。
  2. 避免任务集中在某一个 P 上。
  3. 减少全局锁竞争。

不这样会怎样:

  1. 有的线程忙死,有的线程空闲。
  2. 整体吞吐下降。
  3. 某些 goroutine 等很久才执行。

阻塞时会发生什么

goroutine 可能阻塞在很多地方:

  1. channel 发送或接收。
  2. Mutex 锁。
  3. 网络 IO。
  4. 文件 IO。
  5. 系统调用。
  6. sleep 或 timer。

不同阻塞对调度影响不同。

channel、锁、timer 等 runtime 可感知阻塞

runtime 知道当前 G 暂时不能运行,就把它挂起,让 M 去执行其他可运行 G。

mermaid
flowchart TD
    A["G1 阻塞在 channel"] --> B["runtime 挂起 G1"]
    B --> C["M 继续执行 G2"]
    D["channel 就绪"] --> E["G1 变为可运行"]

网络 IO

Go 网络库集成了网络轮询器。网络读写等待时,G 可以被挂起,M 不需要一直傻等。

商业意义:

  1. 大量 HTTP 请求、TCP 连接、RPC 调用可以用较少线程承载。
  2. 这也是 Go 适合高并发网络服务的重要原因。
  3. 但下游慢仍然会占用 goroutine、连接池、内存,所以必须设置 timeout。

系统调用

如果 M 进入可能阻塞的系统调用,runtime 会把 P 从这个 M 上分离出来,交给其他 M 继续执行 Go 代码。这样一个线程阻塞不至于拖住整个 P。

mermaid
flowchart TD
    A["M1 执行 G1"] --> B["G1 进入阻塞 syscall"]
    B --> C["P 与 M1 分离"]
    C --> D["P 绑定 M2"]
    D --> E["M2 继续执行其他 G"]

抢占和 sysmon

早期 Go 版本里,如果某个 goroutine 长时间执行纯计算且不发生函数调用,可能让其他 goroutine 等很久。后续 Go 不断改进抢占能力,让长时间运行的 goroutine 更容易被暂停,把执行机会让给别人。

sysmon 可以理解为 runtime 的后台监控线程之一,它会做一些监控和协助调度相关的工作,例如:

  1. 发现长时间运行的 G,触发抢占。
  2. 处理网络轮询相关唤醒。
  3. 处理 timer。
  4. 发现阻塞 syscall 后协助调度。

不用背实现细节,但要理解工程后果:

  1. CPU 密集任务仍然会抢 CPU,需要控制并发。
  2. 不要以为 goroutine 多就一定快。
  3. 长循环里适当检查 context,有助于快速停止。

GOMAXPROCS

GOMAXPROCS 控制同时执行 Go 代码的 P 的数量。

查看:

go
fmt.Println(runtime.GOMAXPROCS(0))

设置:

go
runtime.GOMAXPROCS(4)

一般不建议业务代码随意设置它。容器环境里更要关注 CPU limit,现代 Go 版本已经逐渐优化对容器 CPU 的感知,但实际生产仍要结合压测和监控判断。

容易误解的点:

  1. GOMAXPROCS=4 不代表只能有 4 个 goroutine。
  2. 它代表同时并行执行 Go 代码的 P 数量大致为 4。
  3. 阻塞 syscall、cgo、网络 IO 等场景下,M 的数量可能超过 P。
  4. IO 密集服务的 goroutine 数通常远大于 CPU 核数,但真正同时跑 CPU 的有限。

Go 内存模型解决什么问题

多个 goroutine 并发读写同一变量,如果没有同步,结果是不可靠的。

错误示例:

go
package main

import (
    "fmt"
    "sync"
)

func main() {
    done := false
    var wg sync.WaitGroup

    wg.Add(1)
    go func() {
        defer wg.Done()
        done = true
    }()

    for !done {
    }

    wg.Wait()
    fmt.Println("finished")
}

这段代码有数据竞争。你不能认为一个 goroutine 写了 done = true,另一个 goroutine 就一定能及时、正确地看到。

原因包括:

  1. 编译器可能优化读写。
  2. CPU 有缓存和乱序执行。
  3. 不同 goroutine 在不同线程上执行。
  4. 没有同步关系时,可见性和顺序没有保证。

happens-before

Go 内存模型里的核心概念是 happens-before。可以粗略理解为:如果 A happens-before B,那么 B 能看到 A 在这个同步关系之前完成的写入。

常见能建立同步关系的方式:

  1. Mutex.Unlock happens-before 后续成功的 Mutex.Lock
  2. channel 发送 happens-before 对应的接收。
  3. channel close happens-before 接收方观察到关闭。
  4. sync.Once 保证初始化只执行一次,并对后续调用可见。
  5. atomic 操作提供原子性和相应的内存顺序保证。
  6. goroutine 启动前的写入,对新 goroutine 可见。

Mutex 为什么能保证可见性

正确示例:

go
package main

import (
    "fmt"
    "sync"
)

func main() {
    var mu sync.Mutex
    done := false

    go func() {
        mu.Lock()
        done = true
        mu.Unlock()
    }()

    for {
        mu.Lock()
        ok := done
        mu.Unlock()
        if ok {
            break
        }
    }

    fmt.Println("finished")
}

这里写 goroutine 在 Unlock 前写入 done=true,读 goroutine 后续 Lock 成功后能看到同步前的写入。

不建议这样忙等,真实代码更应该用 channel 或 context 表达完成信号。

channel 为什么能同步

用 channel 表达完成信号:

go
package main

import "fmt"

func main() {
    done := make(chan struct{})
    result := ""

    go func() {
        result = "ok"
        close(done)
    }()

    <-done
    fmt.Println(result)
}

close(done) 对接收方 <-done 可见,所以主 goroutine 读取 result 时可以看到关闭前的写入。

这比共享变量忙等更清晰:

  1. 不浪费 CPU。
  2. 同步关系明确。
  3. 代码意图是“等完成”,而不是“反复读一个变量”。

atomic 适合什么

atomic 适合简单计数、开关状态、指标值,不适合复杂业务对象。

go
package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

func main() {
    var count int64
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            atomic.AddInt64(&count, 1)
        }()
    }

    wg.Wait()
    fmt.Println(count)
}

不要用多个 atomic 字段拼复杂状态。例如一个订单对象有状态、金额、时间、版本,这些字段之间有一致性要求,用锁或单 goroutine 串行处理更清楚。

常见误区

误区一:goroutine 多就一定快

不一定。CPU 密集任务开太多 goroutine 会争抢 CPU;IO 密集任务开太多 goroutine 会打爆下游连接池和限流。

误区二:读 bool 不加锁也没事

不对。只要一个 goroutine 写,另一个 goroutine 读,并且没有同步,就可能数据竞争。用 go test -race ./... 能发现很多此类问题。

误区三:map 并发读写概率低就可以

不可以。Go 原生 map 并发写可能直接 panic:fatal error: concurrent map writes。读写共享 map 要加锁、用 sync.Map 或通过 channel 串行化。

误区四:channel 一定比锁高级

不是。channel 适合表达任务流、事件通知和同步信号;锁适合保护共享状态。为了“Go 风格”强行用 channel,反而会让代码绕。

生产排查

goroutine 数暴涨

排查流程:

mermaid
flowchart TD
    A["goroutine 数持续上涨"] --> B["抓 goroutine pprof"]
    B --> C{"主要阻塞点"}
    C --> D["chan send / receive"]
    C --> E["Mutex.Lock"]
    C --> F["net/http 或 RPC"]
    C --> G["database/sql"]
    D --> H["检查 channel 关闭、接收方、context"]
    E --> I["检查锁范围、死锁、热点资源"]
    F --> J["检查超时、连接池、下游慢"]
    G --> K["检查慢 SQL、事务、连接池"]

命令:

bash
go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine

CPU 高

可能原因:

  1. 死循环或 select default 空转。
  2. CPU 密集任务并发过高。
  3. JSON、压缩、加密、正则等计算过重。
  4. 锁竞争导致大量 goroutine 反复抢锁。

排查:

bash
go tool pprof http://127.0.0.1:6060/debug/pprof/profile

锁竞争

如果大量 goroutine 卡在 sync.Mutex.Lock

  1. 看锁保护的临界区是否太大。
  2. 是否在持锁期间调用外部接口、数据库、磁盘 IO。
  3. 是否把全局大锁拆成分片锁。
  4. 是否可以把热点读改为只读快照或 RWMutex

商业场景:高并发接口聚合

一个资产详情接口需要查询用户、资产、订单、权限、统计。每个查询都开 goroutine 看起来很快,但没有边界会出问题。

正确设计:

  1. 入口 context 设置总超时,例如 800ms。
  2. 使用 errgroup.WithContext 并发查询强依赖。
  3. 弱依赖允许降级。
  4. 每个下游 client 设置连接池和 timeout。
  5. 通过指标观察 goroutine 数、下游耗时、错误率。

商业场景:采集 Agent

采集 Agent 常常同时读文件、调接口、解析、上报。GMP 让它可以用少量线程承载很多 IO 等待,但业务仍然要做并发控制。

落地建议:

  1. 读取阶段限速。
  2. 解析阶段使用固定 worker pool。
  3. 上报阶段设置 HTTP timeout 和重试上限。
  4. 停止服务时通过 context 通知所有 goroutine 退出。
  5. pprof 暴露 goroutine 和 CPU profile。

面试标准回答

GMP 是什么

text
GMP 是 Go runtime 的调度模型。G 代表 goroutine,是待执行任务;M 代表系统线程,是真正被操作系统调度执行的线程;P 代表调度上下文,持有本地运行队列并提供执行 Go 代码所需资源。runtime 通过 P 把大量 G 调度到较少的 M 上执行,从而降低创建和切换线程的成本。

GOMAXPROCS 控制什么

text
GOMAXPROCS 控制 P 的数量,也就是同时并行执行 Go 代码的调度上下文数量。它不限制 goroutine 总数,也不一定等于系统线程数。IO 阻塞、系统调用等情况下 M 的数量可能超过 P,但真正同时跑 Go 代码的并行度主要由 P 决定。

work stealing 解决什么问题

text
每个 P 都有本地运行队列。如果某个 P 队列为空,而其他 P 队列里有很多 goroutine,空闲 P 会从其他 P 偷取一部分任务执行。这样可以提高 CPU 利用率,避免某些 P 忙、某些 P 闲,也减少所有任务都争抢全局队列的锁竞争。

Go 内存模型解决什么问题

text
Go 内存模型说明多个 goroutine 读写共享变量时,什么同步操作能保证可见性和顺序。如果没有同步,读写共享变量会产生数据竞争,结果不可预测。Mutex、channel、atomic、sync.Once 等都能建立同步关系,让一个 goroutine 的写入对另一个 goroutine 可见。

为什么 channel close 后能作为通知

text
channel close 会和接收方观察到关闭建立同步关系。一个 goroutine 在 close 前完成的写入,另一个 goroutine 在接收到关闭信号后可以看到。因此 close 一个 chan struct{} 经常用于广播完成或停止信号。

关联知识点

本章小结

GMP 解释的是 goroutine 怎么被调度到 CPU 上,内存模型解释的是 goroutine 之间共享数据什么时候可靠。生产级 Go 并发代码不能只会创建 goroutine,还要理解调度边界、同步关系、数据竞争和排查方法。