队列中获取G
M1获取锁并拿到G1,然后释放锁
M3获取锁拿到G2,然后释放锁
M2获取锁拿到G3,然后释放锁
G1在ch1的channel中阻塞,然后添加到ch1的等待队列。导致M1空闲
M1不能闲着,从全局队列获取锁拿到G4,然后释放锁
G3阻塞在ch2的channel中,然后被放到ch2的等待队列。导致M2空闲
M2获取锁拿到G5,然后释放锁
此时G3在ch2结束阻塞,被放到全局队列尾部等待执行
G1在ch1结束阻塞,被放到全局队列尾部等待执行
G4,G5,G2执行完成
M1,M2,M3重复步骤1-4
-
互斥锁、定时器和网络 IO 使用相同的机制
-
如果一个 goroutine 在系统调用中被阻塞,那么情况就不同了,因为我们不知道内核空间发生了什么。 通道是在用户空间中创建的,因此我们可以完全控制它们,但在系统调用的情况下,我们没法控制它们。
-
阻塞系统调用不仅会阻塞 goroutine 还会阻塞内核线程。
-
假设一个 goroutine 被安排在一个内核线程上的系统调用,当一个内核线程完成执行时,它将唤醒另一个内核线程(线程重用),该线程将拾取另一个 goroutine 并开始执行它。 这是一个理想的场景,但在实际情况下,我们不知道系统调用将花费多少时间,因此我们不能依赖内核线程来唤醒另一个线程,我们需要一些代码级逻辑来决定何时 在系统调用的情况下唤醒另一个线程。 这个逻辑在 golang 中实现为 runtime·entersyscall()和 runtime·exitsyscall()。 这意味着内核线程的数量可以超过核心的数量。
-
当对内核进行系统调用时,它有两个关键点,一个是进入时机,另一个是退出时机。
- M1,M2试着从全局队列拿G
- M1获取锁并拿到G1,然后释放锁
- M2获取锁并拿到G2,然后释放锁
- M2阻塞在系统调用,没有可用的内核线程,所以go调度器创建一个新的线程M3
- M3获取锁并拿到G3,然后释放锁
- 此时M2结束阻塞状态,重新把G2放到全局队列(G2由阻塞变为可执行状态)。M2虽然是空闲状态,但是go调度器不会销毁它,而是自旋发现新的可执行的goroutine。
- G1,G3执行结束
- M1,M3重复步骤1-3
操作系统可以支持多少内核线程?
在 Linux 内核中,此参数在文件 /proc/sys/kernel/threads-max 中定义,该文件用于特定内核。
sh:~$ cat /proc/sys/kernel/threads-max 94751
这里输出94751表示内核最多可以执行94751个线程
每个 Go 程序可以支持多少个 goroutine?
调度中没有内置对 goroutine 数量的限制。
每个 GO程序 可以支持多少个内核线程?
默认情况下,运行时将每个程序限制为最多 10,000 个线程。可以通过调用 runtime/debug 包中的 SetMaxThreads 函数来更改此值。
总结:
- 内核线程数可以多于内核数
- 轻量级 goroutine
- 处理 IO 和系统调用
- goroutine并行执行
- 不可扩展(所有内核级线程都尝试使用互斥锁访问全局运行队列。因此,由于竞争,这不容易扩展)
5、M:N 线程分布式运行队列调度器
为了解决每个线程同时尝试访问互斥锁的可扩展问题,维护每个线程的本地运行队列
- 每个线程状态(本地运行队列)
- 仍然有一个全局运行队列
- M1,M2,M3,M4扫描本地可运行队列
- M1,M2,M3,M4从各自的本地队列取出G4,G6,G1,G3
从上面的动图可以看到:
- 从本地队列拿G是不需要加锁的
- 可运行 goroutine 的全局队列需要锁
结论:
- 轻量级 goroutine
- 处理 IO 和 SystemCalls
- goroutine 并行执行
- 可扩展
- 高效
如果线程数大于内核数,那么会有什么问题呢?
在分布式运行队列调度中,我们知道每个线程都有自己的本地运行队列,其中包含有关接下来将执行哪个 goroutine 的信息。 同样由于系统调用,线程数会增加,并且大多数时候它们的本地运行队列是空的。 因此,如果线程数大于核心数,则每个线程必须扫描所有线程本地运行队列,并且大部分时间它们是空的,所以如果线程过多,这个过程是耗时的并且解决方案 效率不高,因此我们需要将线程扫描限制为使用 M:P:N 线程模型求解的常数。
6、M:P: N 线程
如何检查逻辑处理器的数量?
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println(runtime.NumCPU())
}
分布式 M:P:N 调度例子
- M1,M2各自扫描P1,P2的队列
- M1,M2从各自的P1,P2中取出G3,G1执行
在系统调用期间执行P的切换
- M1,M2各自扫描P1,P2的队列
- M1,M2从各自的P1,P2中取出G3,G1执行
- G1即将进入系统调用,所以在这之前G1会唤醒另一个线程M3,并将P2切换到M3
- M3扫描P2并取出G2运行
- 一旦G1变为非阻塞,它将被推送到全局队列等待运行
在work-stealing期间,只需要扫描固定数量的队列,因为逻辑处理器的数量是有限的。
如何选择下一个要运行的 goroutine ?
Go 调度器 将按以下顺序检查以选择下一个要执行的 goroutine
-
本地运行队列
-
全局运行队列
- M1,M2,M3各自扫描本地队列P1,P2,P3
- M1,M2,M3各自从P1,P2,P3取出G3,G1,G5
- G5完成,M3扫描本地队列P3发现空,然后扫描全局队列
- M3将从全局队列获取一定数量的G(G6,G7),保存到本地队列P3
- 现在M3从本地队列P3取出G6执行
-
Network poller
- M1,M2,M3各自扫描本地队列P1,P2,P3
- M1,M2,M3各自从P1,P2,P3取出G3,G1,G6
- G6执行完成,M3扫描P3发现是空的,然后扫描全局队列
- 但是全局队列也是空的,然后就检查网络轮询中已就绪的G
- 网络轮询中有一个已就绪的G2,所以M3取出G2并执行
-
Work Stealing
- M