Go 的并发优势很大程度上来自 用户态调度器(Goroutine Scheduler)。它不依赖 OS 线程创建大量轻量级任务,通过 G/M/P模型 和智能调度策略保证高吞吐、低延迟。
一、为什么需要 Go 调度器?
操作系统的线程有两个天然缺陷
-
创建成本高:一个线程大概要1MB栈内存,创建和切换开销大
-
调度不可控:操作系统调度器不了解你的程序逻辑
而Go的调度器能做到:
-
协程创建成本极低(首次栈2KB)
-
可以几百万级创建
-
全用户态调度
-
明确知道哪些协程可能阻塞、哪些需要抢占、哪些必须让出CPU
二、G / M / P 模型详解
Go调度器用三个核心实体表示执行模型
| 名称 | 全称 | 职责 |
| G | Goroutine | 任务运行的最小执行单元 |
| M | Machine | 与系统线程一一对应 |
| P | Processor | 运行G的逻辑处理器 |
1. G - Goroutine
-
状态:idle、runnable、running、waiting、dead
-
轻量:默认2KB栈,根据需要增长和收缩
-
被调度器分配到P本地队列中,等待M执行
2. M - Machine (操作系统线程)
-
每个M对应一个真实操作系统线程
-
M必须绑定一个P才能执行G
-
如果G执行涉及syscall阻塞,M会被卡住,P会被移交给其他M
3. P - Processor (逻辑处理器)
-
控制执行G的各项资源(例如运行队列)
-
P的数量定义为GOMAXPROCS
-
每个P持有一个本地run queue,也就是运行队列
-
M只有只有P才能执行G
-
P是调度系统的灵魂:没有P,M就像没有CPU的线程,只能一直等。
三、G 的生命周期(敲黑板)
new
->
->
running
->
->
->
->
最重要的两个状态转换:
3.1 running -> waiting : G会从P的队列里消失,即标记为waiting状态
3.1.1 channel里没有数据/不能发送--等别人
<-ch //阻塞
ch <- v // 阻塞
3.1.2 mutex已经被别人锁住--等锁
Lock
//锁住
3.1.3 IO要等系统返回--等网络/磁盘
Read
// netpoll等待系统通知
3.1.4 明确休眠--等事件
time
.Sleep
3.2 waiting -> runnable :把G唤醒
3.2.1 channel有数据了/能发送了
-
channel 接收方等到发送者了
-
channel 发送方等到接收者了
把等待的G放回队列,状态改为runnable
3.2.2 mutex解锁了
mu.Unlock()
这个时候,调度器会查看有没有goroutine因为这个锁阻塞,有的话就唤醒它并返回队列
3.2.3 netpoll:网络IO就绪了
当epoll/kqueue发现socket可以读/写
内核就通知Go运行时,运行时把对应的G标记为runnable,并放入可运行队列里
3.2.4 timer到点了
time.Sleep结束后,timer管理器发现时间到了,把对应的G唤醒,重新放到P的可运行队列里
四、调度循环(抢工作+ 本地队列优先)
Go的调度器使用组合策略,核心要点:
4.1 优先从本地P队列取G
4.2 本地队列空了就去抢任务,从其他P抢一半goroutine
4.3 新建G优先放入本地队列
4.4 syscall/unblock G,可能丢到全局队列
4.5 定期检查全局队列,只在需要的时候取任务
整体思路:尽可能在本地消化任务,没有任务就去抢,确保所有P都不会闲着。
五、调度触发点(什么时候会调度?)
Go调度不是随时都切换,调度仅发生在特定时机:
5.1 主动让出
Gosched
//告诉调度器,我先不跑了,你切出去吧
5.2 阻塞操作
一旦发送阻塞,就会触发调度,调度器会把协程变成waiting
5.3 函数调用边界
Go会在函数调用入口插入检查点,当一个G运行太久,调度器就会发抢占信号,在下一个函数调用点检查到信号,就会自动让出
5.4 GC安全点
GC需要STW或扫描所有栈,这也会触发抢占。
六、调度器的图示说明(ASCII)
┌──────────┐
│ Global │
│ RunQueue │
└─────┬────┘
│
┌──────────┼──────────┐
▼ ▼ ▼
┌─────┐ ┌─────┐ ┌─────┐
│ P0 │ │ P1 │ │ P2 │ ... (P = GOMAXPROCS)
└─┬───┘ └──┬──┘ └──┬──┘
│ │ │
LocalRQ LocalRQ LocalRQ
│ │ │
▼ ▼ ▼
┌───┐ ┌───┐ ┌───┐
│ M │ │ M │ │ M │ (OS Thread)
└─▲─┘ └─▲─┘ └─▲─┘
│ │ │
└─────────┴──────────┘
Syscalls, Block, Wakeup
七、经典问题解析
7.1 Go能否用多线程跑同一个goroutine?
答:不能,每个G在任意时刻只能在一个M上运行。调度点可以迁移,但不会并行执行。
7.2 Go会频繁切换goroutine吗?
答:不会,Go的调度是协作式+抢占式混合;多数切换发生在阻塞点,少量切换在函数入口抢占点。
7.3 为什么设置GOMAXPROCS大了反而变慢?
答:因为P=并发能力。P增多就会导致更多队列、更多抢任务、更多GC、更多竞争。一半设置为CPU核心数是最佳。
Goroutine 调度器就像一个巨大餐厅:P 是厨房,M 是厨师,G 是订单。厨师想做菜必须占到厨房;订单在每个厨房的本地栏里排队,没订单的厨房可以跑去别的厨房抢订单,保证每个厨房都不闲着。
*源码地址*
1、公众号“Codee君”回复“每日一Go”获取源码
如果您喜欢这篇文章,请您(点赞、分享、亮爱心),万分感谢!