Go语言 goroutine 调度器原理

 

1. goroutine

goroutine 是 Go 语言实现的用户态线程,主要用来解决操作系统线程太重的问题,主要表现在以下两个方面:

  • 创建和切换太重
    操作系统线程的创建和切换都需要进入内核,而进入内核所消耗的性能代价比较高,开销较大。
  • 内存使用太重
    一方面,为了尽量避免极端情况下操作系统线程栈的溢出,内核在创建操作系统线程时默认会为其分配一个较大的栈内存(虚拟地址空间,内核并不会一开始就分配这么多的物理内存),然而在绝大多数情况下,系统线程远远用不了这么多内存,这导致了浪费;另一方面,栈内存空间一旦创建和初始化完成之后其大小就不能再有变化,这决定了在某些特殊场景下系统线程栈还是有溢出的风险。

用户态 goroutine 则比较轻量:

  • goroutine是用户态线程,其创建和切换都在用户代码中完成而无需进入操作系统内核,所以其开销要远远小于系统线程的创建和切换。
  • goroutine启动时默认栈大小只有2k,这在多数情况下已经够用了,即使不够用,goroutine的栈也会自动扩大,同时,如果栈太大了过于浪费它还能自动收缩,这样既没有栈溢出的风险,也不会造成栈内存空间的大量浪费。

正是因为Go语言中实现了如此轻量级的线程,才使得我们在Go程序中,可以轻易的创建成千上万甚至上百万的goroutine出来并发的执行任务而不用太担心性能和内存等问题。

 

2. 线程模型与调度器

goroutine 建立在操作系统线程基础之上,它与操作系统线程之间实现了一个多对多(M:N)的两级线程模型。

这里的 M:N 是指M个goroutine运行在N个操作系统线程之上,内核负责对这N个操作系统线程进行调度,而这N个系统线程又负责对这M个goroutine进行调度和运行。

所谓的对goroutine的调度,是指程序代码按照一定的算法在适当的时候挑选出合适的goroutine并放到CPU上去运行的过程,这些负责对goroutine进行调度的程序代码我们称之为goroutine调度器。用极度简化了的伪代码来描述goroutine调度器的工作流程大概是下面这个样子:

// 程序启动时的初始化代码
......
// 创建N个操作系统线程执行 schedule 函数 for i := 0; i < N; i++{ // 创建一个操作系统线程执行 schedule 函数 create_os_thread(schedule) } // schedule函数实现调度逻辑 func schedule() { // 调度循环 for { // 根据某种算法从 M 个 goroutine 中找出一个需要运行的 goroutine g = find_a_runnable_goroutine_from_goroutines() // CPU 运行该 goroutine,直到需要调度其它 goroutine 才返回 run_g(g) // 保存 goroutine 的状态,主要是寄存器的值 save_status_of_g(g) } }

这段伪代码表达的意思是,程序运行起来之后创建了N个由内核调度的操作系统线程(为了方便描述,我们称这些系统线程为工作线程)去执行shedule函数,而schedule函数在一个调度循环中反复从M个goroutine中挑选出一个需要运行的goroutine并跳转到该goroutine去运行,直到需要调度其它goroutine时才返回到schedule函数中通过save_status_of_g保存刚刚正在运行的goroutine的状态然后再次去寻找下一个goroutine。

这段伪代码对goroutine的调度代码做了高度的抽象、修改和简化处理,可以帮助我们从宏观上了解 goroutine 的两级调度模型。

 内核对系统线程的调度简单的归纳为:在执行操作系统代码时,内核调度器按照一定的算法挑选出一个线程并把该线程保存在内存之中的寄存器的值放入CPU对应的寄存器从而恢复该线程的运行。万变不离 ...