【笔记】Go 语言调度器与 Goroutine

Go 语言调度器与 Goroutine

不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存。

Go 语言的调度器其实就是通过使用数量合适的线程并在每一个线程上执行更多的工作来降低操作系统和硬件的负载。
【笔记】Go 语言调度器与 Goroutine
M 表示操作系统的线程,它是被操作系统管理的线程,与 POSIX 中的标准线程非常类似;
G 表示 Goroutine,每一个 Goroutine 都包含堆栈、指令指针和其他用于调度的重要信息;
P 表示调度的上下文,它可以被看做一个运行于线程 M 上的本地调度器;

Goroutine 只存在于 Go 语言的运行时,它是 Go 语言在用户态为我们提供的『线程』,如果一个 Goroutine 由于 IO 操作而陷入阻塞,操作系统并不会对上下文进行切换,但是 Go 语言的调度器会将陷入阻塞 Goroutine 『切换』下去等待系统调用结束并让出计算资源,作为一种粒度更细的资源调度单元,如果使用得当能够在高并发的场景下更高效地利用机器的 CPU。

虽然 Goroutine 在运行时中定义的状态非常多而且复杂,但是我们可以将这些不同的状态聚合成最终的三种:等待中、可运行、运行中,在运行期间我们会在这三种不同的状态来回切换:
等待中:表示当前 Goroutine 等待某些条件满足后才会继续执行,例如当前 Goroutine 正在执行系统调用或者同步操作;
可运行:表示当前 Goroutine 等待在某个 M 执行 Goroutine 的指令,如果当前程序中有非常多的 Goroutine,每个 Goroutine 就可能会等待更多的时间;
运行中:表示当前 Goroutine 正在某个 M 上执行指令;

Go 语言并发模型中的 M 其实表示的是操作系统线程,在默认情况下调度器能够允许创建 10000 个线程,但是其中绝大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS 个线程 M 能够正常运行。

所有 Golang 程序中的最大『可运行』线程数其实就等于 GOMAXPROCS 这个变量的值;在默认情况下,它会被设置成当前应用的核数,我们也可以使用 runtime.GOMAXPROCS 方法来改变当前程序中最大的线程数。
【笔记】Go 语言调度器与 Goroutine
在默认情况下,一个四核机器上会创建四个操作系统线程,每一个线程其实都是一个 m 结构体,我们也可以通过 runtime.GOMAXPROCS 改变最大可运行线程的数量,我们可以使用 runtime.GOMAXPROCS(3) 将 Go 程序中的线程数改变成 3 个。

在大多数情况下,我们都会使用 Go 的默认设置,也就是 #thread == #CPU,在这种情况下不会触发操作系统级别的线程调度和上下文切换,所有的调度都会发生在用户态,由 Go 语言调度器触发,能够减少非常多的额外开销。

操作系统线程在 Go 语言中就会使用私有结构体 m 来表示, 其中 g0 是持有调度堆栈的 Goroutine,curg 是在当前线程上运行的 Goroutine,这也是作为操作系统线程唯一关心的两个 Goroutine 了。

Go 语言调度器中的最后一个重要结构就是处理器 P,其实就是线程需要的上下文环境,也是用于处理代码逻辑的处理器,通过处理器 P 的调度,每一个内核线程 M 都能够执行多个 G,这样就能在 G 进行一些 IO 操作时及时对它们进行切换,提高 CPU 的利用率。

每一个 Go 语言程序中所以处理器的数量一定会等于 GOMAXPROCS,这是因为调度器在启动时就会创建 GOMAXPROCS 个处理器 P,这些处理器会绑定到不同的线程 M 上并为它们调度 Goroutine。

处理器在 Go 语言运行时中同样使用私有结构体 p 表示, 我们将结构体中 GC 以及用于追踪调试的字段全部删除以简化这里需要展示的属性,在上述字段中,status 表示了当前处理器的状态,runhead、runqtail、runq 以及 runnext 等字段表示处理器持有的运行队列,运行队列中就包含待执行的 Goroutine 列表。

p 结构体中的状态 status 其实就会是以下五种状态其中的一种,我们能在 runtime2.go#L99-L147 文件中找到处理器 P 的全部状态:
【笔记】Go 语言调度器与 Goroutine
简单总结一下,Go 语言中有两个运行队列,其中一个是处理器本地的运行队列,另一个是调度器持有的全局运行队列,只有在本地运行队列没有剩余空间时才会使用全局队列存储 Goroutine。