[译] Go语言的协程,系统线程以及CPU管理

本文基于Go 1.13

创建系统线程以及在系统线程间切换,会对程序的内存和性能造成较大的开销。 Go 的目标是尽量利用 CPU 多核资源。设计之初就考虑了高并发性。

M,P,G 模型

为了达到这个目标, Go 拥有一个将协程调度到系统线程执行的调度器。这个调度器定义了三个核心概念,在 Go 源码中是这样解释的:

G – goroutinue. 协程

M – worker thread, or machine. 工作线程

P – processor, 执行Go代码时所必须的一种资源。

M必须有一个相关联的P才能执行Go代码。

以下是 PMG 模型的示意图:

每个协程( G )在一个分配给逻辑 processorP )的系统线程( M )上运行。来看一个小例子:

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 列表中:

然后,新创建并等待被运行的协程会唤醒一个 P 来执行这个任务,这个 P 会创建一个和系统线程相关联的 M

P 一样,如果一个 M 没有工作可做了,该 M 会被放入空闲 M 链表中:

在程序启动时, Go 会预先创建一些系统线程以及相关联的 M 。在上面的小例子中,第一个打印 hello 的协程会使用主协程,而第二个打印 world 的协程会从空闲列表中获取到一个 M 和一个 P

以上,我们有了一张管理协程和系统线程的全局图,让我们进一步看看 Go 在什么情况下会使用更多的 MP ,以及调用系统调用时协程是如何被管理的。

系统调用

Go 对系统调用做了优化,具体做法是在运行时对系统调用做了封装(不管系统调用是否会造成阻塞)。该部分封装代码会自动将 P 与线程 M 解除绑定,使得另一个线程 M 可以在这个 P 上运行。让我们来看一个读取文件的例子:

func main() {
   buf := make([]byte, 0, 2)

   fd, _ := os.Open("number.txt")
   fd.Read(buf)
   fd.Close()

   println(string(buf)) // 42
}

以下是打开文件的流程:

现在, P0 被放入空闲列表中,可被使用。当系统调用结束之后, Go 顺序执行如下流程直到其中一条规则被满足:

  • 试图获取同一个 P ,在我们上面的例子就是 P0 ,如果获取到,则恢复执行
  • 试图在空闲列表中获取一个 P ,如果获取到,则恢复执行
  • 将协程放入全局队列中,将相关的 M 放入空闲列表中

并且, Go 使用非阻塞 I/O 模式,对资源还没有就绪的情况也做了处理,比如说 http 请求。这种情况下首先也遵循上面所说的系统调用的流程,之后如果底层的系统调用由于资源没有就绪而返回失败时,Go会强制使用 network poller ,并且将该协程挂起。以下是例子:

func main() {
   http.Get(`https://httpstat.us/200`)
}

当底层的系统调用返回并且显式表示资源没有就绪时,协程将被挂起,直到 network poller 通知它资源已经就绪。这种情况下,线程 M 不会被阻塞:

Go 调度器重新调度时,之前的那个协程将被重新运行。调度器会询问 network poller 是否存在之前在等待资源并且现在资源已经就绪的协程:

如果有多个协程就绪了,其它的协程会被放入全局等待执行队列中,稍后会被调度执行。

关于系统线程数的限制

当使用了系统调用时, Go 并不限制这些可能被阻塞的系统线程的数量,以下是 Go 代码中的注释说明:

GOMAXPROCS变量限制的是用户层面Go代码的系统线程数量。对于可能造成阻塞的系统调用的线程数是不做限制的;它们不计算在GOMAXPROCS限制之中。

以下是一个例子:

func main() {
   var wg sync.WaitGroup

   for i := 0;i < 100 ;i++  {
      wg.Add(1)

      go func() {
         http.Get(`https://httpstat.us/200?sleep=10000`)

         wg.Done()
      }()
   }

   wg.Wait()
}

以下是使用 tracing 工具,查看程序创建的线程数量:

值得一提,由于 Go 可以复用系统线程,所以工具查看到的线程数要小于例子中 for 循环的次数。

英文原文地址: Go: Goroutine, OS Thread and CPU Management

原文链接: https://pengrl.com/p/29953/

原文出处: yoko blog ( https://pengrl.com )

原文作者:yoko

版权声明:本文欢迎任何形式转载,转载时完整保留本声明信息(包含原文链接、原文出处、原文作者、版权声明)即可。本文后续所有修改都会第一时间在原始地址更新。