
Golang 的 **GMP** 是 Go 运行时调度器的核心模型。

它解决的问题可以简单理解成：

> 你的 Go 程序里可能有成千上万个 goroutine，但电脑真正能同时执行代码的 CPU 核心数量是有限的。  
> GMP 就是 Go 用来管理、分配、调度这些 goroutine 的一套系统。

先给你一个非常粗暴但好理解的类比：

你可以把 Go 程序想象成一家餐厅。

| 概念 | 类比 | 作用 |
|---|---|---|
| G | 顾客的订单 | 一个 goroutine，要被执行的任务 |
| M | 厨师 | 操作系统线程，真正干活的人 |
| P | 灶台 / 工位 | 执行 Go 代码所需的资源和调度队列 |

一个厨师 `M` 想做菜，必须站在一个灶台 `P` 前面，然后从订单队列里拿一个订单 `G` 来处理。

---

# 一、为什么 Go 需要 GMP？

我们先从最简单的并发说起。

在 Go 里面，你可以这样创建一个 goroutine：

```go
go func() {
    fmt.Println("hello")
}()
```

这行代码看起来很轻松，但是背后 Go 运行时需要考虑很多问题：

1. 这个 goroutine 谁来执行？
2. 如果同时创建 10 万个 goroutine，系统线程够不够？
3. 某个 goroutine 阻塞了，比如读文件、网络等待，其他 goroutine 会不会被卡死？
4. 怎么利用多核 CPU？
5. 怎么降低线程切换成本？

如果完全交给操作系统线程来处理，那么每创建一个 goroutine 就创建一个系统线程，这会非常重。

因为操作系统线程有几个问题：

第一，创建成本高。

第二，占用内存大。传统线程栈可能是 MB 级别，而 goroutine 初始栈很小，通常只有几 KB。

第三，线程上下文切换成本高。

第四，系统线程数量太多，操作系统调度压力会很大。

所以 Go 的设计思路是：

> 不让每个 goroutine 都对应一个操作系统线程，而是由 Go runtime 自己调度 goroutine。

这就引出了 GMP。

---

# 二、GMP 分别是什么？

GMP 是三个英文单词的缩写：

```text
G = Goroutine
M = Machine
P = Processor
```

注意，这里的 `Processor` 不是 CPU，而是 Go runtime 里的一个调度资源。

---

# 三、G：Goroutine 是什么？

`G` 代表一个 goroutine。

你写的：

```go
go test()
```

Go runtime 内部就会创建一个 `G` 对象。

这个 `G` 里面会记录很多信息，比如：

```text
这个 goroutine 要执行哪个函数
当前执行到哪里了
栈在哪里
状态是什么
是否被阻塞
是否等待 channel
是否等待锁
```

你可以把 `G` 理解成：

> 一个等待被执行的任务。

比如：

```go
func main() {
    go task1()
    go task2()
    go task3()
}
```

这里大概就会有多个 G：

```text
G1: main goroutine
G2: task1
G3: task2
G4: task3
```

但是 G 本身不会自己运行。

它只是“任务”。

真正运行代码的是 `M`。

---
# 四、M：Machine 是什么？
`M` 代表操作系统线程。
它是真正干活的执行者。
CPU 实际执行的是线程，而不是 goroutine。

Go 的 goroutine 最终也必须被某个操作系统线程执行。

所以 `M` 可以理解成：

> Go runtime 管理下的操作系统线程。

比如你的电脑有 8 个 CPU 核心，Go 程序可能会创建一些 M。

每个 M 底层对应一个真实的 OS thread。

但是问题来了：

M 能不能直接执行 G？

早期 Go 调度器确实比较接近这种模型，但是后来发现不够高效。

于是 Go 引入了 `P`。

---

# 五、P：Processor 是什么？

`P` 是 GMP 里最容易让新手迷惑的概念。

你一定要记住：

> P 不是 CPU。  
> P 是 Go runtime 里的“逻辑处理器”，可以理解成 goroutine 调度所需的上下文资源。

M 想执行 G，必须先绑定一个 P。

也就是说：

```text
M + P + G
```

凑齐以后，一个 goroutine 才能真正运行。

可以理解为：

```text
G = 任务
M = 干活的线程
P = 允许干活的资格证 + 本地任务队列
```

或者用餐厅类比：

```text
G = 订单
M = 厨师
P = 灶台
```

厨师再厉害，没有灶台也做不了菜。

---

# 六、G、M、P 三者的关系

最核心的一句话：

> M 必须持有 P，才能执行 G。

结构大概是这样：

```text
          G1
          G2
          G3
          G4
           ↓
      P 的本地队列
           ↓
        M 绑定 P
           ↓
        M 执行 G
```

完整一点：

```text
P1 本地队列: G1, G2, G3
P2 本地队列: G4, G5, G6

M1 绑定 P1，执行 G1
M2 绑定 P2，执行 G4
```

如果你的 `GOMAXPROCS = 2`，通常最多同时有 2 个 P 在执行 Go 代码。

也就意味着，最多有 2 个 M 同时执行 Go 代码。

---

# 七、GOMAXPROCS 是什么？

`GOMAXPROCS` 决定了 P 的数量。

比如：

```go
runtime.GOMAXPROCS(4)
```

表示 Go runtime 同时最多允许 4 个 P 执行 Go 代码。
也就是说，最多有 4 个 M 可以同时执行 goroutine。
默认情况下，现代 Go 会把 `GOMAXPROCS` 设置为可用 CPU 核心数。
你可以理解为：

```text
GOMAXPROCS = P 的数量
```

如果你的电脑是 8 核，那么通常：

```text
P = 8 个
```

这时候 Go 可以并行执行最多 8 个 goroutine。

注意，这里说的是“真正同时执行”。

如果你开了 10 万个 goroutine，不代表 10 万个 goroutine 同时在 CPU 上跑。

而是：

```text
大量 G 排队
少量 M 拿着 P 执行它们
```

---

# 八、一个 goroutine 是怎么被调度的？

假设你写了：

```go
go func() {
    fmt.Println("hello")
}()
```

大概会发生这些事情：

第一步，Go runtime 创建一个新的 G。

```text
G_new = 这个匿名函数
```

第二步，把这个 G 放进某个 P 的本地队列。

```text
P1 local queue: G_new
```

第三步，某个 M 绑定 P1。

```text
M1 <-> P1
```

第四步，M1 从 P1 的队列里取出 G_new。

第五步，M1 执行这个 G。

```text
M1 执行 G_new
```

第六步，G 执行完毕，退出。

---

# 九、本地队列和全局队列

Go 调度器里有两类重要队列：

```text
P 的本地队列
全局队列
```

每个 P 都有自己的本地队列。

比如：

```text
P1 local queue: G1, G2, G3
P2 local queue: G4, G5
P3 local queue: G6, G7
```

还有一个全局队列：

```text
global queue: G8, G9
```

一般情况下，新创建的 goroutine 会优先放到当前 P 的本地队列里。

这样做的好处是：

> 减少锁竞争，提高调度效率。

因为如果所有 goroutine 都放进一个全局队列，那么多个线程都要去抢同一个队列，锁竞争会很严重。

所以 Go 使用了“每个 P 一个本地队列”的设计。

---

# 十、如果某个 P 没活干了怎么办？

假设现在是这样：

```text
P1 local queue: G1, G2, G3, G4, G5
P2 local queue: 空
```

P2 没有 goroutine 可执行了。

那绑定 P2 的 M 就会尝试去“偷活”。

这叫：

```text
work stealing
```

也就是工作窃取。

P2 可能会从 P1 的队列里偷一部分 G 过来。

变成：

```text
P1 local queue: G1, G2, G3
P2 local queue: G4, G5
```

这样可以让多个 P 的负载更均衡。

所以 Go 的调度器不是简单地从一个大队列取任务，而是：

```text
本地队列优先
没有任务就去全局队列找
还没有就去别的 P 偷
```

大致顺序可以理解为：

```text
1. 先看当前 P 的本地队列
2. 再看全局队列
3. 再去其他 P 偷任务
4. 再检查网络轮询器里是否有可运行的 G
```

---

# 十一、G 的状态有哪些？

一个 goroutine 不只是“运行”和“不运行”两种状态。

它可能有很多状态。

常见的有：

```text
_Grunnable：可运行，正在等待被调度
_Grunning：正在运行
_Gwaiting：等待中，比如等 channel、锁、定时器、网络 IO
_Gsyscall：正在系统调用
_Gdead：已结束
```

对新手来说，重点理解这几个就够了：

## 1. runnable：可运行

表示这个 G 已经准备好了，只是在排队。

比如：

```text
G 已创建
G 没有阻塞
G 等着某个 M 来执行
```

## 2. running：正在运行

表示这个 G 当前正在某个 M 上执行。

```text
M1 正在执行 G1
```

## 3. waiting：等待中

比如 goroutine 在等待 channel：

```go
ch := make(chan int)

go func() {
    x := <-ch
    fmt.Println(x)
}()
```

这个 goroutine 会阻塞在：

```go
x := <-ch
```

它进入 waiting 状态。

直到有人往 channel 里发送数据：

```go
ch <- 100
```

它才会重新变成 runnable。

## 4. syscall：系统调用中

比如：

```go
os.Open("a.txt")
```

或者某些阻塞系统调用。

这时候 G 可能会进入 syscall 状态。

---

# 十二、阻塞时 GMP 怎么处理？

这是 GMP 最精彩的地方。

假设现在：

```text
M1 绑定 P1
M1 正在执行 G1
```

也就是：

```text
M1 + P1 + G1
```

然后 G1 发起了一个阻塞系统调用。

比如读文件、某些 syscall。

这时候如果 M1 一直卡住，P1 也跟着卡住，那 P1 队列里的其他 G 就无法执行了。

这显然不好。

所以 Go runtime 会尽量这样处理：

```text
G1 跟着 M1 去阻塞
P1 从 M1 身上解绑
P1 去找其他空闲 M
其他 M 绑定 P1，继续执行别的 G
```

也就是说：

```text
阻塞的是 M 和 G
P 会被释放出来
```

这就像：

```text
厨师 M1 突然要去仓库等食材
他不能占着灶台 P1 不动
所以他离开灶台
另一个厨师 M2 过来接着用这个灶台做别的订单
```

这样 Go 就可以避免一个阻塞操作拖垮整个调度器。

---

# 十三、网络 IO 为什么可以高效？

Go 很适合写网络服务，比如 HTTP 服务、RPC 服务、WebSocket 服务。

一个重要原因就是 Go runtime 对网络 IO 做了特殊优化。

比如你写：

```go
conn.Read(buf)
```

看起来像是阻塞读取。

但底层 Go runtime 会尽量使用非阻塞 IO + 网络轮询器。

当一个 goroutine 等待网络数据时，它不会傻傻占着线程。

而是：

```text
G 进入等待状态
M 释放出来继续执行其他 G
等网络数据来了
runtime 把 G 重新放回可运行队列
```

这就是为什么 Go 能轻松处理大量连接。

例如一个 HTTP 服务器：

```go
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "hello")
})

http.ListenAndServe(":8080", nil)
```

每来一个请求，Go 可能会开一个 goroutine 处理。

如果有很多连接在等待网络数据，这些 goroutine 并不会每个都占一个线程。

它们多数时间只是挂起，等待网络事件。

---

# 十四、goroutine 和线程的区别

很多新手会问：

> goroutine 是不是线程？

不是。

goroutine 更像是 Go runtime 管理的轻量级协程。

| 对比 | goroutine | 操作系统线程 |
|---|---|---|
| 管理者 | Go runtime | 操作系统 |
| 创建成本 | 很低 | 较高 |
| 初始栈 | 很小 | 通常更大 |
| 调度者 | Go 调度器 | OS 调度器 |
| 数量 | 可以很多 | 不适合太多 |
| 切换成本 | 较低 | 较高 |

你可以简单理解：

```text
goroutine 是用户态的轻量任务
thread 是内核态管理的执行单元
```

但是 goroutine 最终还是要跑在线程上。

所以关系是：

```text
多个 G 复用少量 M
多个 M 运行在 CPU 上
```

---

# 十五、GMP 的核心流程图

可以这样理解：

```text
大量 goroutine
    ↓
很多 G
    ↓
分散到多个 P 的本地队列
    ↓
M 绑定 P
    ↓
M 从 P 队列取 G
    ↓
执行 G
    ↓
G 结束 / 阻塞 / 让出 / 被抢占
```

简化图：

```text
        Global Queue
             ↓
    ┌────────┬────────┬────────┐
    │   P1   │   P2   │   P3   │
    │ G G G  │ G G G  │ G G G  │
    └───┬────┴───┬────┴───┬────┘
        │        │        │
        M1       M2       M3
        │        │        │
       CPU      CPU      CPU
```

---

# 十六、为什么需要 P，不能只有 G 和 M？

这是一个很关键的问题。

早期如果只有 G 和 M，大概是：

```text
很多 G 放在一个全局队列里
很多 M 去抢这个全局队列
```

问题是：

```text
锁竞争严重
调度成本高
局部性差
```

想象一下：

```text
100 个厨师
所有人都去同一个窗口抢订单
```

这肯定混乱。

引入 P 之后变成：

```text
每个 P 有自己的本地队列
M 绑定 P 后优先执行本地 G
```

这样效率更高。

P 还有一些额外作用，比如管理：

```text
本地 goroutine 队列
内存分配缓存
定时器
调度上下文
```

所以 P 不只是一个队列，它是 Go runtime 执行 Go 代码所需的一组资源。

---

# 十七、GMP 和 CPU 核心的关系

很多人会误以为：

```text
P = CPU 核心
```

这个说法不完全对。

更准确是：

```text
P 的数量通常默认等于可用 CPU 核心数
但 P 不是 CPU
```

CPU 是硬件。

P 是 Go runtime 里的逻辑资源。

比如你可以手动设置：

```go
runtime.GOMAXPROCS(2)
```

即使你的电脑有 16 核，Go 同时执行 Go 代码的 P 也只有 2 个。

反过来，你也可以设置成 32，但如果机器只有 8 核，也不代表真的能有 32 个 CPU 核心同时执行。

所以：

```text
CPU 核心 = 硬件并行能力
P = Go runtime 允许并行执行 Go 代码的额度
M = 操作系统线程
G = goroutine 任务
```

---

# 十八、goroutine 是怎么让出 CPU 的？

goroutine 不可能一直霸占 CPU。

Go 调度器需要在不同 goroutine 之间切换。

常见切换时机有：

```text
goroutine 执行完毕
goroutine 阻塞在 channel
goroutine 阻塞在 mutex
goroutine 进行网络 IO
goroutine 进行系统调用
goroutine 被抢占
runtime 主动调度
```

比如：

```go
ch := make(chan int)

go func() {
    ch <- 1
}()

x := <-ch
fmt.Println(x)
```

当 goroutine 读写 channel 阻塞时，它会进入 waiting 状态，M 就可以去执行其他 G。

---

# 十九、什么是抢占式调度？

早期 Go 的调度更偏协作式。

也就是说，goroutine 需要在某些安全点主动让出。

但如果一个 goroutine 写了一个死循环：

```go
for {
}
```

它可能长时间占着 M，不让其他 goroutine 运行。

后来 Go 引入并增强了异步抢占。

也就是说 runtime 有能力在合适的时候强行打断一个运行太久的 goroutine，让其他 goroutine 有机会运行。

比如：

```go
func main() {
    go func() {
        for {
        }
    }()

    go func() {
        fmt.Println("hello")
    }()

    select {}
}
```

在现代 Go 中，调度器会尽量避免第一个 goroutine 永远霸占 CPU。

这就是抢占式调度的意义：

> 防止某个 goroutine 长时间占用 CPU，导致其他 goroutine 饿死。

---

# 二十、sysmon 是什么？

Go runtime 里还有一个很重要的后台监控线程，叫 `sysmon`。

它可以理解成“调度器巡逻员”。

它会做一些事情，比如：

```text
检查是否有 goroutine 长时间运行，需要抢占
检查网络轮询器
检查定时器
检查系统调用阻塞情况
触发垃圾回收相关工作
```

你可以把它想象成餐厅经理：

```text
哪个厨师卡住了？
哪个订单等太久了？
哪个灶台空出来了？
有没有新的外卖订单到了？
```

---

# 二十一、GMP 执行一个例子

看这个代码：

```go
package main

import (
    "fmt"
    "time"
)

func worker(id int) {
    for i := 0; i < 3; i++ {
        fmt.Println("worker", id, ":", i)
        time.Sleep(time.Second)
    }
}

func main() {
    for i := 0; i < 5; i++ {
        go worker(i)
    }

    time.Sleep(5 * time.Second)
}
```

这里创建了 5 个 goroutine：

```text
G1: main
G2: worker(0)
G3: worker(1)
G4: worker(2)
G5: worker(3)
G6: worker(4)
```

假设 `GOMAXPROCS = 2`，也就是有两个 P：

```text
P1
P2
```

可能执行过程是：

```text
M1 绑定 P1，执行 worker(0)
M2 绑定 P2，执行 worker(1)
```

当 worker 执行到：

```go
time.Sleep(time.Second)
```

这个 goroutine 会进入等待状态。

于是 M 可以去执行其他 goroutine：

```text
worker(0) 睡眠
worker(1) 睡眠

M1 去执行 worker(2)
M2 去执行 worker(3)
```

等 1 秒后，睡眠结束，runtime 会把对应的 G 放回可运行队列。

```text
worker(0) 重新变成 runnable
等待被调度
```

这就是为什么即使只有少量线程，也能运行很多 goroutine。

---

# 二十二、channel 阻塞时发生了什么？

比如：

```go
ch := make(chan int)

go func() {
    value := <-ch
    fmt.Println(value)
}()
```

这个 goroutine 尝试从 channel 读数据。

但是没人发送。

于是：

```text
当前 G 进入 waiting 状态
它不会继续占着 M
M 可以去执行别的 G
```

等后面有人：

```go
ch <- 123
```

Go runtime 会唤醒那个等待接收的 G。

它重新进入 runnable 状态。

然后某个 P 会把它排队，某个 M 之后会执行它。

---

# 二十三、mutex 阻塞时发生了什么？

比如：

```go
var mu sync.Mutex

mu.Lock()
go func() {
    mu.Lock()
    defer mu.Unlock()
}()
```

如果锁已经被占用，新的 goroutine 会阻塞。

此时它不会一直消耗 CPU 空转。

它会进入等待状态。

等锁释放后，runtime 会把它唤醒，重新加入可运行队列。

---

# 二十四、网络阻塞时发生了什么？

比如：

```go
conn.Read(buf)
```

对于网络连接，Go runtime 通常会把这个等待交给网络轮询器。

过程大概是：

```text
G 调用 Read
没有数据可读
G 被挂起
M 去执行别的 G
网络数据到来
netpoll 通知 runtime
G 被放回 runnable 队列
M 再次执行 G
```

所以大量网络连接不会等于大量系统线程。

这就是 Go 写高并发网络服务非常舒服的原因。

---

# 二十五、系统调用阻塞时发生了什么？

系统调用比较特殊。

比如某些文件 IO、阻塞 syscall。

假设：

```text
M1 + P1 正在执行 G1
G1 进入阻塞 syscall
```

Go runtime 会尽量：

```text
M1 带着 G1 进入 syscall
P1 被释放
P1 找其他 M 继续执行别的 G
```

等 syscall 返回后：

```text
G1 需要重新找 P
如果有空闲 P，继续执行
如果没有空闲 P，G1 进入 runnable 队列等待
```

所以核心思想还是：

> 不要让 P 被阻塞的 M 长时间占住。

---

# 二十六、为什么 goroutine 很轻量？

goroutine 轻量主要体现在几个方面。

## 1. 初始栈小

goroutine 的栈一开始很小，会按需增长。

普通系统线程的栈通常更大。

这意味着你可以创建很多 goroutine。

## 2. 调度在用户态

goroutine 的调度主要由 Go runtime 完成，不需要每次都陷入操作系统内核。

成本相对更低。

## 3. 复用线程

很多 goroutine 复用少量 OS 线程。

不是一个 goroutine 一个线程。

## 4. 阻塞处理优化

channel、timer、网络 IO、syscall 等场景，runtime 都做了大量处理。

---

# 二十七、GMP 和垃圾回收 GC 有关系吗？

有关系，但不是一回事。

GMP 是调度系统。

GC 是垃圾回收系统。

不过它们都属于 Go runtime，而且会相互配合。

比如 GC 运行时，需要让 goroutine 配合扫描栈、写屏障等。

Go runtime 需要知道每个 G 的栈在哪里、执行状态如何。

所以 G 的元数据对 GC 很重要。

但是你作为初学者可以先这样理解：

```text
GMP 管 goroutine 怎么跑
GC 管不用的内存怎么回收
```

---

# 二十八、常见误区

## 误区一：goroutine 就是线程

不对。

goroutine 是 Go runtime 管理的轻量任务。

线程是操作系统管理的执行单元。

关系是：

```text
多个 goroutine 复用多个线程
```

---

## 误区二：开多少 goroutine 就会有多少线程

不对。

比如你开 10 万个 goroutine，不会创建 10 万个系统线程。

Go 会用调度器把这些 G 分配给有限数量的 M 执行。

---

## 误区三：P 就是 CPU

不准确。

P 是 Go runtime 里的调度资源。

CPU 是硬件核心。

只是默认情况下，P 的数量通常和 CPU 可用核心数接近。

---

## 误区四：GOMAXPROCS 控制 goroutine 数量

不对。

`GOMAXPROCS` 控制的是 P 的数量，也就是同时执行 Go 代码的能力。

它不限制 goroutine 总数。

你设置：

```go
runtime.GOMAXPROCS(2)
```

不代表只能创建 2 个 goroutine。

它的意思是：

```text
最多同时有 2 个 P 执行 Go 代码
```

---

## 误区五：goroutine 越多越好

也不对。

goroutine 很轻量，但不是没有成本。

太多 goroutine 会带来：

```text
内存占用
调度开销
GC 压力
上下文切换增加
排查问题困难
```

如果 goroutine 都是在等待 IO，数量可以很多。

如果 goroutine 都是 CPU 密集计算，开太多反而没意义。

---

# 二十九、CPU 密集型和 IO 密集型的区别

这个对理解 GMP 很重要。

## CPU 密集型

比如：

```go
for i := 0; i < 1000000000; i++ {
    sum += i
}
```

这种任务主要消耗 CPU。

如果你的机器是 8 核，开 1000 个 CPU 密集 goroutine 不会让程序快 1000 倍。

因为 CPU 核心有限。

对于 CPU 密集任务：

```text
goroutine 数量接近 CPU 核心数比较合理
```

## IO 密集型

比如：

```go
http.Get(...)
db.Query(...)
conn.Read(...)
```

这种任务大量时间在等待网络、磁盘、数据库。

这时候 goroutine 可以开得多一些。

因为大部分 goroutine 在等待，不是真的占用 CPU。

Go 的 GMP 对 IO 密集型任务很友好。

---

# 三十、一个简单实验

你可以运行这个：

```go
package main

import (
    "fmt"
    "runtime"
    "time"
)

func main() {
    fmt.Println("CPU 核心数:", runtime.NumCPU())
    fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
    fmt.Println("当前 goroutine 数:", runtime.NumGoroutine())

    for i := 0; i < 10; i++ {
        go func(id int) {
            for {
                fmt.Println("goroutine:", id)
                time.Sleep(time.Second)
            }
        }(i)
    }

    time.Sleep(5 * time.Second)
    fmt.Println("当前 goroutine 数:", runtime.NumGoroutine())
}
```

这里：

```go
runtime.NumCPU()
```

查看 CPU 核心数。

```go
runtime.GOMAXPROCS(0)
```

查看当前 P 的数量。

```go
runtime.NumGoroutine()
```

查看当前 goroutine 数量。

---

# 三十一、再用一句话总结 GMP

最精简版：

```text
G 是 goroutine，代表任务。
M 是操作系统线程，负责真正执行。
P 是 Go 调度器的处理器，负责保存可运行队列和执行资源。
M 必须绑定 P，才能执行 G。
```

再形象一点：

```text
G = 要做的事情
M = 干活的人
P = 干活的工位
```

所以 Go 的调度过程就是：

```text
很多 G 排队
M 拿到 P
M 从 P 的队列里取出 G
M 执行 G
G 阻塞/结束/被抢占后
M 再执行下一个 G
```

---

# 三十二、你作为新手真正需要掌握到什么程度？

如果你是刚学 Go，不需要一开始就把 runtime 源码看懂。

你先掌握这几个点就够用了：

第一，goroutine 不是线程。

第二，goroutine 很轻量，但不是无限免费。

第三，Go 用 GMP 调度 goroutine。

第四，`GOMAXPROCS` 决定 P 的数量，影响并行度。

第五，阻塞 IO 不一定会阻塞整个线程，Go runtime 会帮你调度。

第六，CPU 密集型任务不能无脑开大量 goroutine。

第七，IO 密集型任务很适合用 goroutine。

第八，channel、mutex、sleep、network IO 都可能让 goroutine 进入等待状态，让 M 去执行别的 G。

---

你可以先记住这个最终模型：

```text
GMP = Go runtime 为了高效运行大量 goroutine，而设计的一套调度模型。

G：goroutine，任务
M：machine，操作系统线程
P：processor，调度上下文和本地队列

M 要执行 G，必须先绑定 P。
P 的数量由 GOMAXPROCS 控制。
每个 P 有自己的本地队列。
没活干时会从别的 P 偷任务。
goroutine 阻塞时，runtime 会尽量释放 M/P，让其他 goroutine 继续执行。
```

这就是 GMP 的核心。

---

