Go 协程模型完全指南

从 GMP 调度模型到 Channel 通信,深入理解 Go 并发的核心原理

01什么是 Goroutine

Go 语言中的并发基本单位,比线程更轻量

初始栈大小
2 KB
OS 线程通常 1-8 MB,协程仅需 2KB,内存开销降低数百倍
切换成本
~100ns
OS 线程上下文切换需 1-10μs,协程切换快 10-100 倍
可创建数量
100万+
单机轻松创建百万级协程,OS 线程通常只能创建数千个

一句话理解

Goroutine 是 Go 运行时管理的轻量级线程。它不是操作系统的线程,而是由 Go Runtime 在用户态自行调度的执行单元。你可以把它想象成操作系统线程内部更小的"微线程"——创建和切换都极其廉价。

当你写 go func() 时,Go 会在当前 P 的本地队列里创建一个新的 G,等待被调度执行。整个过程不需要操作系统介入,这也是 Go 并发性能强大的根源。

// 创建一个 goroutine 只需要一个 go 关键字
go func() {
    fmt.Println("我在另一个协程中运行")
}()

// 主协程
fmt.Println("我是主协程")

02GMP 调度模型

Go 并发调度的核心三要素

G - Goroutine
G
协程实例,包含栈、程序计数器、当前函数等信息。就是 go func() 创建出来的东西。
M - Machine
M
操作系统线程,是真正执行代码的载体。M 必须绑定一个 P 才能执行 G。
P - Processor
P
逻辑处理器,持有本地 G 队列。默认数量 = CPU 核数,可通过 GOMAXPROCS 修改。

结构关系图

下面的静态图展示了 GMP 三者的关系:每个 M 绑定一个 P,每个 P 持有一个本地 G 队列,全局还有一个 G 队列。

┌───────── Global G Queue ─────────┐ │ G₁ G₂ G₃ ... │ └──────────────────────────────────┘ ↑ 工作窃取 │ ┌────────────────────────────────────────────────────────────┐ │ M₁ (OS Thread) │ M₂ (OS Thread) │ │ ┌──── P₁ ────┐ │ ┌──── P₂ ────┐ │ │ │ Local Queue │ │ │ Local Queue │ │ │ │ G₄ G₅ G₆ │ ← ──→ │ │ G₇ G₈ G₉ │ │ │ └────────────┘ Work │ └────────────┘ │ │ ┌──────────┐ Steal │ ┌──────────┐ │ │ │ G(running)│ │ │ G(running)│ │ │ └──────────┘ │ └──────────┘ │ └────────────────────────────────────────────────────────────┘

03调度核心机制

理解 Go 是如何高效调度百万协程的

1. 队列模型:两级调度

本地队列 (LRQ)
P 私有
每个 P 持有最多 256 个 G。本地调度无锁,速度极快。
全局队列 (GRQ)
全局共享
所有 P 共享。新 G 优先放本地,满了才放全局。访问需要加锁。

2. 工作窃取 (Work Stealing)

当 P 的本地队列空了,它不会闲着。Go Runtime 会随机选择另一个 P,从它的本地队列尾部偷一半的 G 过来执行。这个过程叫做 Work Stealing,保证了负载均衡。

窃取策略:每次偷取 len(queue)/2 + 1 个 G,从尾部取走。随机选择目标 P 是为了避免所有空闲 P 同时去偷同一个 P,减少竞争。

3. Hand Off 机制

当 M 正在执行的 G 发生系统调用(如文件 I/O、网络请求)会阻塞时,P 不会跟着等待。P 会与这个 M 解绑,转而绑定到另一个空闲的 M(或创建新 M)继续执行其他 G。等原来的 M 从系统调用返回后,会尝试重新获取一个空闲 P。

这个机制确保了即使某个 G 在做阻塞操作,P 也不会被浪费,其他 G 照样能被调度执行。

4. 抢占式调度

Go 1.14 之后采用基于信号的异步抢占。运行时通过向 M 发送 SIGURG 信号,在安全的时刻(函数序言)强制让 G 让出 CPU。这防止了某个 G 长时间占用 CPU 而饿死其他协程。

抢占时机:每个 G 执行约 10ms 后,调度器会检查是否需要抢占。配合时间片轮转,保证公平调度。

04GMP 调度模拟器

亲手操作,观察协程是如何被创建、调度、窃取的

GMP 调度模拟器
M1 (OS Thread 1)
P1 - Local Queue
运行中:
M2 (OS Thread 2)
P2 - Local Queue
运行中:
Global Queue
[系统] GMP 调度模拟器已就绪,GOMAXPROCS=2

05Goroutine 生命周期

从创建到销毁,一个协程经历的完整过程

1

创建 (Creation)

调用 go func() 时,Runtime 创建一个新的 G,初始栈只有 2KB。G 被放入当前 P 的本地队列。如果本地队列满了(超过 256 个),则放一半到全局队列。

2

就绪 (Runnable)

G 在本地队列或全局队列中等待。调度循环中,P 会优先从本地队列取 G,如果本地队列为空,则从全局队列取(每次最多取 GOMAXPROCS 个),还不够就从其他 P 窃取。

3

运行 (Running)

P 从队列取出 G,放到 M 上执行。每个 G 运行约 10ms 后会被抢占检查。运行期间 G 的栈会按需动态伸缩(最小 2KB,最大可达 GB 级别,通常 1-8MB)。

4

等待 (Waiting)

G 执行 channel 操作、syscallselectmutextime.Sleep 等会进入等待状态。此时 G 会被从 P 的队列中移除,M 继续执行其他 G(Hand Off)。

5

唤醒 (Wake)

等待条件满足时(如 channel 有数据了、锁释放了、sleep 到时间了),G 被唤醒,放回某个 P 的本地队列继续等待执行。

6

结束 (Done)

函数执行完毕或 runtime.Goexit() 被调用时,G 被销毁。它的栈内存会被回收,资源被释放。

06Channel 通信

不要通过共享内存来通信,而应通过通信来共享内存

Channel 的本质

Channel 是 Go 实现协程间通信的核心机制。它本质上是一个线程安全的 FIFO 队列,内部使用互斥锁和等待队列来管理并发访问。发送方把数据放入队列,接收方从队列取出数据。

Goroutine A
→→→
Channel (缓冲区)
→→→
Goroutine B

三种 Channel

无缓冲
ch := make(chan int)
发送和接收必须同时准备好,否则阻塞。实现同步握手
有缓冲
ch := make(chan int, 10)
缓冲区未满时发送不阻塞,未空时接收不阻塞。实现异步解耦
单向
ch chan<- int
只发送或只接收。增强类型安全性,明确通信方向。
// Channel 基本用法
ch := make(chan int, 3)  // 缓冲大小 3

// 发送
ch <- 42

// 接收
val := <-ch

// 关闭 (不再发送)
close(ch)

// 遍历已关闭的 channel
for v := range ch {
    fmt.Println(v)
}

07核心代码模式

掌握这些模式,应对 90% 的并发场景

模式一:Fan-Out / Fan-In(扇出 / 扇入)

启动多个 worker 协程并行处理,然后汇聚结果。

func fanOutFanIn(jobs []int) []int {
    // Fan-out: 启动多个 worker
    numWorkers := 3
    jobsCh := make(chan int, len(jobs))
    resultsCh := make(chan int, len(jobs))

    for i := 0; i < numWorkers; i++ {
        go func() {
            for job := range jobsCh {
                resultsCh <- process(job) // 处理并发送结果
            }
        }()
    }

    // 分发任务
    for, j := range jobs { jobsCh <- j }
    close(jobsCh)

    // Fan-in: 收集结果
    var results []int
    for i := 0; i < len(jobs); i++ {
        results = append(results, <-resultsCh)
    }
    return results
}

模式二:Select 多路复用

同时监听多个 channel,哪个先就绪就处理哪个。

func handleWithTimeout(ch chan string) {
    timeout := time.After(3 * time.Second)

    select {
    case result := <-ch:
        fmt.Println("收到结果:", result)
    case <-timeout:
        fmt.Println("超时了!")
    case <-interrupt:
        fmt.Println("被中断")
    }
}

模式三:Context 控制协程树

用 Context 实现取消传播、超时控制、值传递。

func worker(ctx context.Context, id int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("worker %d 退出\n", id)
            return
        default:
            doWork(id)
            time.Sleep(time.Second)
        }
    }
}

// 带超时的 context,5秒后自动取消所有 worker
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

for i := 0; i < 10; i++ {
    go worker(ctx, i)
}

模式四:sync.WaitGroup 等待组

等待一组协程全部完成后再继续。

var wg sync.WaitGroup

for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        doWork(id)
    }(i)
}

wg.Wait()  // 阻塞直到所有协程完成
fmt.Println("所有任务完成!")

08Goroutine vs 其他并发模型

横向对比不同语言的并发方案

特性 Go Goroutine Java Thread Python Coroutine JS async/await
调度方式 Runtime M:N 调度 OS 1:1 调度 事件循环单线程 事件循环单线程
栈大小 2KB 起步,动态伸缩 固定 512KB-1MB 堆分配 调用栈
创建成本 极低 (~KB 级) 高 (~MB 级) 极低
切换成本 ~100ns (用户态) ~1-10μs (内核态) 极低
真并行 是 (多 M 并行) 否 (单线程) 否 (单线程)
通信方式 Channel (CSP) 共享内存 + 锁 await / Queue Promise
编程复杂度 高 (锁/死锁)

为什么 Go 的方案更好?

Go 选择了 CSP (Communicating Sequential Processes) 模型,鼓励通过 Channel 通信而非共享内存。这从根本上避免了数据竞争和死锁的大部分问题。配合 M:N 调度,你在写代码时可以随意创建协程而不用担心资源耗尽,这是 Go 在高并发场景下如此受欢迎的根本原因。

09FAQ

初学者最容易困惑的问题

Goroutine 和线程到底有什么区别?
线程由操作系统内核调度,切换需要从用户态陷入内核态,开销很大(1-10μs)。Goroutine 由 Go Runtime 在用户态调度,切换只需要保存少量寄存器和栈指针(~100ns)。一个 OS 线程可以运行多个 Goroutine(M:N 模型),Go 会在 OS 线程上自己决定运行哪个 G。简单说:G 是 Go 自己管的"轻量线程",M 是 OS 管的"重型线程",多个 G 复用少量 M。
GOMAXPROCS 应该设多少?
从 Go 1.5 开始默认值等于 CPU 核数。对于 CPU 密集型任务,保持默认值即可。对于 I/O 密集型任务(大量阻塞等待),可以适当调大,但通常不必要,因为 Hand Off 机制会让阻塞的 M 释放 P。大多数情况下不需要手动调整。
什么情况会导致 goroutine 泄露?
常见场景:1) 向已满的无缓冲 channel 发送但没接收者;2) 从 nil channel 接收(永远阻塞);3) 忘记 close channel 导致 range 死循环;4) select 中只有一个永远不会有数据的 case。预防方法:使用 context 控制生命周期,确保有超时或取消机制。
Channel 和共享内存+锁,该用哪个?
Go 的哲学是"通过通信来共享内存",推荐优先使用 Channel。但在某些场景下(如读多写少的缓存、精细的原子操作),使用 sync.Mutex 或 atomic 更高效。原则:能用 Channel 就用 Channel,需要极致性能时再用锁。
协程栈是怎么动态伸缩的?
初始 2KB,采用分段栈(分段式/连续式)技术。当函数调用导致栈空间不足时,Runtime 会分配更大的新栈(通常翻倍),把旧栈数据拷贝过去,然后释放旧栈。这个过程对开发者完全透明。最大可达约 1GB(64 位系统),但大部分协程栈在 8-64KB 之间。
runtime.Gosched() 是做什么的?
它主动让当前 G 让出 CPU,放回 P 的本地队列末尾,让其他 G 有机会运行。在写测试或需要显式让出执行权的场景有用。正常业务代码很少需要调用它,因为调度器会自动抢占(10ms 时间片)。