参考资料:
goroutine基本模型和调度设计策略
协程

1:1的方式依旧解决不了下面问题:
- 线程创建成本高
- 内存占用大
- 切换昂贵
而 1:N 导致一个阻塞,全体阻塞,而后Go采用了 M:N 的方式
通过调度器,将多个协程分配给系统线程,即采用 GMP 的调度模型
GMP 调度模型
GMP 模型是 Go 并发的核心灵魂,首先我们要了解他们是什么东西?有什么作用?
GMP 其全称如下:
- G Goroutine
- M Machine(线程)
- P Processor(逻辑处理器)
Goroutine延伸自coroutine,代表协程任务,一个协程其内部保存了协程栈,执行状态,指令位置(PC),当前函数信息。我们可以将它理解为待执行任务
Machine实际上是代表操作系统的线程(内核级),由操作系统管理
Processor是协程调度器,他管理着协程队列,连接了G和M,提供运行上下文
在早期的Go中,由于没有P,M:N 关系并不稳固高效,协程的执行都需要加锁,如下图所示

- 创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争。
- M转移G会造成延迟和额外的系统负载。
- 系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。
此时我们引入了Processor解决上面的问题

调度流程运行如下:
- 第一步:创建 goroutine
runtime 创建很多 G 对象。 - 第二步:放入本地队列
每个 P 都有本地队列:
P1 -> [G1 G2 G3]P2 -> [G4 G5]
- 第三步:M 执行
线程 M 从 P 中取 goroutine 执行。 - 第四步:工作窃取(work stealing)
如果:
P1 很忙P2 空闲
P2 会偷 P1 的任务。这是 Go 高性能的重要原因。
ps:全局队列什么时候用:
- 本地队列默认满了之后
- 某些新任务直接进入全局,避免局部不均衡
实现协程状态检测的原理是Go 通过特殊线程 sysmon 监控线程检测
- 检测长时间运行 goroutine
- 检测阻塞线程
- 协助 GC
- 协助抢占
协程与线程的对比:
| 对比 | goroutine | OS线程 |
|---|---|---|
| 创建成本 | 极低 | 很高 |
| 内存占用 | 几 KB | 几 MB |
| 调度者 | Go runtime | 操作系统 |
| 切换成本 | 很低 | 很高 |
| 数量 | 可几十万 | 一般几千 |
调度器设计策略
- 复用线程(work stealing,hand off)
- 利用并行(GOMAXPROCS限定P的个数 = CPU核数/2)
- 抢占
- 全局G队列
work stealing:
当P1队列正在执行G1,此时P1队列中存在G2,G3,而M2目前没有执行任务,此时P2会从P2中偷取一个协程
hand off:
如果M1的正在执行G1,M2执行G3。一旦G1阻塞了,运行时会创建/唤醒一个threadM3,将P1放到M3执行,至于P1直接由M1执行
抢占策略:
Go 的协程调度采用的是「协作式 + 抢占式」混合模型:早期 goroutine 主要依赖 主动让出 CPU(如 channel 阻塞、IO、sleep、函数调用等)进行调度,但从 Go1.14 开始引入了异步抢占机制,runtime 会通过 sysmon 监控运行时间过长的 goroutine(通常超过约 10ms),并向对应线程发送 signal,在 safe point(安全点)强制暂停 当前 goroutine,把 CPU 让给其他协程执行,从而避免死循环或 CPU 密集任务长期霸占线程,解决 goroutine 饿死问题,同时降低 GC 延迟并提高调度公平性。
全局队列:当P2队列没有协程任务的时候优先 Work Stealing ,如果没了就从全局队列取(需要加锁)
实战
创建 Goroutine
package main
import (
"fmt"
"time"
)
func newTask() {
i := 0
for {
i++
fmt.Printf("New Goroutine:i=%d\n", i)
time.Sleep(1 * time.Second)
}
}
func main() {
// 创建一个协程执行newTask
go newTask()
i := 0
for {
i++
fmt.Printf("main Goroutine:i=%d\n", i)
time.Sleep(1 * time.Second)
}
}
上面是协程创建的一个用例,下面我将结合这个用例进一步讲解
代码中有两个协程:newTask和main
这时候你可能会想,欸 我有两个协程,为了CPU充分利用起来,我是不是应该将他分配到两个线程上 ?
当然具体还要看 P 的数量,两个 Goroutine ≠ 必然两个线程,两个 Goroutine 可以被调度到一个或多个线程上执行,是否并行取决于 GOMAXPROCS、调度器和运行时状态
- 如果
GOMAXPROCS = 1,两个 Goroutine 会并发切换执行,但同一时刻通常只有一个在跑。 - 如果
GOMAXPROCS >= 2,它们有可能分别跑在不同的 M,也就是不同 OS 线程上,从而并行执行。
当 GOMAXPROCS = 2 时,新创建的 newTask goroutine 通常会优先放到当前 P 的本地运行队列,也就是 main goroutine 正在使用的那个 P。
P1 本地队列: main goroutine, newTask goroutine
P2 本地队列: 空
之后如果 P2 下面的 M 没活干,它可能会通过 work stealing 从 P1 偷取一部分 goroutine 过去执行。
所以最终变成:
P1 -> main goroutine
P2 -> newTask goroutine
如果此时来了一个 newTask2 goroutine,但是我 GOMAXPROCS = 2,怎么办?
这就要回顾之前GMP调度流程,GOMAXPROCS = 2 代表同一时刻最多 2 个 P 在执行 Go 代码
现如今P1和P2分别正在执行main和newTask,newTask2只好优先放入某个队列中
P1 本地队列
P2 本地队列
全局运行队列
等待被调度,当某个正在goroutine发生调度点,调度器就换下 main 让 newTask2 上去执行。
而这个被调度的条件可以是下面几种情况
- time.Sleep
- IO 阻塞
- channel 阻塞
- syscall
- 等锁
就好比收银台P1正在服务main顾客,收银收到一半,顾客main说我忘记买一个东西了,就被收银员叫走了,然后顾客newTask2过来被P1服务
等顾客main拿完东西了,就会重新跑到等待被继续服务的队列,轮到她时从刚刚中断的地方继续
退出当前协程
// 在当前协程的任意作用域内都可以使用
runtime.Goexit()
得到协程执行结果
Go 获取 goroutine 执行结果,最常见的方法是:
- channel(最核心)
- sync.WaitGroup(等待结束)
- context(控制取消)
- errgroup(并发任务管理)
WaitGroup + channel:
多个 goroutine 并发
↓
结果写入 channel
↓
WaitGroup 等待全部结束
↓
关闭 channel
↓
统一读取结果

评论(0)
暂无评论