1、操作系统线程调度
程序启动时会创建一个进程和虚拟内存,每个进程会创建一个初始的线程,线程又可以创建更多的线程。进程是资源分配的最小单位,线程是计算和调度的最小单位。每个线程会在进程的虚拟内存中保存自己的栈信息(?虚拟内存的堆区),每个线程都有一个 IP 指向下一个需要执行的指令(顺序执行)。每个 cpu 核心同一时刻只能执行一个指令,因此需要操作系统来合理地调度线程到实际的 cpu 核心上执行指令。当进程数或线程数超过 cpu 核心数时还需要进程或线程的上下文切换,以保证所有线程能“公平”运行。
操作系统线程调度一般需要保证:
- 当有线程需要执行时,cpu 不会空闲等待
- 用户看起来所有线程都是同时执行的,同时优先级高的线程尽可能优先执行,优先级低的也不能等待太长时间(平衡的艺术)
操作系统有许多不同的策略去调度线程:
- 时间片轮转调度策略
- 抢占式调度策略
- 多级反馈队列
- 。。。
进程、线程的创建、销毁和上下文切换(保存进程、线程运行状态)都需要开销,并且进程开销大于线程。对于计算密集型(CPU-Bound)应尽量避免上下文切换,对于 io 密集型(IO-Bound)可以用多线程来提高效率,但是多线程的数量仍限制于系统资源,过多的线程会占用更多的内存资源,产生更频繁的上下文切换,导致效率不升级反降(核数3倍?)。
2、GPM 模型
- 为了解决多线程的问题,golang 在系统调度的基础上实现了自己的 goroutine 调度器,即 GPM 模型。goroutine 相比线程更加轻量,GPM 调度器效率更高,因此 go 可以有很强的并发能力。
- G:goroutine
- P:Processor,相当于 G 的CPU,数量等于 GOMAXPROC
- M:执行 G 的线程
GPM 简单原理:
每个 P 都有一个局部队列,负责保存待执行的 G,当局部队列满了就放到全局队列中
每个 P 都有一个 M 绑定,正常情况下 M 从局部队列中获取 G 执行
M 可以从其他队列偷取 G 执行(work stealing),也可以从全局队列获取 G 执行
当 G 因系统调用(syscall)阻塞时会阻塞 M,此时 P 会和 M 解绑(hand off),并寻找新的空闲 M,若没有空闲 的M 就会新建一个 M
当 G 因 channel 或者 network I/O 阻塞时,不会阻塞 M,M 会寻找其他 runnable 的 G;当阻塞的 G 恢复后会重新进入 runnable 进入 P 队列等待执行
mcache(内存分配状态)位于 P,所以 G 可以跨M调度,不再存在跨 M 调度局部性差的问题
G 是抢占调度。不像操作系统按时间片调度线程那样,Go 调度器没有时间片概念,G 因阻塞和被抢占而暂停,并且 G只能在函数调用时有可能被抢占,极端情况下如果 G 一直做死循环就会霸占一个 P 和 M,Go 调度器也无能为力。
使用 GODEBUG 可以查看 Go 调度器的实际过程:
gomaxprocs:P 的数量
idleprocs:空闲的 P
threads:总线程数
spinningthreads:自旋线程数
idlethreads:空闲线程数
runqueue:全局队列中 G 的数目
[2 3 3 2 1 1 2 1]:局部队列中 G 的数目