面试题答案
一键面试Goroutine 默认调度机制
- M:N 调度模型:Go 语言采用 M:N 调度模型,即多个 Goroutine 映射到多个操作系统线程上。Goroutine 是用户态线程,由 Go 运行时(runtime)进行调度管理,而操作系统线程(M)由操作系统内核调度。
- 调度器组件:Go 调度器主要由三个组件构成:G(Goroutine)、M(操作系统线程)、P(处理器)。
- P:处理器,它包含一个本地的 Goroutine 队列,并且持有运行时的一些上下文信息,如内存分配状态等。P 的数量在程序启动时通过
runtime.GOMAXPROCS
设置,默认等于 CPU 的核心数。 - M:操作系统线程,每个 M 会绑定一个 P 来运行 Goroutine。M 从 P 的本地队列或者全局队列中获取 Goroutine 来执行。
- G:Goroutine,包含了需要执行的函数以及函数的参数等信息。
- P:处理器,它包含一个本地的 Goroutine 队列,并且持有运行时的一些上下文信息,如内存分配状态等。P 的数量在程序启动时通过
- 调度流程:
- 当一个 Goroutine 创建时,它会被放入 P 的本地队列。如果 P 的本地队列已满,会被放入全局队列。
- M 会优先从绑定的 P 的本地队列中获取 Goroutine 执行。如果本地队列为空,M 会尝试从全局队列中获取一批 Goroutine 到本地队列,或者从其他 P 的本地队列中偷取一半的 Goroutine 到自己绑定的 P 的本地队列。
为何没有传统意义上的优先级调度
- 设计目标:Go 语言设计的初衷是为了实现高效的并发编程,强调的是公平调度,让所有的 Goroutine 都有机会执行,避免某个 Goroutine 长时间占用资源而导致其他 Goroutine 饿死。
- 调度策略:默认调度器采用的是协作式调度,即 Goroutine 在执行过程中会主动让出 CPU 资源(例如通过系统调用、channel 操作、
runtime.Gosched()
等),这种调度方式没有为不同的 Goroutine 分配优先级,而是平等对待所有 Goroutine。
实现简单优先级调度的考虑方面及原因
- 修改调度器:
- 原因:最直接的方式就是对 Go 语言的调度器进行修改。在调度器中引入优先级队列,根据 Goroutine 的优先级将其放入不同的队列中。M 在获取 Goroutine 时,优先从高优先级队列中获取。这样可以实现根据优先级来调度 Goroutine。但这种方式需要深入理解 Go 调度器的底层实现,修改难度较大,并且可能会破坏 Go 调度器原有的一些特性和稳定性。
- 使用工作池:
- 原因:可以创建多个工作池,每个工作池对应不同的优先级。高优先级的任务放入高优先级工作池,低优先级的任务放入低优先级工作池。通过控制工作池中的资源(如 M 的数量或者调度频率)来实现优先级调度。例如,给高优先级工作池分配更多的 M 或者更高的调度频率,使得高优先级工作池中的任务能更快速地被执行。这种方式相对简单,不直接修改调度器,对 Go 原有调度机制的侵入性较小。
- 利用 channel 缓冲:
- 原因:可以利用 channel 的缓冲机制来模拟优先级。高优先级的 Goroutine 向缓冲较小的 channel 发送数据,低优先级的 Goroutine 向缓冲较大的 channel 发送数据。由于 channel 满时发送操作会阻塞,缓冲小的 channel 更容易满,这样高优先级的 Goroutine 就更容易被阻塞从而让出 CPU 资源给其他高优先级的 Goroutine 执行,实现一种简单的优先级调度概念。这种方式实现起来较为简单,利用了 Go 语言本身的 channel 特性,不会对调度器底层造成影响。