Golang Go语言中关于 goroutine 调度的问题

昨天在学习 golang 的 goroutine 的时候,遇到了一个令我有点不解的问题。

	func main(){
	runtime.GOMAXPROCS(1)

    waitGroup.Add(1)
    go func(){
        defer waitGroup.Done()
        for i := 0;i < 20;i++ {
            fmt.Println("hello")
        }
    }()

    waitGroup.Add(1)
    go func(){
        defer waitGroup.Done()
        for {
        }
    }()

    waitGroup.Wait()
}

是这样的,这是在 main 里的一段代码, 我设置 GOMAXPROCS 为 1,也就是只有一个上下文(不知道对不对,按照某文 GMP 这里应该是 P 吧),一个 M 对应一个 P,M 是 OS thread 的抽象,在每个 M 上挂载一个 runqueue,这样的话,为什么是死循环的 goroutine 先入了 runqueue 然后得到了调度,hello 没有得到打印。

问题 1:难道 go 不会管哪个 goroutine 占取 P 的时间吗?为什么死循环的 goroutine 得到调度之后,一直占用 P,而没有让出给打印 hello 的 goroutine

问题 2:既然 goroutine 会被装入 runqueue,为什么是按声明的顺序倒序装入 runqueue 的,难道不是应该先装入打印 hello 的 goroutine 吗?然后得到调度吗? 为什么是倒序?

小弟初学 golang, 实在不解


Golang Go语言中关于 goroutine 调度的问题

更多关于Golang Go语言中关于 goroutine 调度的问题的实战教程也可以访问 https://www.itying.com/category-94-b0.html

19 回复

不用细究这个问题,一来实际不会出现这种代码,二来 1.12 会修正这个 bug。

答案:
1:goroutine 调度是 M 自己主动跳过去的,死循环了自然跳不过去,就一直占用 P。1.12 会用信号让 M 强制跳到信号处理过程,所以死循环不影响。
2:goroutine 的执行顺序不确定,应该认为它是随机的,不是说写在前面就应该先执行,没有保证的,所以不要依赖这个顺序,想要确定的顺序,就用线程同步机制,chan 或者锁等。

更多关于Golang Go语言中关于 goroutine 调度的问题的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


1:
runtime.GOMAXPROCS(1)对应产出一个 m,一个 m 对应一个 p。
每个 P 会维护一个本地的 go routine 队列,一个 G 如果发生阻塞等事件会进行阻塞。(减少上下文切换浪费时间)

G 发生上下文切换条件:
系统调用;
读写 channel ;
gosched 主动放弃,会将 G 扔进全局队列;

而你的 for 不符合上面三个任 1 切换条件,所以阻塞。

2:
协程是栈操作,后放进去的先拿出来。

谢谢解答, 大概懂了一些,但是有点不懂的是, 问题 2, 执行顺序是随机的, 但是我每次执行都是声明的第二个 goroutine 先执行(测试了很多遍),难道是有什么因素影响了它们的执行顺序吗?

谢谢你的回答, 但是 runqueue 不应该和它的名字一样是队列吗,为什么是栈操作

不好意思,我上面第二个说错了。
少看了你代码中第二个 waitGroup.Add(1) ,按照网上说的 waitGroup 是没有顺序的

但是为什么测试了很多遍,总是第二个 goroutine 先执行,这点我不明白,难道是有什么因素影响了它们的执行顺序吗?

不是说测试很多遍它就会一直这样,语言规范没有说必须是这个顺序,那编译器怎么实现都可以,因为都不违反规范。所以你要把它看作是随机的,不能依赖这种未确定的行为,不然很可能新版的编译器就会破坏你依赖的事实。有些项目不敢升级编译器版本,就是因为依赖了特定版本的编译器的行为,一升级就坏了。不是你自己测试很多遍你就能依赖它,编译器、操作系统、硬件等等不同,都有可能出现不同的结果。可以依赖的只有语言规范( https://golang.org/ref/spec ),编译器实现者是一定会遵守的。

编译器的某种行为,如果语言规范没有说,那就是未定义行为,如果你的程序依赖这种行为才能正确工作,那以后编译器改动了,这种行为和之前的不一样了,那你的程序崩了就是你自己的责任,编译器没有责任。语言规范定义了的,编译器实现得不对,那就是编译器实现者的责任。一个例子是,之前并发读写 map 不做同步,是不会报错的,但是某个版本之后,运行时直接就会 panic。规范没有说 map 是线程安全的,那编译器就可以这么做,因为并发读写不做同步,是未定义行为。你在旧版编译器测试很多次都不出错,不代表以后编译器就不会让你的程序出错。goroutine 的执行顺序,就是未定义行为,讨论它是顺序还是倒序,是毫无意义的。

runtime.GOMAXPROCS(1)

这一行代码是没有任何意义的,goroutine 可能在任意地方发生调度,不是说你只用一个 P,你的程序就能保证什么。该上锁的还是得上锁,该同步的还是得同步。goroutine 不是协程,不要拿协程的性质来看待它。

不信的话,1.12 的调度器很可能教你做人…

哇!懂了!思路清晰,谢谢大佬

我解释一下这个现象
创建 goroutine 的 runtime.newproc 会把 g 放进 runq, 同时放进 p 的 runnext, 第一个 goroutine 先占 runnext, 然后第二个 goroutiner 把它踢了出来。 当调度发生,runq 出队的时候, 先考虑 p 的 runnext, 然后才会按照 runq 的队列顺序来。

你们看这个函数
func runqget(p *p) (gp *g, inheritTime bool) {
// If there’s a runnext, it’s the next G to run.
for {
next := p.runnext
if next == 0 {
break
}
if p.runnext.cas(next, 0) {
return next.ptr(), true
}
}

for {
h := atomic.Load(&p.runqhead) // load-acquire, synchronize with other consumers
t := p.runqtail
if t == h {
return nil, false
}
gp := p.runq[h%uint32(len(p.runq))].ptr()
if atomic.Cas(&p.runqhead, h, h+1) { // cas-release, commits consume
return gp, false
}
}
}

感谢回答,runnext 是什么呢

#14 就是个指针,指向 g

谢谢,我再去补补

其实是 你的 死循环里面没有调用任何方法,就会在那个 goroutine 里面一直死循环,不信你调用个方法试试,然后将打印出来的东西记录到文本,你就会发现这样做之后就会发生调度了。( google 一下 goroutine 10ms 很多文章都讲到了,其实不明白你都知道 mpg 了为什么没有顺便看到 goroutine 10ms 这个抢占式的调度…)至于执行顺序,我测试了一下,如果不用 waitGropup 的话,执行顺序是 主 goroutine ----》从上至下顺序执行 goroutine

前面说错了,再测试了一遍,即使是 runtime.GOMAXPROCS(1)
goroutine 的执行顺序也是随机的

关于Golang中goroutine调度的问题,以下是一些专业解答:

Golang的goroutine调度是基于其独特的GPM(或GMP)调度模型实现的。在这个模型中,G代表goroutine,是Go语言中的并发体;P代表Processor,负责执行goroutine;M代表内核级线程,goroutine就是跑在M之上的。调度器会将可运行的goroutine分配到P的本地运行队列中,并在M上执行它们。

goroutine的调度主要发生在以下情况:

  1. goroutine因等待某个条件而阻塞时,调度器会将其挂起,并在条件满足时重新调度。
  2. goroutine主动调用Gosched()函数让出CPU时,也会发生调度。
  3. 当goroutine运行时间过长或长时间处于系统调用中时,调度器可能会剥夺其运行权,以实现更公平的调度。

此外,Golang的调度器还通过一系列优化手段,如工作窃取算法等,来提高系统的吞吐量和响应性。这使得在Go中编写并发程序变得更加简单和直观。

总的来说,Golang的goroutine调度机制是一个高效、灵活的并发执行模型,为开发者提供了强大的并发处理能力。如需了解更多信息,建议查阅Go语言官方文档或相关权威书籍。

回到顶部