协程与线程

进程是CPU分配资源的最小单位,线程是CPU调度的最小单位。
协程本质上是用户态的软件来自己调度自己的“多个线程”(多个并发的工作单位),从而产生以下这些好处:

  1. 协程能够避免不必要的内存浪费,一个协程比一个线程拥有更少的内存资源。
  2. 协程的调度不需要操作系统的参与,即在协程产生调度时,CPU不需要将上下文切换到操作系统的线程调度程序(并再切换回来),减少了切换的消耗。
  3. 简化异步回调的编写,由协程框架来处理异步回调,使阻塞式的函数(IO/同步/互斥)拥有更高的性能。

    go和Python的协程

    go和Python的协程在实现上完全不同。Python的协程依赖yield来保存当前上下文的状态,并主动让出资源,被动等待唤醒。其调度通过event loop来实现,是1:N模型(一个线程对N个协程)
    go在语言层面支持协程,使用MPG模型来实现协程的创建和调度,是N:M模型(N个线程对M个协程)。

    MPG模型

    三角形代表一个工作线程,被称为M,即machine的简称。这是被操作系统管理用来执行代码的线程,工作起来就像标准线程一样。
    圆圈代表一个协程,它被称为G。它包含了栈,指令指针和其它关于调度协程的重要信息。每个关键字go就会创造一个协程。
    正方形代表一个用于调度的上下文,被称为P,即processor的简称。你可以把它看成一个本地化版本(这个“本地”是go的程序相对操作系统而言)的调度器,一方面用于调度线程执行,另一方面是go的调度从N:1模型转变成M:N模型的重要部分。
    Go和Python对比协程,Go和操作系统对比调度 - 图1
    如图所示,我们有2个线程(M),每个线程持有一个上下文(P),每个上下文(P)执行一个协程(G)。为了执行协程,一个线程(M)必须持有一个上下文(P)。

    上下文(P

    上下文(P)的数量在程序启动时通过环境变量GOMAXPROCS指定,或者通过运行时函数GOMAXPROCS()指定。正常情况下在程序运行时这个数量不会变。上下文数量固定,所以在任意时刻只有GOMAXPROCS数量的Go代码在运行。利用这点我们可以调整对Go调用的数量,比如在4核机器上运行4个线程。
    灰色的是已经准备好,可以被调度的协程。每当在代码中运行到go func(),就会将一个新的协程添加到队列的最后。
    当一个线程需要阻塞调用(比如一些系统调用)的时候,我们需要提交给操作系统一个线程(因为操作系统只认识线程,不认识协程)。此时就将当下这个M交给操作系统,同时P去找/创建一个空闲的M来接着运行协程队列。
    M0执行结束,M0会尝试从其他M上获取一个P。如果拿不到,则将这个G放进全局协程队列。将M(自己)放进线程池,等待下一次P来宠幸它。当上下文(P)的G队列空时,会从全局队列中获取,且上下文(P)也会周期性地从全局队列中获取。
    总结一下,上下文(P)的数量是有限且固定的。每个P都绑定着一个可以执行的M(线程)。M的数量大于等于P,且存在一个线程池,避免重复创建线程。
    runtime.p 是处理器的运行时表示,它包含的字段也非常多,其中包括与性能追踪、垃圾回收和计时器相关的字段,这些字段也非常重要,但是在这里就不展示了,我们主要关注处理器中的线程和运行队列: ```java

type p struct { id int32 status uint32 // 状态 m muintptr // 绑定的M

  1. // Queue of runnable goroutines. Accessed without lock.
  2. runqhead uint32 // 持有的运行队列的头
  3. runqtail uint32 // 持有的运行队列的尾
  4. runq [256]guintptr // 持有的运行队列
  5. runnext guintptr // 线程下一个需要执行的 Goroutine
  6. ...

}

  1. [runtime.p](https://link.juejin.cn?target=https%3A%2F%2Fdraveness.me%2Fgolang%2Ftree%2Fruntime.p) 结构体中的状态 status 字段会是以下五种中的一种:
  2. | 状态 | 描述 |
  3. | --- | --- |
  4. | _Pidle | 处理器没有运行用户代码或者调度器,被空闲队列或者改变其状态的结构持有,运行队列为空 |
  5. | _Prunning | 被线程 M 持有,并且正在执行用户代码或者调度器 |
  6. | _Psyscall | 没有执行用户代码,当前线程陷入系统调用 |
  7. | _Pgcstop | 被线程 M 持有,当前处理器由于垃圾回收被停止 |
  8. | _Pdead | 当前处理器已经不被使用 |
  9. <a name="xp767"></a>
  10. ## 线程(**M**)
  11. 线程(**M**)的数量最多有10000个,但是最多只会有GOMAXPROCS()个线程在工作(因为只有GOMAXPROCS()个**P**),其他线程要么陷入了操作系统的系统调用,那么在线程池内等待**P**的宠幸。
  12. ```java
  13. type m struct {
  14. g0 *g // 持有调度栈的 Goroutine
  15. curg *g // 当前线程上运行的用户 Goroutine
  16. p puintptr // 正在运行代码的处理器
  17. nextp puintptr // 暂存的处理器
  18. oldp puintptr // 执行系统调用之前使用线程的处理器
  19. ...
  20. }

g0 是一个运行时中比较特殊的 Goroutine,它会深度参与运行时的调度过程,包括 Goroutine 的创建、大内存分配和 CGO 函数的执行。在后面的小节中,我们会经常看到 g0 的身影。

协程(G

Goroutine 在 Go 语言运行时使用私有结构体 runtime.g表示。协程和线程的结构有相似之处,比如都需要程序计数器、堆栈地址、一个id等等。

调度

总览-和进程调度相比

不同

操作系统

  1. 可以高度依赖底层硬件;
  2. 需要同时考虑长任务和短任务;
  3. 考虑周转时间和响应时间;
  4. 对运行的代码的具体内容没有控制权;

Goland

  1. 尽量不依赖底层硬件(因为一旦依赖,就免不了被操作系统捕获,CPU从用户态到内核态再到用户态,就丧失了减少CPU上下文切换的初衷);
  2. Goland从设计上,优先考虑的大量短任务;
  3. Goland从设计上,优先考虑周转时间;
  4. 知道每个协程在做什么,可以在编译时和运行时对调度做优化;

    相同

  5. 都要考虑多核下,众多调度单元之间调度平衡;

  6. 有共同的困境:如果一个进程在CPU上运行,这就意味着操作系统没有运行,如果操作系统没有运行,它怎么能做别的事情,比如进程进程调度?

    怎么实现调度

    协作式抢占调度

  7. 编译器会在调用函数前插入 runtime.morestack;

  8. 当发生函数调用时,可能会执行编译器插入的 runtime.morestack,它调用的 runtime.newstack 会检查 Goroutine 的 stackguard0 字段是否为 StackPreempt;
  9. 如果 stackguard0 是 StackPreempt,就会触发抢占让出当前线程;

这种实现方式虽然增加了运行时的复杂度,但是实现相对简单,也没有带来过多的额外开销,总体来看还是比较成功的实现,也在 Go 语言中使用了十几个版本。因为这里的抢占是通过编译器插入函数实现的,还是需要函数调用作为入口才能触发抢占,所以这是一种协作式的抢占式调度

非协作式抢占调度

协作式抢占有很多无法处理的情况,比如

  1. 一个无限自循环的协程;
  2. 程序正在运行C语言代码;
  3. ……

因此非协作式抢占调度非常有必要。但是和操作系统的调度相似,这件事情没法通过Go程序自己完成,也要借助操作系统(就像操作系统需要借助硬件的时钟和注册陷阱处理函数一样),因此非协作式抢占会在有限的情况下被迫使用。

  1. 程序启动时注册 SIGURG 信号的处理函数 runtime.doSigPreempt;
  2. 在触发垃圾回收的栈扫描时会调用runtime.suspendG挂起 Goroutine,该函数会执行下面的逻辑:
    1. 将 _Grunning 状态的 Goroutine 标记成可以被抢占,即将 preemptStop 设置成 true;
    2. 调用 runtime.preemptM 触发抢占;
  3. runtime.preemptM 会向线程发送信号 SIGURG;
  4. 操作系统会中断正在运行的线程并执行预先注册的信号处理函数 runtime.doSigPreempt;
  5. runtime.doSigPreempt函数会调用一系列函数,处理抢占信号,获取当前的 SP 和 PC 寄存器并保存这些状态,修改当前 Goroutine 的状态到 _Gpreempted 并调用 runtime.schedule让当前函数陷入休眠并让出线程,调度器会选择其它的 Goroutine 继续执行;

    窃取任务

    上面两种都是一个协程队列中,怎么在协程不主动等待的情况下,实现协程间的调度。然后Go程序中,可能还会发生跨协程队列的运行不平衡:和CFS类似,可能有的P + M已经运行完所有的G了,而有的P + M则还有好多G在运行。因此,Go也采用了和CFS类似的窃取任务机制。
    但是在具体的窃取策略上,Go的策略和CFS并不相同。
  • CFS会隔一段时间,就主动从别的CPU的进程队列中偷一些任务,并且还有一套挑选CPU和进程的规则,涉及到一段时间内CPU的占用率等等。
  • Go的策略则简单的多。只有在当前P的协程队列和全局协程队列都为空时,才会去偷一些协程。同时通过一些检查+随机,随机挑选出一个P(不涉及什么指标),偷取的协程也没什么规则,直接拿走一半的协程。

注意,Go的策略相比CFS简单,有调度要求方面的优势,也有Go的策略发展时间不长的劣势。CFS在调度时会考虑程序在CPU的缓存,提高数据局部性来提高效率。而Go也有这方面的需求,只是受限于Go的发展时间太短,暂时还没实现这一部分。

调度的时机

更加准确的定义:什么时候,M上的G会发生改变

  1. 创建了新的协程时,有可能会发生调度。
  2. 协程主动等待,包括被chan阻塞、sleep等
  3. 发生了系统调用,导致当前线程被阻塞
  4. 调用新的函数时,会触发runtime.morestack,在这个函数内会,会检查stackguard0 字段是否为 StackPreempt。如果时,则这个协程会被调度。GO程序会在以下两种情况下将stackguard0 字段设置为 StackPreempt
    1. GC时,GC需要暂停当前协程;
    2. 系统监控发现 Goroutine 运行超过 10ms 时;
  5. 垃圾回收的栈扫描时,当前M会被设置为可抢占的,并向该线程发送信号SIGURG,进而触发预先注册的处理函数 runtime.doSigPreempt。

    调度的策略

    在实现调度和调度的时机中提到了一点调度的策略,这里做一下整理和完善。

    P与P之间的协调

  6. 当本地P的协程队列为空时,从全局队列中获取;

    1. 当全局协程队列为空时,尝试从别的P中偷取协程。
  7. 当本地P的协程队列占满时,将新创建的协程放入全局协程队列。
  8. 在从本地P的协程队列中获取线程前,有概率从全局队列中获取一个协程。

    P本地队列的协调

    顺序获取

    参考资料

    morsmachine.dk/go-schedule…
    draveness.me/golang/docs…
    github.com/golang/go/b…
    zhuanlan.zhihu.com/p/323271088
    《GO专家编程》