Go: Goroutine, 系统线程和CPU管理

ℹ️ 本文基于Go1.13。

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

M, P, G 编排

为了解决这个问题,Go有自己的调度程序,可以在线程上分配goroutine。 该调度程序定义了三个主要概念,如代码本身所述:

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模型的示意图:

每个goroutine(G)在分配给逻辑CPU(P)的系统线程(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的列表中:

然后,准备运行的新goroutine将唤醒一个P来执行任务。此P将创建一个和系统线程相关联的M:

与P一样,没有工作的M将会进入空闲列表:

在程序启动过程中,Go已经创建了一些系统线程和关联的M。在我们的示例中,打印hello的第一个goroutine将使用主goroutine,而第二个goroutine将从这个空闲列表中获取M和P:

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

系统调用

Go通过在运行时包装系统调用来优化系统调用(无论它是否阻塞)。这个包装器将自动将P与线程M分离,并允许另一个线程在其上运行。让我们以一个文件读取为例:

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来停放goroutine。这是一个例子:

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

一旦系统调用完成并明确表示资源尚未就绪,goroutine将停放,直到network poller通知它资源现在已准备就绪。在这种情况下,线程M不会被阻塞:

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

如果有超过一个goroutine就绪,则另外的goroutine将被放到全局队列中等待后续调度。

对系统线程数的限制

当使用了系统调用时,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()
}

这是通过trace工具得到的线程数:

因为Go可以复用系统线程,所以工具查看到的线程数要小于例子中循环的次数。

编译自:https://medium.com/a-journey-with-go/go-goroutine-os-thread-and-cpu-management-2f5a5eaf518a

张贴在Go标签:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据