首页 > 解决方案 > Go 的调度程序会为 CPU 密集型工作从一个 goroutine 控制到另一个 goroutine 吗?

问题描述

golang methods that will yield goroutines的公认答案解释说,当遇到系统调用时,Go 的调度程序将控制从一个 goroutine 到另一个 goroutine。我理解这意味着如果您有多个 goroutine 正在运行,并且其中一个开始等待 HTTP 响应之类的东西,调度程序可以将此作为提示,将控制权从该 goroutine 交给另一个。

但是不涉及系统调用的情况呢?例如,如果您运行的 goroutine 与可用的逻辑 CPU 内核/线程一样多,并且每个 goroutine 都处于不涉及系统调用的 CPU 密集型计算的中间,该怎么办。理论上,这会使 CPU 的工作能力饱和。Go 调度程序是否仍然能够检测到将控制权从这些 goroutine 之一让给另一个的机会,这可能不会花费很长时间来运行,然后将控制权返回给这些 goroutine 中的一个执行长时间的 CPU 密集型计算?

标签: multithreadinggoconcurrencyschedulergoroutine

解决方案


这里几乎没有任何承诺。

Go 1.14 发行说明运行时部分中说明了这一点:

Goroutines 现在是异步可抢占的。因此,没有函数调用的循环不再可能使调度程序死锁或显着延迟垃圾收集。windows/arm除、darwin/armjs/wasm和之外的所有平台都支持此功能plan9/*

实施抢占的结果是,在包括 Linux 和 macOS 系统在内的 Unix 系统上,使用 Go 1.14 构建的程序将接收到比使用早期版本构建的程序更多的信号。这意味着使用类似syscallgolang.org/x/sys/unix将看到更慢系统调用的程序包的程序会因EINTR错误而失败。...

我在这里引用了第三段的一部分,因为这为我们提供了关于这种异步抢占如何工作的重要线索:运行时系统让操作系统按照某种时间表(真实或虚拟)传递一些操作系统信号(SIGALRM、SIGVTALRM 等)时间)。这允许 Go 运行时实现与真实操作系统使用真实(硬件)或虚拟(虚拟化硬件)计时器实现的调度程序相同的类型。与 OS 调度程序一样,由运行时决定如何处理时钟滴答声:例如,也许只运行 GC 代码。

我们还看到了这样做的平台列表。所以我们可能根本不应该假设它会发生。

幸运的是,运行时源实际上是可用的:如果任何给定的平台实现它,我们可以去看看会发生什么。这表明在runtime/signal_unix.go

// We use SIGURG because it meets all of these criteria, is extremely
// unlikely to be used by an application for its "real" meaning (both
// because out-of-band data is basically unused and because SIGURG
// doesn't report which socket has the condition, making it pretty
// useless), and even if it is, the application has to be ready for
// spurious SIGURG. SIGIO wouldn't be a bad choice either, but is more
// likely to be used for real.
const sigPreempt = _SIGURG

和:

// doSigPreempt handles a preemption signal on gp.
func doSigPreempt(gp *g, ctxt *sigctxt) {
        // Check if this G wants to be preempted and is safe to
        // preempt.
        if wantAsyncPreempt(gp) && isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()) {
                // Inject a call to asyncPreempt.
                ctxt.pushCall(funcPC(asyncPreempt))
        }

        // Acknowledge the preemption.
        atomic.Xadd(&gp.m.preemptGen, 1)
        atomic.Store(&gp.m.signalPending, 0)
}

实际的asyncPreempt函数在汇编中,但它只是做了一些仅汇编的技巧来保存用户寄存器,然后调用asyncPreempt2位于runtime/preempt.go

//go:nosplit
func asyncPreempt2() {
        gp := getg()
        gp.asyncSafePoint = true
        if gp.preemptStop {
                mcall(preemptPark)
        } else {
                mcall(gopreempt_m)
        }
        gp.asyncSafePoint = false
}

将此与runtime/proc.go'sGosched函数进行比较(记录为自愿让步的方式):

//go:nosplit

// Gosched yields the processor, allowing other goroutines to run. It does not
// suspend the current goroutine, so execution resumes automatically.
func Gosched() {
        checkTimeouts()
        mcall(gosched_m)
}

我们看到主要区别包括一些“异步安全点”的东西,我们安排了一个 M-stack-call 来gopreempt_m代替gosched_m. 因此,除了安全检查内容和不同的跟踪调用(此处未显示)之外,非自愿抢占几乎与自愿抢占完全相同。

为了找到这一点,我们必须深入挖掘(在本例中为 Go 1.14)实现。人们可能不想过分依赖这一点。


推荐阅读