golang调度原理
# 一.golang 调度器
多线程/多进程操作系统
并发执行进程/线程时
进程/线程数量越多,切换成本就越大
多线程伴随着同步竞争(锁、资源冲突等)
存在问题:
高内存占用
调度的高消耗CPU(上下文切换)
后来人们发现一个线程可被分为用户态和内核态线程
用户态线程对CPU透明
一个用户态线程绑定内核态线程(Linux 的 PCB 进程控制块),这样用户态任务可以切换,而CPU执行的线程不变,减少了切换线程带来的开销
约定:
内核线程 = 线程
用户线程 = 协程
线程由CPU调度,是抢占式的
协程由用户态调度,是协作式的(一个协程让出CPU后,才执行下一个协程)
goroutine由runtime(go协程调度器)进行管理,而不是操作系统
一个/多个协程可绑定在一个/多个线程上
3种协程和线程的关系
- N:1关系——N个协程绑定1个线程
优点:
协程在用户态线程即完成切换,不会陷入到内核态,切换轻量快速
缺点:
一个进程的所有协程都绑定在一个线程上,一旦某个协程阻塞,协程调度器没有切换协程的能力时会造成线程阻塞,本进程的其他协程都无法执行了,失去并发能力
- 1:1关系——1个协程绑定1个线程
优点:
实现简单,协程的调度由CPU完成,不存在N:1的缺点
缺点:
协程的创建、删除和切换的代价都由CPU完成,略显昂贵
- M:N关系——M个协程绑定1个线程
优点:
克服了前两种的缺点
缺点:
实现起来最复杂
# 二.golang对协程的处理
# 协程和goroutine关系
Go 为了提供更容易使用的并发方法,使用了 goroutine 和 channel。
goroutine
来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被runtime调度
# Go的GMP调度模型
GMP是Go运行时调度层面的实现,负责在适当实际将合适的协程分配到合适的位置,保证公平和效率
包含4个重要结构,分别是G、M、P、Sched
全局队列
存放等待运行的G
P的本地队列
存放等待运行的G
数量限制,不能超过256G
优先将新创建的G放在P的本地队列中
G 协程
是Goroutine的缩写
相当于操作系统的进程控制块(process control block)。
它包含:函数执行的指令和参数,任务对象,线程上下文切换,字段保护,和字段的寄存器。
M 线程
每个M都有一个线程的栈。如果没有给线程的栈分配内存,操作系统会给线程的栈分配默认的内存。当线程的栈制定,M.stack->G.stack, M的PC寄存器会执行G提供的函数。
P (处理器,Processor)
包含运行goroutine的资源
如果线程想运行goroutine,必须先获取P。P中还包含了可运行的G队列。
优先将新创建的G放在P的本地队列中,如果满了会放在全局队列中
P 和 M 何时会被创建
P 何时创建:
在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P。
M 何时创建:
没有足够的 M 来关联 P 并运行其中的可运行的 G。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。
P和M的个数 P的个数——在同一时刻的P个数,而不是宏观的并发
程序启动时创建
最多有GOMAXPROCS个
配置方法:
环境变量$GOMAXPROCS个(可配置)
在程序中通过runtime.GOMAXPROCS()来设置
M的个数——动态数量
动态的,一旦有一个M阻塞,就会创建一个新的M
如果有M空闲,就会回收/睡眠
Go语言本身,限定了M的最大量是10000(忽略)
通过runtime/debug包中的SetMaxThreads函数来设置
# 调度器的设计策略
(一)复用线程 避免频繁的创建、销毁线程,而是对线程的复用
work stealing机制
当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程
hand off机制
当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行
(二)利用并行 eg.GOMAXPROCS限定P的个数,最多有GOMAXPROCS个线程分布在多个CPU上同时运行
(三)抢占 之前:coroutine,要等待另一个协程主动释放CPU才执行下一个协程
现在:一个goroutine最多占用CPU10ms时间片轮询),防止其他goroutine被饿死(抢占)
(四)全局G队列 基于work stealing机制的补充,当M从其他P偷不到G时,他可以从全局G队列中获取G
# go func()调度流程
go func()
创建一个G
1G优先放到当前线程持有的P的本地队列中;
2如果已经满了,则放入全局队列中
1M通过P获取G;(一个M必须持有一个P——1:1)
2如果M的本地队列为空,从全局队列获取G;
3(work stealing机制)如果也为空,则从其他的MP组合偷取G
调度
执行func()函数
5.1 超出时间片后返回P的本地队列
5.2 若G.func()发生systemCall/阻塞
5.2.1 runtime(即调度器)会把这个M从P中摘除,(hand off机制)创建一个M或从休眠队列中取一个空闲的M,接管正在被阻塞中的P
5.2.2 M系统调用(阻塞)结束时,G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态,加入到空闲线程中,然后这个G会被放入全局队列中
6.销毁G
7.返回
# 调度器的生命周期
M0和G0
M0(进程唯一)
启动程序后的编号为0的主线程
在全局变量runtime.m0中,不需要在heap上分配
负责执行初始化操作和启动第一个G
启动第一个G后,M0就和其他的一样了
G0(线程唯一)
每启动一个M,都会第一个创建G0,每个M都会有一个自己的G0
G0不指向任何可执行的函数
G0仅用于负责调度G,在调度/系统调用时,M会切换到G0,调度其他G
M0的G0会放在全局空间
package main
import "fmt"
func main() {
fmt.Println("Hello world")
}
2
3
4
5
6
7
- runtime 创建最初的线程 m0 和 goroutine g0,并把 2 者关联。
- 调度器初始化:初始化 m0、栈、垃圾回收,以及创建和初始化由 GOMAXPROCS 个 P 构成的 P 列表。
- 示例代码中的 main 函数是 main.main,runtime 中也有 1 个 main 函数 ——runtime.main,代码经过编译后,runtime.main 会调用 main.main,程序启动时会为 runtime.main 创建 goroutine,称它为 main goroutine 吧,然后把 main goroutine 加入到 P 的本地队列。
- 启动 m0,m0 已经绑定了 P,会从 P 的本地队列获取 G,获取到 main goroutine。
- G 拥有栈,M 根据 G 中的栈信息和调度信息设置运行环境
- M 运行 G
- G 退出,再次回到 M 获取可运行的 G,这样重复下去,直到 main.main 退出,runtime.main 执行 Defer 和 Panic 处理,或调用 runtime.exit 退出程序。
调度器的生命周期几乎占满了一个 Go 程序的一生,runtime.main 的 goroutine 执行之前都是为调度器做准备工作,runtime.main 的 goroutine 运行,才是调度器的真正开始,直到 runtime.main 结束而结束。