# Go 调度器辣么聪明,怎么还是把你代码写慢了?
> 没错,Go 调度器确实牛逼,但不代表你可以随便浪。
---
## 开篇先问一个问题
你的 Go 程序有没有遇到过以下情况:
- 明明开了几百个 goroutine,结果 CPU 利用率还是上不去?
- 明明代码写得挺简洁,结果延迟忽高忽低跟过山车似的?
- 每次上线都心里没底,不知道哪儿会突然卡一下?
如果全中,那这篇文章就是写给你的。
很多人觉得 Go 调度器牛逼,我就随便写,goroutine 随便开,反正好歹有个调度器帮我兜底。兄弟,调度器确实牛逼,但你要是这么玩,它也救不了你。
今天咱们就深入聊聊 Go 调度器的 GMP 模型,看看它到底是怎么工作的,以及——**怎么样写代码才能不让调度器背锅**。
---
## GMP 到底是啥?
G、M、P 分别是三个核心概念:
- **G (Goroutine)**:你写的 `go func()`,就是这玩意儿。一个 G 就是一个轻量级用户态线程。
- **M (Machine)**:真正干活的操作系统线程。内核线程你懂得, 创建成本高。
- **P (Processor)**:调度上下文。你可以理解为"工作许可证",只有持有 P 的 M 才能从本地队列里拉 G 来执行。
三者关系是这样的:
```
M + P = 可以在 CPU 上跑
G 必须绑定到 M+P 才能执行
P 有一个本地 G 队列
```
这是 Go 调度器的核心设计,也是它能实现几百万并发而不炸的根本原因。
---
## 调度器的工作流程到底是怎样的?
很多人以为 goroutine 是直接扔到一个大池子里然后谁有空谁拿——大错特错。
Go 的调度是**分层级的**:
### 第一层:本地队列
每个 P 都有自己的本地队列,里面存着一堆 G。数量限制是多少?**256 个**,这是硬编码。所以如果你一个劲地往一个 P 的队列里塞,超过 256 个就得分流到全局队列了。
### 第二层:全局队列
所有 P 共用一个全局队列。这是个链表实现的,理论上无限长。但是!**从全局队列拿 G 要加锁**,所以 Go 调度器的策略是:**尽量从本地队列拿,本地空了才去全局队列偷**。
### 第三层:work stealing(工作窃取)
这是最骚的操作。当一个 P 的本地队列空了,它不会坐着等,而是去别的 P 的队列里偷!偷一半!这种设计保证了**即使负载不均衡,整体 CPU 利用率也能维持在一个较高的水平**。
这就好比公司里有个摸鱼的,隔壁同事看不过去就把他的活抢过来自己干——当然,调度器比同事道德一点,它是被动偷的。
---
## 那些年我们踩过的调度器坑
### 坑一:goroutine 开太多,结果全堵在队列里
**症状**:CPU 利用率低,延迟高,代码里到处是 `go func()`。
**原因**:goroutine 不是越多越好。每个 G 虽然只有几 KB,但架不住量太大。本地队列 256 个上限,全局队列虽然是无限的但访问要锁——当 G 数量爆炸的时候,调度开销会变成瓶颈。
**解决方案**:
- 用 **worker pool** 或 **semaphore** 限制并发数
- 案例:如果你要做 10000 个 HTTP 请求,别傻愣愣地一次性 `go` 出去,用一个带缓冲的 channel 或者现成的 worker pool 库(比如 `tunny`、`gocron` 里的 worker)
```go
// 错误示范
for _, url := range urls {
go fetch(url) // 10000个goroutine同时飞,调度器哭给你看
}
// 正确示范
sem := make(chan struct{}, 100) // 最多100并发
for _, url := range urls {
sem <- struct{}{}
go func(u string) {
defer func() { <-sem }()
fetch(u)
}(url)
}
```
### 坑二:channel 乱用,导致 G 永久阻塞
**症状**:程序看起来在跑,但某部分逻辑完全不执行,CPU 也不高。
**原因**:channel 如果没缓冲又没人接收,发送方会**永久阻塞**。这种 G 会被调度器标记为 **Gwaiting**,除非有人来 channel 另一端,否则它永远不会被唤醒。
**解决方案**:
- 用 buffered channel
- 用 `select` + `default` 做非阻塞发送
- 用 `context` + `timeout`
```go
// 错误示范
ch := make(chan int)
go func() { ch <- 1 }()
// 如果没人接收,这里永远阻塞
// 正确示范
ch := make(chan int, 1) // 缓冲
// 或者
select {
case ch <- 1:
default:
// 队列满了,走这儿
}
```
### 坑三:把调度器想得太聪明
**症状**:代码里有大量同步操作,比如 `time.Sleep()`、`mutex.Lock()`、`sync.WaitGroup.Wait()`,然后抱怨调度器没分配时间片。
**实际情况**:调度器确实会在这些阻塞操作发生时有 M 去执行其他 G,但是!**阻塞式系统调用会让 M 一起挂**。比如你调用一个同步的数据库操作,整个 M 都会卡住,调度器只能等这个系统调用返回才能重新调度。
**解决方案**:
- 用 `net.Conn` 的异步模式(`SetReadDeadline` + 非阻塞 IO)
- 用 `context` 控制超时,别让 G 无限等
- 数据库操作用连接池 + 异步 client
---
## 进阶:怎么写出"调度器友好"的代码?
### 原则一:减少协程间的依赖
goroutine 之间依赖越少,调度器越容易并行。如果每个 G 都要等另一个 G,那开再多也没用。
### 原则二:让 work stealing 生效
如果你必须用大量 goroutine,记得**打散**它们。尽量让每个 P 的队列都有活干,别让某个 P 特别闲或者特别忙。
### 原则三:用 profiling 工具说话
别猜,跑 `go tool pprof` 和 `trace`!看看你的程序到底卡在哪儿。调度器是不是瓶颈,一目了然。
```bash
# 启动trace
go test -trace=trace.out ./...
go tool trace trace.out
# 或者生产环境
import _ "net/http/pprof"
```
---
## 最后说几句
Go 调度器确实聪明,聪明到让很多人产生了幻觉——觉得自己随便写代码都没事。醒醒,它只是降低了并发编程的门槛,但不是让你站着把饭要了。
写 Go 代码,该考虑并发模型还是得考虑。该限制并发数还是得限制。该用异步的还是得用异步。
调度器是帮你省事的,不是帮你省脑子的。
**代码写得好,调度器跟着跑。代码写得烂,调度器也救不了。**
共勉。
---
*本文配套代码示例已上传 GitHub,有兴趣的可以自己跑跑 trace 玩。*