Go语言中基于信号的抢占式调度
Go语言中基于信号的抢占式调度
在任何情况下,Go 运行时并行执行(注意,不是并发)的 goroutines 数量是 小于等于 P 的数量的。为了提高系统的性能,P 的数量肯定不是越小越好,所 以官方默认值就是 CPU 的核心数,设置的过小的话,如果一个持有 P 的 M,由于 P 当前执行的 G 调用了 syscall 而导致 M 被阻塞,那么此时关键点:
GO 的调度器是迟钝的,它很可能什么都没做,直到 M 阻塞了相当长时间以后,才会发现有一个 P/M 被 syscall 阻塞了。然后,才会用空闲的 M 来强这 个 P。通过 sysmon 监控实现的抢占式调度,最快在 20us,最慢在 10-20ms 才 会发现有一个 M 持有 P 并阻塞了。操作系统在 1ms 内可以完成很多次线程调 度(一般情况 1ms 可以完成几十次线程调度),Go 发起 IO/syscall 的时候执 行该 G 的 M 会阻塞然后被 OS 调度走,P 什么也不干,sysmon 最慢要 10-20ms 才能发现这个阻塞,说不定那时候阻塞已经结束了,这样宝贵的 P 资源就这么 被阻塞的 M 浪费了。
在Go语言中,虽然其调度器(goroutine scheduler)本身是一个基于协作(cooperative)的调度模型,意味着goroutine会在完成其任务或显式调用如runtime.Gosched()
来让出CPU时,才会被调度器切换到其他goroutine执行,但Go运行时(runtime)确实利用了一些底层机制,包括操作系统提供的信号(signals),来管理goroutine的生命周期和调度策略,特别是在处理诸如GC(垃圾收集)、栈扩展、系统调用等场景下。
然而,需要注意的是,Go语言的标准库和运行时并不直接提供基于信号的抢占式调度接口给开发者使用。抢占式调度主要是Go运行时内部为了优化资源使用和公平性而实现的。
不过,我们可以通过模拟或利用Go的一些特性来间接观察或影响这种调度行为。下面,我将提供一个示例,说明如何在Go程序中触发系统调用,这可能会间接导致goroutine的调度切换(虽然这不是直接基于信号的抢占式调度):
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
// 启动多个goroutine
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 10; j++ {
// 模拟计算任务
time.Sleep(time.Millisecond * 100)
fmt.Printf("Goroutine %d: Iteration %d\n", id, j)
// 触发系统调用,可能导致调度切换
runtime.KeepAlive(nil) // 实际上这个调用主要是为编译器优化使用,并不直接导致调度切换,但类似操作可能触发
}
}(i)
}
wg.Wait()
fmt.Println("All goroutines finished.")
}
// 注意:runtime.KeepAlive(x) 主要用于标记x为"被使用",防止编译器在静态分析时将其优化掉,
// 它本身并不直接导致goroutine的调度切换。这里只是为了说明可能的系统调用场景。
在这个例子中,我们并没有直接使用信号来实现抢占式调度,但runtime.KeepAlive(nil)
的调用(尽管它主要用于编译器优化,而不是调度)和time.Sleep
调用(这会导致goroutine暂停,从而可能被调度器抢占)都展示了Go程序中可能发生的调度行为。
真正基于信号的抢占式调度通常是由Go运行时在内部实现的,例如,在检测到goroutine长时间运行而没有主动让出CPU时,可能会通过系统信号或其他机制来强制中断并重新调度。但这样的机制对开发者是透明的,且通常不需要开发者直接干预。