36

[译] 都说 Go 可以开启成千上万的 Goroutine,那调度器是怎么处理核上任务分配的?

 4 years ago
source link: https://mp.weixin.qq.com/s/_By9rjPgb8zqIpVrsJrToQ
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

点击上方蓝色“ Go语言中文网 ”关注我们, 领全套Go资料 ,每天学习 Go 语言

aMZBv2.png!mobile

Illustration created for “A Journey With Go”, made from the original Go Gopher, created by Renee French.

:information_source: 这篇文章基于 Go 1.13 版本。

在 Go 中创建 gorotine 既方便又快捷,然而 Go 在同一时间内最多在一个核上运行一个 gorotine,因此需要一种方法来存放其他的 gorotine,从而确保处理器(processor)负载均衡。

Goroutine 队列

Go 使用两级队列来管理等待中的 goroutine,分别为本地队列和全局队列。每一个处理器都拥有本地队列,而全局队列是唯一的,且能被所有的处理器访问到:

QFjEvme.png!mobileGlobal and local queues

每个本地队列都有最大容量,为 256。在容量满了之后,任意新到来的 Goroutine 都会被放置到全局队列。下面的例子是,生产了上千个 Goroutine 的程序:

func main() {
   var wg sync.WaitGroup

   for i := 0;i < 2000 ;i++ {
      wg.Add(1)
      Go func() {
         a := 0

         for i := 0; i < 1e6; i++ {
            a += 1
         }

         wg.Done()
      }()
   }

   wg.Wait()
}

下面是拥有两个处理器的调度器追踪数据(traces):

eiYjU3y.png!mobileDetails of the local and global queues

追踪数据通过 runqueue 展示了全局队列中 Goroutine 的数量,以及方括号中 [3 256] 的本地队列 goroutine 数量(分别为 P0P1 )。当本地队列满了,积压了 256 个等待中的 goroutine 后,下一个 Goroutine 会被压栈到全局队列中,正如我们从 runqueue 看到的数量增长一样。

Goroutine 仅在本地队列满载之后才会加入到全局队列;它也会在 Go 往调度器中批量注入时被加到全局队列,例如,网络轮询器(network poller) 或者在垃圾回收期间等待的 goroutine。

下面是上一个例子的图示:

MJZrI3y.png!mobileLocal queues have up to 256 goroutines

不过,我们还想知道,为什么本地队列 P0 在上一个列子中不为空。因为 Go 使用了其他策略确保每个处理器都有任务处理。

任务窃取

如果处理器没有任务可处理,它会按以下规则来执行,直到满足某一条规则:

  • 从本地队列获取任务

  • 从全局队列获取任务

  • 从网络轮询器获取任务

  • 从其它的处理器的本地队列窃取任务

在我们前面的例子中,主函数在 P1 上运行并创建 goroutine。当第一批 gourinte 已经进入了 P1 的本地队列时, P0 正在寻找任务。然而,它的本地队列,全局队列,以及网络轮询器都是空的。最后的解决方法是从 P1 中窃取任务。

FvMJJ3N.png!mobileWork-stealing by P0

下面是调度器在发生任务窃取前后的追踪数据:

7NVzieq.png!mobile

Work-stealing by P0

追踪数据展示了,处理器是如何从其它处理器中窃取任务的。它从(其他处理器的)本地队列中取走一半的 goroutine;在七个 Goroutine 中,偷走了四个 —— 其中一个立马在 P0 执行,剩下的放到本地队列。现在处理器间工作处于负载良好的状态。这能通过执行 tracing 来确认:

Ef26jy.png!mobile

goroutine 被合理地分发,然后因为没有 I/O,goroutine 被链式执行而不需要切换。我们现在看一下,当出现例如涉及到文件操作等 I/O 时,会发生什么。

I/O 与全局队列

一起看下涉及到文件操作的例子:

func main() {
   var wg sync.WaitGroup

   for i := 0;i < 20 ;i++ {
      wg.Add(1)
      Go func() {
         a := 0
         for i := 0; i < 1e6; i++ {
            a += 1
            if i == 1e6/2 {
               bytes, _ := ioutil.ReadFile(`add.txt`)
               inc, _ := strconv.Atoi(string(bytes))
               a += inc
            }
         }
         wg.Done()
      }()
   }

   wg.Wait()
}

变量 a 随着时间以文件的字节数增加,下面是新的追踪数据:

mMBVJn.png!mobile

在这个例子中,我们能看到每一个 Goroutine 不只被一个处理器处理。在系统调用的情况下,当调用完成后,Go 使用网络轮询器从全局队列中把 gouroutine 取回来。这里是 Goroutine #35 的一个示意图:

iiEvAvQ.png!mobileI/O operations put the work back to the global queue

当一个处理器能从全局队列中获取任务,第一个可用的处理器( P ) 会执行这个 goroutine。这个行为解释了,为什么一个 Goroutine 能在不同的处理器中运行,也展示了 Go 是如何让空闲的处理器资源运行 goroutine,从而进行系统调用的优化。

via: https://medium.com/a-journey-with-go/go-work-stealing-in-go-scheduler-d439231be64d

作者: Vincent Blanchon [1] 译者: LSivan [2] 校对: polaris1119 [3]

本文由 GCTT [4] 原创编译, Go 中文网 [5] 荣誉推出

参考资料

[1]

Vincent Blanchon: https://medium.com/@blanchon.vincent

[2]

LSivan: https://github.com/LSivan

[3]

polaris1119: https://github.com/polaris1119

[4]

GCTT: https://github.com/studygolang/GCTT

[5]

Go 中文网: https://studygolang.com/

推荐阅读

学习交流 Go 语言,扫码回复「 进群 」即可

站长 polarisxu

自己的原创文章

不限于 Go 技术

职场和创业经验

Go语言中文网

每天为你

分享 Go 知识

Go爱好者值得关注


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK