Go进阶训练营(3)Runtime - Goroutine原理

938 阅读9分钟

这是我参与8月更文挑战的第6天,活动详情查看:8月更文挑战

一些收获

  • pstree -p pid:查看进程中的线程
  • Go 语言中的引用类数据类型有 func, interface, slice, map, chan, *Type
  • Go语言函数调用都是值传递,即使是引用类型,也是传递的值,参考:又吵起来了,Go 是传值还是传引用?

Goroutine

虚拟机

go不像java,运行时是编译到程序中的,所以一个hello world程序有2MB多大,一个使用Go写的SDK有100多MB(依赖的包都是源码编译进程序的)。

73285956-2FE6-4A8E-95DB-F97CE2EED80E.png

Goroutine和线程的区别

  • 内存占用,创建一个 goroutine 的栈内存消耗为 2 KB(Linux AMD64 Go v1.4后,POSIX Thread为1-8MB),运行过程中,如果栈空间不够用,会自动进行扩容。
  • 创建/销毁,线程创建和销毀都会有巨大的消耗,是内核级的交互(trap)。
  • 调度切换:抛开陷入内核,线程切换会消耗 1000-1500 纳秒(上下文保存成本高,较多寄存器,公平性,复杂时间计算统计),一个纳秒平均可以执行 12-18 条指令。所以由于线程切换,执行指令的条数会减少 12000-18000。goroutine 的切换约为 200 ns(用户态、3个寄存器),相当于 2400-3600 条指令。因此,goroutines 切换成本比 threads 要小得多
  • 复杂性:线程的创建和退出复杂,多个thread间通讯复杂(share memory)

M:N调度模型

Go 创建 M 个线程(CPU 执行调度的单元,内核的 task_struct),之后创建的 N 个 goroutine 都会依附在这 M 个线程上执行,即 M:N 模型。它们能够同时运行,与线程类似,但相比之下非常轻量。因此,程序运行时,Goroutines 的个数应该是远大于线程的个数的(phread 是内核线程?)。同一个时刻,一个线程只能跑一个 goroutine。当 goroutine 发生阻塞 (chan 阻塞的情况,syscall系统调用goroutine和M一起阻塞 ) 时,Go 会把当前的 M调度走,让其他 goroutine 来继续执行,而不是让线程阻塞休眠,尽可能多的分发任务出去,让 CPU 忙。

1ED8BD33-06BF-4E00-89FD-BD4A2336B9AF.png

image.png

Per Connection Per Routine

一个协程一个连接,这是区别于C++/Java等使用epoll等I/O复用实现高性能的地方之一。

我们通过看http包的server实现,就能发现,一个客户的请求会启动一个routine来处理。

func (srv *Server) Serve(l net.Listener) error {
   if fn := testHookServerServe; fn != nil {
      fn(srv, l) // call hook with unwrapped listener
   }

   origListener := l
   l = &onceCloseListener{Listener: l}
   defer l.Close()

   if err := srv.setupHTTP2_Serve(); err != nil {
      return err
   }

   if !srv.trackListener(&l, true) {
      return ErrServerClosed
   }
   defer srv.trackListener(&l, false)

   baseCtx := context.Background()
   if srv.BaseContext != nil {
      baseCtx = srv.BaseContext(origListener)
      if baseCtx == nil {
         panic("BaseContext returned a nil context")
      }
   }

   var tempDelay time.Duration // how long to sleep on accept failure

   ctx := context.WithValue(baseCtx, ServerContextKey, srv)
   for {
      rw, err := l.Accept()
      if err != nil {
         select {
         case <-srv.getDoneChan():
            return ErrServerClosed
         default:
         }
         if ne, ok := err.(net.Error); ok && ne.Temporary() {
            if tempDelay == 0 {
               tempDelay = 5 * time.Millisecond
            } else {
               tempDelay *= 2
            }
            if max := 1 * time.Second; tempDelay > max {
               tempDelay = max
            }
            srv.logf("http: Accept error: %v; retrying in %v", err, tempDelay)
            time.Sleep(tempDelay)
            continue
         }
         return err
      }
      connCtx := ctx
      if cc := srv.ConnContext; cc != nil {
         connCtx = cc(connCtx, rw)
         if connCtx == nil {
            panic("ConnContext returned nil")
         }
      }
      tempDelay = 0
      c := srv.newConn(rw)
      c.setState(c.rwc, StateNew) // before Serve can return
      
      // 启动一个go routine
      go c.serve(connCtx)
   }
}

因为routine栈很小,只有2KB,再结合GMP调度模型,同时活跃的永远只有CPU个数的内核线程,不会爆掉。另外routine上下午切换代价很小,相比epoll的各种模型(单reactor单线程,单reactor多线程,主从reactor多线程)来解决吞吐问题而带来的复杂性,我认为在go里面,per connection per routine是一种新的解决方案。

PS:我猜测gRPC中也是(目前还没来得及看源码),一个调用就是一个go routine。

GMP调度模型

GMP概念

  • G:Goroutine 的缩写,每次 go func() 都代表一个 G,无限制,但受内存影响。使用 struct runtime.g,包含了当前 goroutine 的状态、堆栈、上下文。
  • M:工作线程(OS thread)也被称为 Machine,使用 struct runtime.m,所有 M 是有线程栈的。M 的默认数量限制是 10000(来源),可以通过debug.SetMaxThreads修改。
  • P:Processor,是一个抽象的概念,并不是真正的物理 CPU,P 表示执行 Go 代码所需的资源,可以通过 GOMAXPROCS 进行修改。当 M 执行 Go 代码时,会先关联 P。当 M 空闲或者处在系统调用时,就需要 P。且在 Go1.5 之后GOMAXPROCS 被默认设置可用的核数,而之前则默认为1。更多的可以看这几篇文章:再见 Go 面试官:GMP 模型,为什么要有 P?Go 群友提问:Goroutine 数量控制在多少合适,会影响 GC 和调度?work-stealing scheduler

PS:通过理解GM模型(有点类似生产消费模型)的问题以理解GMP,主要看:《Scalable Go Scheduler Design Doc》会比较清晰。

调度模型演变一:GM模型

GM模型(Go1.2之前的模型),这种模型限制了 Go 并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序。

概念

每个 goroutine 对应于 runtime 中的一个抽象结构:G,而 thread 作为“物理 CPU”的存在而被抽象为一个结构:M(machine)。当 goroutine 调用了一个阻塞的系统调用,运行这个 goroutine 的线程就会被阻塞,这时至少应该再创建一个线程来运行别的没有阻塞的 goroutine。线程这里可以创建不止一个,可以按需不断地创建,而活跃的线程(处于非阻塞状态的线程)的最大个数存储在变量 GOMAXPROCS中。

问题

要知道GM调度模型的问题,必看:《Scalable Go Scheduler Design Doc》

  • 单一全局互斥锁(Sched.Lock)和集中状态存储。导致所有 goroutine 相关操作,比如:创建、结束、重新调度等都要上锁。
  • Goroutine 传递问题。M 经常在 M 之间传递”可运行”的 goroutine,这导致调度延迟增大以及额外的性能损耗(刚创建的 G 放到了全局队列,而不是本地 M 执行,不必要的开销和延迟)
  • Per-M 持有内存缓存 (M.mcache)。每个 M 持有 mcache 和 stack alloc,然而只有在 M 运行 Go 代码时才需要使用的内存(每个 mcache 可以高达2mb),当 M 在处于 syscall 时并不需要。运行 Go 代码和阻塞在 syscall 的 M 的比例高达1:100,造成了很大的浪费。同时内存亲缘性也较差。G 当前在 M运 行后对 M 的内存进行了预热,因为现在 G 调度到同一个 M 的概率不高,数据局部性不好。
  • 严重的线程阻塞/解锁。在系统调用的情况下,工作线程经常被阻塞和取消阻塞,这增加了很多开销。比如 M 找不到G,此时 M 就会进入频繁阻塞/唤醒来进行检查的逻辑,以便及时发现新的 G 来执行。by Dmitry Vyukov “Scalable Go Scheduler Design Doc”

C64C195F-9F90-46D7-8CC3-D67CF1B827C9.jpg

调度模型演变二:GMP模型

为了解决GM全局queue带来的性能问题,引入了P的概念,并在 P 之上实现“可窃取调度”(Work-stealing

7C591415-F30C-4844-A5CB-8EF15B5E4BEB.png

引入P带来的改变

  • 每个 P 有自己的本地队列,大幅度的减轻了对全局队列的直接依赖,所带来的效果就是锁竞争的减少。而 GM 模型的性能开销大头就是锁竞争。
  • 每个 P 相对的平衡上,在 GMP 模型中也实现了 Work Stealing 算法,如果 P 的本地队列为空,则会从全局队列或其他 P 的本地队列中窃取可运行的 G 来运行,减少空转,提高了资源利用率。

为什么要有 P?

如果是想实现本地队列、Work Stealing 算法,那为什么不直接在 M 上加呢,M 也照样可以实现类似的功能),结合 M(系统线程) 的定位来看,若这么做,有以下问题:

  • 一般来讲,M 的数量都会多于 P。像在 Go 中,M 的数量最大限制是 10000,P 的默认数量的 CPU 核数。另外由于 M 的属性,也就是如果存在系统阻塞调用,阻塞了 M,又不够用的情况下,M 会不断增加。
  • M 不断增加的话,如果本地队列挂载在 M 上,那就意味着本地队列也会随之增加。这显然是不合理的,因为本地队列的管理会变得复杂,且 Work Stealing 性能会大幅度下降。
  • M 被系统调用阻塞后,我们是期望把他既有未执行的任务分配给其他继续运行的,而不是一阻塞就导致全部停止。

总结GMP调度模型

调度流程

7C591415-F30C-4844-A5CB-8EF15B5E4BEB.png

    1. 当我们执行 go func() 时,实际上就是创建一个全新的 Goroutine,我们称它为 G。
    1. 新创建的 G 会被放入 P 的本地队列(Local Queue)或全局队列(Global Queue)中,准备下一步的动作。需要注意的一点,这里的 P 指的是创建 G 的 P。
    1. 唤醒或创建 M 以便执行 G。
    1. 不断地进行事件循环
    1. 寻找在可用状态下的 G 进行执行任务
    1. 清除后,重新进入事件循环

PS:在描述中有提到全局和本地这两类队列,其实在功能上来讲都是用于存放正在等待运行的 G,但是不同点在于,本地队列有数量限制,不允许超过 256 个。并且在新建 G 时,会优先选择 P 的本地队列,如果本地队列满了,则将 P 的本地队列的一半的 G 移动到全局队列。这可以理解为调度资源的共享和再平衡。

工作窃取

我们可以看到图上有 steal 行为,这是用来做什么的呢,我们都知道当你创建新的 G 或者 G 变成可运行状态时,它会被推送加入到当前 P 的本地队列中。其实当 P 执行 G 完毕后,它也会 “干活”,它会将其从本地队列中弹出 G,同时会检查当前本地队列是否为空,如果为空会随机的从其他 P 的本地队列中尝试窃取一半可运行的 G 到自己的名下。

756FC8FC-7EBA-4D47-88E9-4336F0742517.png

在这个例子中,P2 在本地队列中找不到可以运行的 G,它会执行 work-stealing 调度算法,随机选择其它的处理器 P1,并从 P1 的本地队列中窃取了三个 G 到它自己的本地队列中去。

至此,P1、P2 都拥有了可运行的 G,P1 多余的 G 也不会被浪费,调度资源将会更加平均的在多个处理器中流转