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,拿它本地队列的一半

无锁优化:

本地队列是无锁的,只有偷取的时候才用原子操作,减少竞争