目录

1.Go调度器系列-起源

远古时代

世界上第一台通用计算机ENIAC,和现代的计算机相比是非常笨重的,它的计算能力就算跟现在的智能手机相比都是一个天上一个地下,ENIAC在地下,智能手机在天上。它上面没有操作系统,更没有进程、线程和协程。

进程时代

后来,现代化的计算机有了操作系统,每个程序都是一个进程,但是操作系统在一段时间内只能运行一个进程,直到这个进程运行完,才能运行下一个进程,这个时期可以称为单进程时代–串行时代。

和ENIAC相比,单进程有了几万倍的提速,但是依然很慢,比如进程要读数据阻塞了,CPU就在那浪费着,怎样才能充分的利用CPU成了程序员思考的问题。

再后来,操作系统就具备了最早的并发能力:多进程并发,当一个进程阻塞的时候,切换到另外等待执行的进程,这样就能尽量把CPU利用起来,CPU就不浪费了。

线程时代

多进程确实很好,有了对进程的调度能力。但是进程拥有太多资源,在创建、切换和销毁的时候,都会占用很长的时间,CPU虽然利用起来了,但是CPU有很大的一部分都被用来进行进程调度。这时大家希望有一种轻量级的进程,调度不怎么花时间,这样CPU就有更多的时间用在执行任务上。

后来,操作系统支持了线程,线程在进程里面,线程运行所需要的资源比进程少很多,切换起来也很快。

一个进程可以有多个线程,CPU在执行调度的时候切换的是线程,如果下一个线程也是当前进程的,就只有线程切换,很快就能完成,如果下一个线程不是当前进程的,就需要切换进程,相对来说就得费点时间。

这个时代,CPU的调度切换的是进程和线程,多线程看起来很美好,但是实际多线程编程很复杂,一是由于线程的设计本身就很复杂,二是由于需要考虑很多底层细节,比如锁和冲突检测。

协程

多进程、多线程已经提高了系统的并发能力,但是在当今互联网高并发场景下,为每个任务创建一个线程是不现实的,因为会消耗大量的内存(每个线程的内存占用级别是MB),现成多了之后调度也会消耗大量的CPU,这个时候,如何才能充分利用CPU、内存等资源的情况下,实现更高的并发成了程序员思考的问题。

既然线程的资源占用、调度在高并发的情况下,依然是比较大的,那是否有一种东西,更加轻量?那就是协程。

线程分为内核态线程和用户态线程,用户态线程需要绑定内核态线程,CPU并不能感知用户态线程的存在,它只知道他在运行一个线程,这个线程实际是内核态线程。

用户态线程实际有一个名字叫协程(co-routine),为了容易区分,我们使用协程指用户态线程,使用线程指内核态线程。

User-level threads, Application-level threads, Green threads都指一样的东西,就是不受OS感知的线程,如果你Google coroutine相关的资料,会看到它指的就是用户态线程,在Green threads的维基百科 里,看Green threads的实现列表,你会看到好很多coroutine实现,比如Java、Lua、Go、Erlang、Common Lisp、Haskell、Rust、PHP、Stackless Python,所以,我认为用户态线程就是协程。

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

协程和线程有3种映射关系:

  • N:1,N个协程绑定1个线程,优点就是协程在用户态线程就完成了切换,不会陷入到内核态,这种切换非常的轻量快速。但是也有很大的缺点,1个进程的所有协程都绑定在1个线程上,一是某个程序用不了硬件的多核加速能力,二是一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,根本就没有并发的能力了。

  • 1:1,1个协程绑定一个线程,这种最容易实现。协程的调度都有CPU完成了,不存在N:1的缺点,但是有一个缺点就是协程的创建、删除和切换的代价都有CPU完成,有点略显昂贵。

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

https://file.yingnan.wang/golang/coroutine-thread-map.png

协程是个好东西,因此不少语言都支持了协程,比如Lua、Erlang、Java,就算语言不支持,也有库支持协程,比如C语言、Kotlin和Python都有相应的协程库。

goroutine

Go语言的诞生就是为了支持高并发,有两个支持高并发的模型:CSP和Actor。鉴于Occam和Erlang都选用了CSP,并且效果还不错,Go也选了CSP,但是与前两者不同的是,Go把channel作为头等公民。

前面说过多线程编程不太友好,Go为了提供更容易使用的并发方式,使用了goroutine和channel,goroutine来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被runtime调度,转移到其他可运行的线程上。最关键的是使用者看不到这些底层实现的细节,这就降低了编程的难度,提供了更容易的并发。

Go中,协程被称为goroutine,它非常轻量,一个goroutine只占几KB,并且这几KB就足够goroutine运行完,这就能在有限的内存空间内支持大量goroutine,支持更多的并发,虽然一个goroutine的栈只占几KB,但是实际是可以伸缩的,如果需要更多的内容,runtime会自动为goroutine分配。

Go语言的老调度器

调度器的任务是在用户态完成goroutine的调度,而调度器的实现好坏,对并发实际有很大的影响,并且Go的调度器就是M:N类型的,实现起来也是最复杂的。

在2012年之前实现的Go语言调度器称为老调度器,老调度器的实现不太好,存在性能问题,所以用了四年左右就被替换掉了,老调度器大概是下面的样子:

https://file.yingnan.wang/golang/golang-old-scheduler.png

最下面的是操作系统,中间的是runtime,runtime在Go中很重要,许多程序运行时的工作都是由runtime完成,调度器就是runtime的一部分,虚线圈出来的就是调度器,它有两个重要的组成部分:

  • M,代表线程,它要运行goroutine。

  • Global G Queue,是全局goroutine队列,所有的goroutine都保存在这个队列中,goroutine用G进行代表。

M想要执行、放回G都必须访问全局G队列,并且M有多个,即多线程访问同一个资源需要加锁进行保护互斥/同步,所以全局G队列是有互斥锁进行保护的。

老调度器有4个缺点:

  1. 创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争。

  2. M转移G会造成延迟和额外的系统负载。比如当G中包含创建创建新协程的时候,M创建了G’,为了继续执行G,需要把G’交给M’执行,也造成了很差的局部性,因为G’和G是相关的,最好放在M上执行,而不是其他M’。

  3. M中的mcache是用来存放小对象的,mcache和栈都和M关联造成了大量的内存开销和差的局部性。

  4. 系统调用导致频繁的线程阻塞和取消阻塞增加了系统开销。

Go语言的新调度器

Go语言新调度器引入了:

  • P:Processor,它包含了运行goroutine的资源,如果线程想运行goroutine,必须先获取P,P中还包含了可运行的G队列,

  • work stealing:当M绑定的P没有可运行的G时,它可以从其他运行的M’那里偷取G。

调度器中3个重要的缩写:

  • G:goroutine

  • M:工作线程

  • P:处理器,它包含了运行Go代码的资源,M必须和一个P关联才能运行G。

调度器的两大思想:

  • 复用线程:协程本身就是运行在一组线程之上,不需要频繁的创建、销毁线程,而是对线程的复用。在调度器中复用线程有两个体现:

    • work stealing,当本线程无可运行的G时,尝试从其他线程绑定的P中偷取G,而不是销毁线程。

    • hand off,当本线程因为G进行系统调用阻塞时,现成释放绑定的P,把P转移给其他空闲的线程执行。

  • 利用并行:GOMAXPROCS设置P的数量,当GOMAXPROCS大于1时,就最多有GOMAXPROCS个线程处于运行状态,这些线程可能分布在多个CPU核上同时运行,使得并发利用并行。另外,GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行。

调度器的两小策略:

  • 抢占:在coroutine中要等待一个协程主动让出CPU才能执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死,这就是goroutine不同于coroutine的一个地方。

  • 全局G队列:在新的调度器中依然有全局G队列,但功能已经被弱化了,当P的本地队列为空时,他可以从全局G队列中获取G。如果全局队列也为空,执行work stealing从其他P偷取G。

关于并行和并发,Go创始人Rob Pike一直在强调go是并发,不是并行,因为Go做的是在一段时间内完成几十万、甚至上百万的工作,而不是同一时间同时在做大量的工作。并发可以利用并行提高效率,调度器是有并行设计的。并行依赖多核技术,每个核上在某个时间只能执行一个线程,当我们的CPU有8个核时,我们能同时执行8个线程,这就是并行。