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,还要理解调度边界、同步关系、数据竞争和排查方法。