1. 底层实现
Linux对线程的实现包括两部分:用户态是C语言在运行时库glibc中,定义代表线程的struct pthread和创建线程的pthread_create; 另一部分是内核中的轻量级进程task_struct和clone系统调用。Go要在用户态定义他自己的线程对象和线程创建函数。栈大小起始2kb,在g结构体中可以扩容。传统线程8MB在整个线程生命周期内都不会改变。
g 结构体(Goroutine 描述符)定义在 src/runtime/runtime2.go 中,包括以下几个部分
- 管理 Goroutine 的执行上下文,定义当前栈的内存边界、栈指针和程序计数器。
- 与调度器
sched交互。抢占或者主动让出时,栈指针和程序计数器等寄存器值会被保存到sched这个“检查点”中。当它被再次调度时,调度器就从这个gobuf加载数据到 CPU 寄存器。 - Goroutine 标识和状态,
goid: Goroutine 的唯一 ID,类型为int64。atomicstatus: Goroutine 的当前状态,一个原子变量。 - ……太多了
2. GPM
① G代表着goroutine,P调度器资源,维护G的队列和运行上下文(比如栈、指令指针等),M Machine代表操作系统线程thread,是真正执行G的实体。
在GPM模型,有一个全局队列global存放等待运行的G;还有一个P的本地队列也是存放等待运行的G,但数量有限,不超过256个。
② GPM的调度流程从go func()开始创建一个goroutine,新建的goroutine优先保存在P的本地队列中,如果P的本地队列已经满了,则会保存到全局队列中。
③ M会1) 从P的队列中取一个可执行状态的G来执行(M持有一个P,一个P就像是执行票,不持有就不能调度G)。
如果P的本地队列为空,2) 就会从全局队列/其他的MP组合偷取一个可执行的G来执行。
当M执行某一个G时候发生系统调用或者阻塞,3)M阻塞,如果这个时候G在执行,当前M会被摘除出P,runtime会让这个P重新绑定到另一个空闲M(或新建一个M),继续调度其他G。
4) 当M系统调用结束时,这个G会尝试获取一个空闲的P来执行,并放入到这个P的本地队列;或放到全局队列等待下一次调度。
5) 如果这个线程M变成休眠状态,加入到空闲线程池中,然后整个G就会被放入到全局队列中。
(Go 的 GMP 模型有类似协作式调度(协程主动让出)+ 抢占式调度(runtime 会定期检查长时间运行的 G 并中断),防止某个 goroutine 永久霸占 CPU。)
3. 线程协程映射关系
goroutine和OS线程在Go的M:N模型中怎么映射?
将大量G通过调度器P绑定到有限的系统线程M上运行,形成M:N的调度关系。这是Go实现高并发、低资源消耗的核心机制。
| 关系 | 是否一对一/多 | 说明 |
|---|---|---|
| P和M | 一对一绑定 | 每个M必须绑定一个P才能执行 goroutine。无P的M是闲的;P会被分配给空闲的M |
| P和G | 一对多 | 每个P持有一个本地G队列(最多256个G),调度这些G给M执行 |
| M和G | 多对多 | 一个M可以连续执行多个G串行,同一个G在不同时间可能被不同M执行(迁移) |
Go语言调度模型G,M,P的数量多少合适?
P的数量:由 GOMAXPROCS 决定,建议设为CPU核数
4. 主协程等待其余协程退出
sync.WaitGroup是等待一组协程结束,sync.WaitGroup只有3个方法,Add()是添加计数,Done()减去一个计数,Wait()阻塞直到所有的任务完成。
还能通过有缓冲的channel实现其阻塞等待一组协程结束,这个不能保证一组goroutine按照顺序执行,可以并发执行协程。Go里面能通过无缓冲的channel也能,但是不能并发执行。
5. Goroutine 的上下文保存/恢复和操作系统进程的上下文切换
上下文切换的本质:保存旧执行上下文 + 挑选下一执行实体 + 恢复新执行上下文。
都要做的事情:
- 都要保存“执行现场”——当前 PC、栈指针、部分寄存器,以便将来恢复。
- 都要挑选下一个可运行实体——调度算法无论放在内核还是用户态,逻辑一样。
- 都涉及“栈”切换
区别:
注意进程和线程在操作系统看来都是task,那么有很多操作是相同的。(区别是有无独立地址空间嘛)
| goroutine | 线程 | 进程 |
|---|---|---|
| 用户态 runtime 决定大部分情况用户态来做切换 | 内核 scheduler触发上下文切换,在时间片到、阻塞、抢占等时候切换 | 内核 scheduler触发上下文切换,在时间片到、阻塞、抢占等时候切换 |
| 特殊情况:M(内核线程)不够用了,runtime 要新建或销毁 M,此时伴随一次线程切换;或者当前 G 进入阻塞性系统调用,runtime 把 M 和 P 解绑,让别的 M 接管 P。 | 由于虚拟内存独占的额外操作:空间切换时要把 CR3寄存器换成新进程的页表,TLB 刷一遍 | |
| 绝大部分情况用户态(P 的本地 runqueue、全局队列、steal 列表都在用户态地址空间,runtime 自己用原子指令就能改。) | 内核态(时钟中断、系统调用、page-fault 的入口都在内核。并且用户态寄存器保存在内核栈) | 内核态(写 CR3 寄存器 → 页表基址改变 → TLB 全部失效。这些操作都需要内核) |
| 切换时只保存 15-20 个通用寄存器+SP+PC,用纯用户态汇编完成 | 通用寄存器+段/调试/浮点/SIMD 等 | 通用寄存器+段/调试/浮点/SIMD/TLB 等 |
| 栈在进程堆段——runtime 调用 mmap 匿名页,自己管理 pool,再切成 2 KB/4 KB 的小块给 g.stack 使用。有自己的扩容策略。 | 在内核态执行时用内核栈,回到用户态后用用户栈(内核栈:系统调用、中断、异常入口;用户栈:用户态指令运行期间) | 在内核态执行时用内核栈,回到用户态后用用户栈(内核栈:系统调用、中断、异常入口;用户栈:用户态指令运行期间) |