go的核心优势是goroutine,但是系统无法调用goroutine,所以需要go自己实现一个用户态调度器
解决了两个问题:
线程太重:创建/销毁耗资源,切换耗cpu,单机最多几千个
M:N映射效率:如何把N个goroutine高效搬运到M个内核线程上运行,且避免阻塞浪费cpu
核心思想:
解耦内核线程雨goroutien,用逻辑处理器p做桥梁
限制同时运行的M的数量,减少内核切换开销
用本地队列+工作窃取实现无锁调度,提升并发性能
这里补充一点:java对不同场景任务有不同的线程策略,io密集的需要设置线程池线程数量为2n,因为io阻塞严重,需要很多空余线程来填满cpu。cpu密集的设置n+1,因为基本不涉及空余cpu,很少有线程阻塞。但是go不需要考虑这种情况,线程默认数量就是cpu数量,gmp实现线程不会因为阻塞阻塞,cpu也不会空闲
核心三大组件
G:Goroutine(协程,任务本身)
本质:用户态轻量级执行单位,初始栈仅为2kb,可动态扩缩
核心存储内容:
- 栈信息(栈顶,栈底指针)
- 程序计数器:记录当前执行到哪行代码
- 状态:_Gidle(空闲)、_Grunnable(就绪,等待运行)、_Grunning(运行中)、_Gsyscall(系统调用中)、_Gwaiting(阻塞,如 channel / 锁等待);
- 绑定的m和p
作用:真正被调度的任务,代码逻辑的载体
M:Machine(内核线程,执行者)
本质:对操作系统内核线程的封装,由os调度
核心存储内容:
- 绑定的p:只有绑定的p才能执行g
- 正在运行的g
- 线程栈信息,寄存器状态
作用:真正在cpu上执行g代码的实体
P:Procrssor(逻辑处理器,调度核心)
本质:go层面的逻辑处理器
核心存储内容:
- 本地g队列L无锁环形队列,默认容量为256,存放待运行的g,优先使用本地队列,减少竞争
- 绑定的M
- 内存缓存:加速小对象分配
- 调度状态,定时器等
核心作用:
- 限制并发度:p的数量默认等于cpu核心数
- 提供执行环境:管理本地G队列,内存缓存,让m专注执行
- 实现无锁调度:本地队列无锁,只有偷取的时候才会使用全局锁,大幅提高性能
GMP完整调度流程
场景一:正常执行(从创建到运行)
程序启动:
- 初始化runtime,创建默认大小个P放进全局P池
- 创建主线程M0,绑定第一个P
- main函数被包装成第一个G,放进P的本地队列
G被调度执行:
- M从绑定的P的本地队列取G(优先本地队列,无锁快)
- 如果本地队列为空,取全局队列取(全局队列有锁,一次取一批)
- M执行G的代码(直到G主动让出阻塞或者执行完)
G执行完:
- G的状态变为_Gidle,放入G池(复用,避免重复创建)
- M继续从P的队列取下一个G,循环往复
场景二:系统调用阻塞(文件io,网络io前的syscall)
当G执行系统调用的时候会触发以下逻辑:
G进入_Gsyscall状态,M释放P
P被移交,绑定新M,因为系统阻塞M,M进入sleep,需要新M来占用cpu执行P
系统调用返回:
原来的M尝试重新获取P:
优先找之前绑定的P
没找到的话就把G放进全局队列,自己进入空闲M池
场景三:channel/锁阻塞(如<-ch, sync.Mutex等待)
当G因channel或者锁阻塞时:
G进入_Gwatting状态,从M上脱钩
M继续执行,从P队列上取下一个G运行
阻塞唤醒:
当条件满足时,阻塞的G被唤醒,状态变为_Grunnable
唤醒的G会被放进某个P的本地队列
场景四:工作窃取
当一个P的本地队列空了,会取偷其他P的G,保证cpu不空闲:
窃取顺序:
- 先看自己的本地队列(空了):
- 在看全局队列(加锁取一批):
- 再看网络轮询器(netpoller,处理就绪的网络I/O G):
- 最后随机选一个其他的P,拿它本地队列的一半
无锁优化:
本地队列是无锁的,只有偷取的时候才用原子操作,减少竞争