goroutine 在什么时候可以 yield?

At which point a goroutine can yield?

我试图更好地了解 Go 程序中 goroutine 的调度方式,尤其是在哪些点它们可以让步给其他 goroutine。我们知道 goroutine 在 syscals 上产生会阻塞它,但显然这不是全部。

引起了一些类似的担忧,评分最高的答案说 goroutine 也可以打开函数调用,因为这样做会调用调度程序来检查堆栈是否需要增长,但是它明确表示

If you don't have any function calls, just some math, then yes, goroutine will lock the thread until it exits or hits something that could yield execution to others.

我写了一个简单的程序来验证:

package main

import "fmt"

var output [30]string      // 3 times, 10 iterations each.
var oi = 0

func main() {
    runtime.GOMAXPROCS(1)   // Or set it through env var GOMAXPROCS.
    chanFinished1 := make(chan bool)
    chanFinished2 := make(chan bool)

    go loop("Goroutine 1", chanFinished1)
    go loop("Goroutine 2", chanFinished2)
    loop("Main", nil)

    <- chanFinished1
    <- chanFinished2

    for _, l := range output {
        fmt.Println(l)
    }
}

func loop(name string, finished chan bool) {
    for i := 0; i < 1000000000; i++ {
        if i % 100000000 == 0 {
            output[oi] = name
            oi++
        }
    }

    if finished != nil {
        finished <- true
    }
}

注意:我知道在数组中放入一个值并在不同步的情况下递增 oi 是不太正确的,但我想让代码简单并且没有可能导致切换。毕竟,可能发生的最糟糕的事情是在不推进索引(覆盖)的情况下输入一个值,这没什么大不了的。

不同于, I avoided using of any function calls (including built-in append()) from the loop() function that is launched as a goroutine, also I am explicitly setting GOMAXPROCS=1 which according to documentation

limits the number of operating system threads that can execute user-level Go code simultaneously.

然而,在输出中我仍然看到消息 Main/Goroutine 1/Goroutine 2 交错,意思是以下之一:

要么 不完整,要么自 2016 年以来发生了一些变化(我在 Go 1.13.5 和 1.15.2 上测试过)。

如果问题得到了回答,我很抱歉,但我既没有找到关于为什么这个特定示例产生控制的解释,也没有找到 goroutine 通常产生控制的点(阻塞系统调用除外)。

注意:这个问题纯粹是理论性的,我现在不打算解决任何实际任务,但总的来说,我假设知道 goroutine 可以让步和不能让步的点避免冗余使用同步原语。

Go 版本 1.14 推出asynchronous preemption:

Goroutines are now asynchronously preemptible. As a result, loops without function calls no longer potentially deadlock the scheduler or significantly delay garbage collection. This is supported on all platforms except windows/arm, darwin/arm, js/wasm, and plan9/*.

正如 中的回答,Go 的抢占点可能会从一个版本到下一个版本发生变化。异步抢占只是在几乎所有地方添加可能的抢占点。

您对 output 数组的写入不同步并且您的 oi 索引不是原子的,这意味着我们无法真正确定输出数组会发生什么。当然,用互斥锁给它增加原子性会引入协作调度点。虽然这些不是协作调度开关的来源(必须根据您的输出发生),但它们确实会扰乱我们对程序的理解。

output数组保存字符串,使用字符串可以调用垃圾收集系统,可以使用锁和导致调度切换。所以这是 pre-Go-1.14 实现中调度切换的最可能原因。

正如@torek 指出的,GO 最流行的 运行time 环境已经使用 pre-emptive 调度几个月了(从 1.14 开始)。否则,goroutine 可能产生的点因 运行 时间环境和版本而异,但 William Kennedy 给出了一个很好的总结。

我还记得几年前在编译器中添加了一个选项,用于向长 运行ning 循环添加屈服点,但这是一个通常不会触发的实验性选项。 (当然,您可以通过不时地在循环中调用 runtime.GoSched 来手动完成。)

至于你的测试,我对你在 Go 1.13.5 下 运行ning 得到的结果感到惊讶。由于数据竞争(我知道您避免了任何同步机制以避免触发收益),该行为并未完全定义,但我不会预料到该结果。一件事是将 GOMAXPROCS 设置为 1 将意味着只有一个 goroutine 在同时执行,但这可能并不一定意味着当另一个 goroutine 执行时它将 运行 在同一个核心上。不同的核心将有不同的缓存和(没有同步)对 outputoi.

值的不同意见

但我建议您干脆忘记修改全局变量,只在 busy-loop 之前和之后记录一条消息。这应该清楚地表明(在 GO < 1.14 中)一次只有一个 lopp 运行。 (我在很多年前尝试做与您相同的实验,而且似乎有效。)