Go - 协程调度

135 阅读6分钟

1. 前言

Go是并发语言,而不是并行语言。 在讨论如何在Go中进行并发处理之前,我们首先必须了解什么是并发,以及它与并行性有什么不同。

  • 并发性Concurrency:你一边听音乐,一边刷微博,一边聊QQ,一边用Markdown写作业
  • 并行性parallelism:并行就是同时做很多事情。

2. 基本概念

2.1 进程 / 线程 / 协程

  • 进程: 进程是一个程序在一个数据集中的一次动态执行过程,它是CPU资源分配和调度的独立单位。进程一般由程序、数据集、进程控制块三部分组成。

  • 线程: 线程也叫轻量级进程,它是一个基本的CPU执行单元,也是程序执行过程中的最小单元。由线程ID、程序计数器、寄存器集合和堆栈共同组成。一个进程可以包含多个线程。

  • 协程: 协程是一种用户态的轻量级线程,协程的调度完全由用户控制,人们通常将协程和子程序(函数),Go语言gorountine ,占用内存更小(几 kb) 。

2.2 线程 和 协程对比

与传统的系统级线程和进程相比:

  • 协程的最大优势在于其"轻量级",可以轻松创建上百万个而不会导致系统资源衰竭。
  • 协程的执行效率极高,子程序切换不是线程切换,而是由程序自身控制,没有线程切换的开销,线程数量越多协程的性能优势就越明显。

2.3 线程池

在高并发应用中频繁创建线程会造成不必要的开销,所以有了线程池。

  • worker线程执行任务中发生系统调用,则操作系统会将该线程置为阻塞状态,也意味着该线程在怠工,也意味着消费任务队列的worker线程变少了,也就是说线程池消费任务队列的能力变弱了。

  • 如果任务中大部分任务都会进行系统调用,则会让这种状态恶化,大部分worker线程进入阻塞状态,从而任务队列中的任务产生堆积。

解决这个问题:思路就是重新审视线程池中线程的数量,增加线程池中线程数量可以一定程度上提高消费能力,但随着线程数量增多由于过多线程争抢CPU,消费能力会有上限,甚至出现消费能力下降。

3. Goroutine调度器

线程数过多,意味着操作系统会不断地切换线程,频繁的上下文切换就成了性能瓶颈。

Goroutine主要概念如下:

  • G(Goroutine): 即Go协程,每个go关键字都会创建一个协程。
  • M(Machine): 工作线程,在Go中称为Machine。
  • P(Processor): 处理器(Go中定义的一个摡念,不是指CPU),包含运行Go代码的必要资源,也有调度goroutine的能力。

M必须拥有P才可以执行G中的代码,P持有一个G的队列,P可以调度G交由M执行。

  • M是交给操作系统调度的线程:M持有一个P。
  • P维护着一个包含G的队列(图中灰色部分):可以按照一定的策略将G调度到M中执行。

P个数默认情况下等同于CPU的核数,由于M必须持有一个P才可以运行Go代码,也即线程数一般等同于CPU的个数,以达到尽可能的使用多核CPU 而 不至于产生过多的线程切换开销。

4. Goroutine调度策略

4.1 队列轮询

  • P维护着一个包含G的队列:不考虑G进入系统调用或IO操作的情况下,P周期性的将G调度到M中执行,执行一小段时间,将上下文保存下来,然后将G放到队列尾部,然后从队列中重新取出一个G进行调度。一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死

  • 有一个全局的队列:每个P会周期性地查看全局队列中是否有待运行的G,并将其调度到M中执行。

全局队列中G的来源,主要有从系统调用中恢复的G。之所以P会周期性地查看全局队列,也是为了防止全局队列中的G被饿死。

4.2 系统调用

P的个数默认等于CPU核数,每个M必须持有一个P才可以执行G,一般情况下M的个数会略大于P的个数,这多出来的M将会在G产生系统调用时发挥作用。类似线程池Go也提供一个M的池子,需要时从池子中获取,用完放回池子不够用时就再创建一个。

  • M运行的某个G产生系统调用时问题

当G0即将进入系统调用时,M0将释放P,进而某个空闲的M1获取P,继续执行P队列中剩下的G。

M1接替M0的工作,只要P不空闲就可以保证充分利用CPU。

当G0系统调用结束后,根据M0是否能获取到P,将会将G0做不同的处理:

  • 如果有空闲的P,则获取一个P,继续执行G0。
  • 如果没有空闲的P,则将G0放入全局队列,等待被其他的P调度,然后M0将进入缓存池睡眠。

4.3 工作窃取

  • 多个P中维护的G队列有可能是不均衡问题

右边的P已经将G全部执行完,然后去查询全局队列,全局队列中也没有G,而另一个M中除了正在运行的G外,队列中还有3个G待运行。

此时,空闲的P会将其他P中的G偷取一部分过来,一般每次偷取一半继续执行。

5. 协程和线程映射关系

协程 (co-routine) 绑定 线程 (thread) 执行,映射关系是什么?

5.1 1:1 关系

1 个协程绑定 1 个线程,这种最容易实现,协程的调度都由 CPU 完成了。

缺点:协程的创建、删除和切换的代价都由 CPU 完成,有点略显昂贵了。

5.2 N:1 关系

N 个协程绑定 1 个线程,优点就是协程

,不会陷入到内核态,这种切换非常的轻量快速。

缺点:

  • 不能使用到CPU的多核能力。
  • 一旦某协程阻塞造成线程阻塞,导致其他协程都无法执行了,丧失了并发能力。

5.2 M:N 关系

M 个协程绑定 N个线程,是 N:1 和 1:1 类型的结合,克服了以上 2 种模型的缺点,但实现起来最为复杂。

协程跟线程是有区别的,线程由 CPU 调度是抢占式的,协程由用户态调度是协作式的,一个协程让出 CPU 后才执行下一个协程。

6. 链接