70 lines
6.5 KiB
Markdown
70 lines
6.5 KiB
Markdown
|
### Go goroutine调度
|
|||
|
- 目的:将 Goroutine 按照一定算法放到不同的操作系统线程中去执行
|
|||
|
- 调度模型与算法几经演化,从最初的 G-M 模型、到 G-P-M 模型,从不支持抢占,到支持协作式抢占,再到支持基于信号的异步抢占
|
|||
|
|
|||
|
### GM模型
|
|||
|
- 每个 Goroutine 对应于运行时中的一个抽象结构:G(Goroutine)
|
|||
|
- 被视作“物理 CPU”的操作系统线程,则被抽象为另外一个结构:M(machine)
|
|||
|
|
|||
|
#### GM不足
|
|||
|
- 单一全局互斥锁(Sched.Lock) 和集中状态存储的存在,导致所有 Goroutine 相关操作,比如创建、重新调度等,都要上锁
|
|||
|
- Goroutine 传递问题:M 经常在 M 之间传递“可运行”的 Goroutine,这导致调度延迟增大,也增加了额外的性能损耗
|
|||
|
- 每个 M 都做内存缓存,导致内存占用过高,数据局部性较差
|
|||
|
- 由于系统调用(syscall)而形成的频繁的工作线程阻塞和解除阻塞,导致额外的性能损耗
|
|||
|
> 集中状态(centralized state),就是一把全局锁要保护的数据太多。这样无论访问哪个数据,都要锁这把全局锁。数据局部性差是因为每个m都会缓存它执行的代码或数据,但是如果在m之间频繁传递goroutine,那么这种局部缓存的意义就没有了。无法实现局部缓存带来的性能提升。
|
|||
|
|
|||
|
|
|||
|
### GMP模型
|
|||
|
- 在 Go 1.1 版本中实现了 G-P-M 调度模型和work stealing 算法,这个模型一直沿用至今
|
|||
|
![](https://blog-heysq-1255479807.cos.ap-beijing.myqcloud.com/blog/wiki/go/gmp.png)
|
|||
|
|
|||
|
- P 是一个“逻辑 Proccessor”,每个 G(Goroutine)要想真正运行起来,首先需要被分配一个 P,也就是进入到 P 的本地运行队列(local runq)中
|
|||
|
- 对于 G 来说,P 就是运行它的“CPU”,可以说:在 G 的眼里只有 P
|
|||
|
- 从 Go 调度器的视角来看,真正的“CPU”是 M,只有将 P 和 M 绑定,才能让 P 的 runq 中的 G 真正运行起来
|
|||
|
|
|||
|
- Go 1.1 模型不支持抢占式调度
|
|||
|
- GO 1.2 加入抢占式调度,原理就是,Go 编译器在每个函数或方法的入口处加上了一段额外的代码 (runtime.morestack_noctxt),让运行时有机会在这段代码中检查是否需要执行抢占调度,弊端就是只有在函数或方法不能能加入代码,纯算法循环代码无法加入调度功能(比如死循环)
|
|||
|
- Go 1.14 版本中增加了对非协作的抢占式调度的支持,这种抢占式调度是基于系统信号的,也就是通过向线程发送信号的方式来抢占正在运行的 Goroutine
|
|||
|
> 协作式:大家都按事先定义好的规则来,比如:一个goroutine执行完后,退出,让出p,然后下一个goroutine被调度到p上运行。这样做的缺点就在于 是否让出p的决定权在groutine自身。一旦某个g不主动让出p或执行时间较长,那么后面的goroutine只能等着,没有方法让前者让出p,导致延迟甚至饿死。而非协作: 就是由runtime来决定一个goroutine运行多长时间,如果你不主动让出,对不起,我有手段可以抢占你,把你踢出去,让后面的goroutine进来运行
|
|||
|
### G
|
|||
|
- goroutine 缩写,每次go func()都代表一个G,无限制,而且 G 对象是可以重用的
|
|||
|
- 使用struct runtime.g,包含了当前goroutine的状态,堆栈和上下文
|
|||
|
|
|||
|
### M
|
|||
|
- 工作线程(OS thread)也被称为Machine,使用 struct runtime.m,所有M是有线程栈的(1-8M)
|
|||
|
- 执行流程是从 P 的本地运行队列以及全局队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复
|
|||
|
- 如果不对该线程栈提供内存的话,系统会给该线程栈提供内存(不同操作系统提供的线程栈大小不同)。当指定了线程栈,则 M.stack→G.stack,M 的 PC 寄存器指向 G 提供的函数,然后去执行
|
|||
|
|
|||
|
### P
|
|||
|
- Processor是一个抽象概念,并不是真正的物理CPU
|
|||
|
- 代表了M所需的上下文环境,也是处理用户级代码逻辑的处理器。负责衔接M和G的调度上下文,将等待执行的G和M进行连接。当P有任务是需要创建或者唤醒一个M来执行 它队列里的任务,所以P/M需要进行绑定,构成一个执行单元
|
|||
|
- P决定了并发任务的数量,通过runtime.GOMAXPROCS来设定,Go1.5之后被默认设置为可用核数,之前默认设置为1
|
|||
|
|
|||
|
### GMP优化部分
|
|||
|
- netpoller,即便 G 发起网络 I/O 操作,也不会导致 M 被阻塞(仅阻塞 G),也就不会导致大量线程(M)被创建出来
|
|||
|
- io poller,这个功能可以像 netpoller 那样,在 G 操作那些支持监听(pollable)的文件描述符时,仅会阻塞 G,而不会阻塞 M。不支持对常规文件的监听
|
|||
|
|
|||
|
### 协作抢占式监控线程 sysmon M
|
|||
|
- M 的特殊之处在于它不需要绑定 P 就可以运行(以 g0 这个 G 的形式)
|
|||
|
- sysmon 每 20us~10ms 启动一次
|
|||
|
- 释放闲置超过 5 分钟的 span 内存
|
|||
|
- 如果超过 2 分钟没有垃圾回收,强制执行
|
|||
|
- 将长时间未处理的 netpoll 结果添加到任务队列
|
|||
|
- 向长时间运行的 G 任务发出抢占调度
|
|||
|
- 收回因 syscall 长时间阻塞的 P
|
|||
|
|
|||
|
### sysmon M 抢占调度goroutine
|
|||
|
- 如果一个 G 任务运行 10ms,sysmon 就会认为它的运行时间太久而发出抢占式调度的请求
|
|||
|
- 一旦 G 的抢占标志位被设为 true,等到这个 G 下一次调用函数或方法时,运行时就可以将 G 抢占并移出运行状态,放入队列中,等待下一次被调度
|
|||
|
|
|||
|
### channel 阻塞或网络 I/O 情况下的调度
|
|||
|
- G 被阻塞在某个 channel 操作或网络 I/O 操作上时,G 会被放置到某个等待(wait)队列中,而 M 会尝试运行 P 的下一个可运行的 G
|
|||
|
- 如果这个时候 P 没有可运行的 G 供 M 运行,那么 M 将解绑 P,并进入挂起状态
|
|||
|
- 当 I/O 操作完成或 channel 操作完成,在等待队列中的 G 会被唤醒,标记为可运行(runnable),并被放入到某 P 的队列中,绑定一个 M 后继续执行
|
|||
|
|
|||
|
### 系统调用阻塞情况下的调度
|
|||
|
- G 被阻塞在某个系统调用(system call)上,不光 G 会阻塞,执行这个 G 的 M 也会解绑 P,与 G 一起进入挂起状态
|
|||
|
- 如果此时有空闲的 M,那么 P 就会和它绑定,并继续执行其他 G
|
|||
|
- 如果没有空闲的 M,但仍然有其他 G 要去执行,Go 运行时就会创建一个新 M(线程)
|
|||
|
- 当进行一些慢系统调用的时候,比如常规文件io,执行系统调用的m就要和g一起挂起,这是os的要求,不是go runtime的要求。毕竟真正执行代码的还是m
|