七叶笔记 » golang编程 » Go:协程,操作系统线程和 CPU 管理

Go:协程,操作系统线程和 CPU 管理

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

ℹ️ 本文运行环境为 Go 1.13

对于一个程序来说,从内存和性能角度讲创建一个 OS 线程 或切换线程花费巨大。Go 志在极尽所能地充分利用内核资源。从第一天开始,它就是为并发而生的。

M, P, G 编排

为了解决这个问题,Go 有它自己的在线程间调度协程的调度器。这个调度器定义了三个主要概念,如源码中解释的这样:

 The main concepts are:
G - goroutine.
M - worker thread, or machine.
P - processor, a resource that is required to execute Go code.
    M must have an associated P to execute Go code[...].
  

P, M, G 模型图解:

P, M, G diagram

每个协程(G)运行在与一个逻辑 CPU(P)相关联的 OS 线程(M)上。我们一起通过一个简单的示例来看 Go 是怎么管理他们的:

 func main() {
    var  wg sync.WaitGroup
   wg.Add(2)
   go func() {
      println(`hello`)
      wg.Done()
   }()
   go func() {
      println(`world`)
      wg.Done()
   }()
   wg.Wait()
}
  

首先,Go 根据机器逻辑 CPU 的个数来创建不同的 P,并且把它们保存在一个空闲 P 的 list 里。

P initialization

然后,为了更好地工作新创建的已经准备好的协程会唤醒一个 P。这个 P 通过与之相关联的 OS 线程来创建一个 M:

OS thread creation

然而,像 P 那样,系统调用返回的甚至被 gc 强行停止的空闲的 M — 比如没有协程在等待运行 — 也会被加到一个空闲 list:

M and P idle list

在程序启动阶段,Go 就已经创建了一些 OS 线程并与 M 想关联了。在我们的例子中,打印 hello 的第一个协程会使用主协程,第二个会从这个空闲 list 中获取一个 M 和 P:

M and P pulled from the idle list

现在我们已经掌握了协程和线程管理的基本要义,来一起看看什么情形下 Go 会用比 P 多的 M,在系统调用时怎么管理协程。

系统调用

Go 会优化系统调用 — 无论阻塞与否 — 通过运行时封装他们。封装的那一层会把 P 和线程 M 分离,并且可以让另一个线程在它上面运行。我们拿文件读取举例:

 func main() {
   buf := make([] byte , 0, 2)
   fd, _ := os.Open("number.txt")
   fd.Read(buf)
   fd.Close()
   println(string(buf)) // 42
}
  

文件读取的流程如下:

Syscall handoffs P

P0 现在在空闲 list 中,有可能被唤醒。当系统调用 exit 时,Go 会遵守下面的规则,直到有一个命中了。

  • 尝试去捕获相同的 P,在我们的例子中就是 P0,然后 resume 执行过程
  • 尝试从空闲 list 中捕获一个 P,然后 resume 执行过程
  • 把协程放到全局队列里,把与之相关联的 M 放回空闲 list 去

然而,在像 http 请求等 non-blocking I/O 情形下,Go 在资源没有准备好时也会处理请求。在这种情形下,第一个系统调用 — 遵循上述流程图 — 由于资源还没有准备好所以不会成功,(这样就)迫使 Go 使用 network poller 并使协程停驻。请看示例:

 func main() {
   http.Get(``)
}
  

当第一个系统调用完成且显式地声明了资源还没有准备好,协程会在 network poller 通知它资源准备就绪之前一直处于停驻状态。在这种情形下,线程 M 不会阻塞:

Network poller waiting for the resource

在 Go 调度器在等待信息时协程会再次运行。调度器在获取到等待的信息后会询问 network poller 是否有协程在等待被运行。

如果多个协程都准备好了,只有一个会被运行,其他的会被加到全局的可运行队列中,以备后续的调度。

OS 线程方面的限制

在系统调用中,Go 不会限制可阻塞的 OS 线程数,源码中有解释:

译注: GOMAXPROCS 变量表示可同时运行用户级 Go 代码的操作系统线程的最大数量。系统调用中可被阻塞的最大线程数并没有限制;可被阻塞的线程数对 GOMAXPROCS 没有影响。这个包的 GOMAXPROCS 函数查询和修改这个最大数限制。

对这种情形举例:

 func main() {
   var wg sync.WaitGroup
   for i := 0;i < 100 ;i++  {
      wg.Add(1)
      go func() {
         http.Get(``)
         wg.Done()
      }()
   }
   wg.Wait()
}
  

利用追踪工具得到的线程数如下:

由于 Go 优化了线程使用,所以当协程阻塞时,它仍可复用,这就解释了为什么图中的数跟示例代码循环中的数不一致。


via:

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

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

参考资料

[1]

Vincent Blanchon:

[2]

lxbwolf:

[3]

polaris1119:

[4]

GCTT:

[5]

Go 中文网:

相关文章