http://www.sizeofvoid.net/goroutine-under-the-hood/html
o语言从诞生到普及已经三年了,先行者大都是Web开发的背景,也有了一些普及型的书籍,可系统开发背景的人在学习这些书籍的时候,总有语焉不详的感受,网上也有若干流传甚广的文章,可其中或多或少总有些与事实不符的技术描述。但愿这篇文章能为比较缺乏系统编程背景的Web开发人员介绍一下goroutine背后的系统知识。linux
1. 操做系统与运行库
2. 并发与并行 (Concurrency and Parallelism)
3. 线程的调度
4. 并发编程框架
5. goroutineios
1. 操做系统与运行库golang
对于普通的电脑用户来讲,能理解应用程序是运行在操做系统之上就足够了,可对于开发者,咱们还须要了解咱们写的程序是如何在操做系统之上运行起来的,操做系统如何为应用程序提供服务,这样咱们才能分清楚哪些服务是操做系统提供的,而哪些服务是由咱们所使用的语言的运行库提供的。编程
除了内存管理、文件管理、进程管理、外设管理等等内部模块之外,操做系统还提供了许多外部接口供应用程序使用,这些接口就是所谓的“系统调用”。从DOS时代开始,系统调用就是经过软中断的形式来提供,也就是著名的INT 21,程序把须要调用的功能编号放入AH寄存器,把参数放入其余指定的寄存器,而后调用INT 21,中断返回后,程序从指定的寄存器(一般是AL)里取得返回值。这样的作法一直到奔腾2也就是P6出来以前都没有变,譬如windows经过INT 2E提供系统调用,Linux则是INT 80,只不事后来的寄存器比之前大一些,并且可能再多一层跳转表查询。后来,Intel和AMD分别提供了效率更高的SYSENTER/SYSEXIT和SYSCALL/SYSRET指令来代替以前的中断方式,略过了耗时的特权级别检查以及寄存器压栈出栈的操做,直接完成从RING 3代码段到RING 0代码段的转换。c#
系统调用都提供什么功能呢?用操做系统的名字加上对应的中断编号到谷歌上一查就能够获得完整的列表 (Windows, Linux),这个列表就是操做系统和应用程序之间沟通的协议,若是须要超出此协议的功能,咱们就只能在本身的代码里去实现,譬如,对于内存管理,操做系统只提供进程级别的内存段的管理,譬如Windows的virtualmemory系列,或是Linux的brk,操做系统不会去在意应用程序如何为新建对象分配内存,或是如何作垃圾回收,这些都须要应用程序本身去实现。若是超出此协议的功能没法本身实现,那咱们就说该操做系统不支持该功能,举个例子,Linux在2.6以前是不支持多线程的,不管如何在程序里模拟,咱们都没法作出多个能够同时运行的并符合POSIX 1003.1c语义标准的调度单元。windows
但是,咱们写程序并不须要去调用中断或是SYSCALL指令,这是由于操做系统提供了一层封装,在Windows上,它是NTDLL.DLL,也就是常说的Native API,咱们不但不须要去直接调用INT 2E或SYSCALL,准确的说,咱们不能直接去调用INT 2E或SYSCALL,由于Windows并无公开其调用规范,直接使用INT 2E或SYSCALL没法保证将来的兼容性。在Linux上则没有这个问题,系统调用的列表都是公开的,并且Linus很是看重兼容性,不会去作任何更改,glibc里甚至专门提供了syscall(2)来方便用户直接用编号调用,不过,为了解决glibc和内核之间不一样版本兼容性带来的麻烦,以及为了提升某些调用的效率(譬如__NR_ gettimeofday),Linux上仍是对部分系统调用作了一层封装,就是VDSO(早期叫linux-gate.so)。api
但是,咱们写程序也不多直接调用NTDLL或者VDSO,而是经过更上一层的封装,这一层处理了参数准备和返回值格式转换、以及出错处理和错误代码转换,这就是咱们所使用语言的运行库,对于C语言,Linux上是glibc,Windows上是kernel32(或调用msvcrt),对于其余语言,譬如Java,则是JRE,这些“其余语言”的运行库一般最终仍是调用glibc或kernel32。缓存
“运行库”这个词其实不止包括用于和编译后的目标执行程序进行连接的库文件,也包括了脚本语言或字节码解释型语言的运行环境,譬如Python,C#的CLR,Java的JRE。网络
对系统调用的封装只是运行库的很小一部分功能,运行库一般还提供了诸如字符串处理、数学计算、经常使用数据结构容器等等不须要操做系统支持的功能,同时,运行库也会对操做系统支持的功能提供更易用更高级的封装,譬如带缓存和格式的IO、线程池。
因此,在咱们说“某某语言新增了某某功能”的时候,一般是这么几种可能:
1. 支持新的语义或语法,从而便于咱们描述和解决问题。譬如Java的泛型、Annotation、lambda表达式。
2. 提供了新的工具或类库,减小了咱们开发的代码量。譬如Python 2.7的argparse
3. 对系统调用有了更良好更全面的封装,使咱们能够作到之前在这个语言环境里作不到或很难作到的事情。譬如Java NIO
但任何一门语言,包括其运行库和运行环境,都不可能创造出操做系统不支持的功能,Go语言也是这样,无论它的特性描述看起来多么炫丽,那必然都是其余语言也能够作到的,只不过Go提供了更方便更清晰的语义和支持,提升了开发的效率。
2. 并发与并行 (Concurrency and Parallelism)
并发是指程序的逻辑结构。非并发的程序就是一根竹竿捅到底,只有一个逻辑控制流,也就是顺序执行的(Sequential)程序,在任什么时候刻,程序只会处在这个逻辑控制流的某个位置。而若是某个程序有多个独立的逻辑控制流,也就是能够同时处理(deal)多件事情,咱们就说这个程序是并发的。这里的“同时”,并不必定要是真正在时钟的某一时刻(那是运行状态而不是逻辑结构),而是指:若是把这些逻辑控制流画成时序流程图,它们在时间线上是能够重叠的。
并行是指程序的运行状态。若是一个程序在某一时刻被多个CPU流水线同时进行处理,那么咱们就说这个程序是以并行的形式在运行。(严格意义上讲,咱们不能说某程序是“并行”的,由于“并行”不是描述程序自己,而是描述程序的运行状态,但这篇小文里就不那么咬文嚼字,如下说到“并行”的时候,就是指代“以并行的形式运行”)显然,并行必定是须要硬件支持的。
并且不难理解:
1. 并发是并行的必要条件,若是一个程序自己就不是并发的,也就是只有一个逻辑控制流,那么咱们不可能让其被并行处理。
2. 并发不是并行的充分条件,一个并发的程序,若是只被一个CPU流水线进行处理(经过分时),那么它就不是并行的。
3. 并发只是更符合现实问题本质的表达方式,并发的最初目的是简化代码逻辑,而不是使程序运行的更快;
这几段略微抽象,咱们能够用一个最简单的例子来把这些概念实例化:用C语言写一个最简单的HelloWorld,它就是非并发的,若是咱们创建多个线程,每一个线程里打印一个HelloWorld,那么这个程序就是并发的,若是这个程序运行在老式的单核CPU上,那么这个并发程序还不是并行的,若是咱们用多核多CPU且支持多任务的操做系统来运行它,那么这个并发程序就是并行的。
还有一个略微复杂的例子,更能说明并发不必定能够并行,并且并发不是为了效率,就是Go语言例子里计算素数的sieve.go。咱们从小到大针对每个因子启动一个代码片断,若是当前验证的数能被当前因子除尽,则该数不是素数,若是不能,则把该数发送给下一个因子的代码片断,直到最后一个因子也没法除尽,则该数为素数,咱们再启动一个它的代码片断,用于验证更大的数字。这是符合咱们计算素数的逻辑的,并且每一个因子的代码处理片断都是相同的,因此程序很是的简洁,但它没法被并行,由于每一个片断都依赖于前一个片断的处理结果和输出。
并发能够经过如下方式作到:
1. 显式地定义并触发多个代码片断,也就是逻辑控制流,由应用程序或操做系统对它们进行调度。它们能够是独立无关的,也能够是相互依赖须要交互的,譬如上面提到的素数计算,其实它也是个经典的生产者和消费者的问题:两个逻辑控制流A和B,A产生输出,当有了输出后,B取得A的输出进行处理。线程只是实现并发的其中一个手段,除此以外,运行库或是应用程序自己也有多种手段来实现并发,这是下节的主要内容。
2. 隐式地放置多个代码片断,在系统事件发生时触发执行相应的代码片断,也就是事件驱动的方式,譬如某个端口或管道接收到了数据(多路IO的状况下),再譬如进程接收到了某个信号(signal)。
并行能够在四个层面上作到:
1. 多台机器。天然咱们就有了多个CPU流水线,譬如Hadoop集群里的MapReduce任务。
2. 多CPU。无论是真的多颗CPU仍是多核仍是超线程,总之咱们有了多个CPU流水线。
3. 单CPU核里的ILP(Instruction-level parallelism),指令级并行。经过复杂的制造工艺和对指令的解析以及分支预测和乱序执行,如今的CPU能够在单个时钟周期内执行多条指令,从而,即便是非并发的程序,也多是以并行的形式执行。
4. 单指令多数据(Single instruction, multiple data. SIMD),为了多媒体数据的处理,如今的CPU的指令集支持单条指令对多条数据进行操做。
其中,1牵涉到分布式处理,包括数据的分布和任务的同步等等,并且是基于网络的。3和4一般是编译器和CPU的开发人员须要考虑的。这里咱们说的并行主要针对第2种:单台机器内的多核CPU并行。
关于并发与并行的问题,Go语言的做者Rob Pike专门就此写过一个幻灯片:http://talks.golang.org/2012/waza.slide
在CMU那本著名的《Computer Systems: A Programmer’s Perspective》里的这张图也很是直观清晰:
3. 线程的调度
上一节主要说的是并发和并行的概念,而线程是最直观的并发的实现,这一节咱们主要说操做系统如何让多个线程并发的执行,固然在多CPU的时候,也就是并行的执行。咱们不讨论进程,进程的意义是“隔离的执行环境”,而不是“单独的执行序列”。
咱们首先须要理解IA-32 CPU的指令控制方式,这样才能理解如何在多个指令序列(也就是逻辑控制流)之间进行切换。CPU经过CS:EIP寄存器的值肯定下一条指令的位置,可是CPU并不容许直接使用MOV指令来更改EIP的值,必须经过JMP系列指令、CALL/RET指令、或INT中断指令来实现代码的跳转;在指令序列间切换的时候,除了更改EIP以外,咱们还要保证代码可能会使用到的各个寄存器的值,尤为是栈指针SS:ESP,以及EFLAGS标志位等,都可以恢复到目标指令序列上次执行到这个位置时候的状态。
线程是操做系统对外提供的服务,应用程序能够经过系统调用让操做系统启动线程,并负责随后的线程调度和切换。咱们先考虑单颗单核CPU,操做系统内核与应用程序实际上是也是在共享同一个CPU,当EIP在应用程序代码段的时候,内核并无控制权,内核并非一个进程或线程,内核只是以实模式运行的,代码段权限为RING 0的内存中的程序,只有当产生中断或是应用程序呼叫系统调用的时候,控制权才转移到内核,在内核里,全部代码都在同一个地址空间,为了给不一样的线程提供服务,内核会为每个线程创建一个内核堆栈,这是线程切换的关键。一般,内核会在时钟中断里或系统调用返回前(考虑到性能,一般是在不频繁发生的系统调用返回前),对整个系统的线程进行调度,计算当前线程的剩余时间片,若是须要切换,就在“可运行”的线程队列里计算优先级,选出目标线程后,则保存当前线程的运行环境,并恢复目标线程的运行环境,其中最重要的,就是切换堆栈指针ESP,而后再把EIP指向目标线程上次被移出CPU时的指令。Linux内核在实现线程切换时,耍了个花枪,它并非直接JMP,而是先把ESP切换为目标线程的内核栈,把目标线程的代码地址压栈,而后JMP到__switch_to(),至关于伪造了一个CALL __switch_to()指令,而后,在__switch_to()的最后使用RET指令返回,这样就把栈里的目标线程的代码地址放入了EIP,接下来CPU就开始执行目标线程的代码了,其实也就是上次停在switch_to这个宏展开的地方。
这里须要补充几点:(1) 虽然IA-32提供了TSS (Task State Segment),试图简化操做系统进行线程调度的流程,但因为其效率低下,并且并非通用标准,不利于移植,因此主流操做系统都没有去利用TSS。更严格的说,其实仍是用了TSS,由于只有经过TSS才能把堆栈切换到内核堆栈指针SS0:ESP0,但除此以外的TSS的功能就彻底没有被使用了。(2) 线程从用户态进入内核的时候,相关的寄存器以及用户态代码段的EIP已经保存了一次,因此,在上面所说的内核态线程切换时,须要保存和恢复的内容并很少。(3) 以上描述的都是抢占式(preemptively)的调度方式,内核以及其中的硬件驱动也会在等待外部资源可用的时候主动调用schedule(),用户态的代码也能够经过sched_yield()系统调用主动发起调度,让出CPU。
如今咱们一台普通的PC或服务里一般都有多颗CPU (physical package),每颗CPU又有多个核 (processor core),每一个核又能够支持超线程 (two logical processors for each core),也就是逻辑处理器。每一个逻辑处理器都有本身的一套完整的寄存器,其中包括了CS:EIP和SS:ESP,从而,以操做系统和应用的角度来看,每一个逻辑处理器都是一个单独的流水线。在多处理器的状况下,线程切换的原理和流程其实和单处理器时是基本一致的,内核代码只有一份,当某个CPU上发生时钟中断或是系统调用时,该CPU的CS:EIP和控制权又回到了内核,内核根据调度策略的结果进行线程切换。但在这个时候,若是咱们的程序用线程实现了并发,那么操做系统可使咱们的程序在多个CPU上实现并行。
这里也须要补充两点:(1) 多核的场景里,各个核之间并非彻底对等的,譬如在同一个核上的两个超线程是共享L1/L2缓存的;在有NUMA支持的场景里,每一个核访问内存不一样区域的延迟是不同的;因此,多核场景里的线程调度又引入了“调度域”(scheduling domains)的概念,但这不影响咱们理解线程切换机制。(2) 多核的场景下,中断发给哪一个CPU?软中断(包括除以0,缺页异常,INT指令)天然是在触发该中断的CPU上产生,而硬中断则又分两种状况,一种是每一个CPU本身产生的中断,譬如时钟,这是每一个CPU处理本身的,还有一种是外部中断,譬如IO,能够经过APIC来指定其送给哪一个CPU;由于调度程序只能控制当前的CPU,因此,若是IO中断没有进行均匀的分配的话,那么和IO相关的线程就只能在某些CPU上运行,致使CPU负载不均,进而影响整个系统的效率。
4. 并发编程框架
以上大概介绍了一个用多线程来实现并发的程序是如何被操做系统调度以及并行执行(在有多个逻辑处理器时),同时你们也能够看到,代码片断或者说逻辑控制流的调度和切换其实并不神秘,理论上,咱们也能够不依赖操做系统和其提供的线程,在本身程序的代码段里定义多个片断,而后在咱们本身程序里对其进行调度和切换。
为了描述方便,咱们接下来把“代码片断”称为“任务”。
和内核的实现相似,只是咱们不须要考虑中断和系统调用,那么,咱们的程序本质上就是一个循环,这个循环自己就是调度程序schedule(),咱们须要维护一个任务的列表,根据咱们定义的策略,先进先出或是有优先级等等,每次从列表里挑选出一个任务,而后恢复各个寄存器的值,而且JMP到该任务上次被暂停的地方,全部这些须要保存的信息均可以做为该任务的属性,存放在任务列表里。
看起来很简单啊,但是咱们还须要解决几个问题:
(1) 咱们运行在用户态,是没有中断或系统调用这样的机制来打断代码执行的,那么,一旦咱们的schedule()代码把控制权交给了任务的代码,咱们下次的调度在何时发生?答案是,不会发生,只有靠任务主动调用schedule(),咱们才有机会进行调度,因此,这里的任务不能像线程同样依赖内核调度从而毫无顾忌的执行,咱们的任务里必定要显式的调用schedule(),这就是所谓的协做式(cooperative)调度。(虽然咱们能够经过注册信号处理函数来模拟内核里的时钟中断并取得控制权,可问题在于,信号处理函数是由内核调用的,在其结束的时候,内核从新得到控制权,随后返回用户态并继续沿着信号发生时被中断的代码路径执行,从而咱们没法在信号处理函数内进行任务切换)
(2) 堆栈。和内核调度线程的原理同样,咱们也须要为每一个任务单独分配堆栈,而且把其堆栈信息保存在任务属性里,在任务切换时也保存或恢复当前的SS:ESP。任务堆栈的空间能够是在当前线程的堆栈上分配,也能够是在堆上分配,但一般是在堆上分配比较好:几乎没有大小或任务总数的限制、堆栈大小能够动态扩展(gcc有split stack,但太复杂了)、便于把任务切换到其余线程。
到这里,咱们大概知道了如何构造一个并发的编程框架,可如何让任务能够并行的在多个逻辑处理器上执行呢?只有内核才有调度CPU的权限,因此,咱们仍是必须经过系统调用建立线程,才能够实现并行。在多线程处理多任务的时候,咱们还须要考虑几个问题:
(1) 若是某个任务发起了一个系统调用,譬如长时间等待IO,那当前线程就被内核放入了等待调度的队列,岂不是让其余任务都没有机会执行?
在单线程的状况下,咱们只有一个解决办法,就是使用非阻塞的IO系统调用,并让出CPU,而后在schedule()里统一进行轮询,有数据时切换回该fd对应的任务;效率略低的作法是不进行统一轮询,让各个任务在轮到本身执行时再次用非阻塞方式进行IO,直到有数据可用。
若是咱们采用多线程来构造咱们整个的程序,那么咱们能够封装系统调用的接口,当某个任务进入系统调用时,咱们就把当前线程留给它(暂时)独享,并开启新的线程来处理其余任务。
(2) 任务同步。譬如咱们上节提到的生产者和消费者的例子,如何让消费者在数据尚未被生产出来的时候进入等待,而且在数据可用时触发消费者继续执行呢?
在单线程的状况下,咱们能够定义一个结构,其中有变量用于存放交互数据自己,以及数据的当前可用状态,以及负责读写此数据的两个任务的编号。而后咱们的并发编程框架再提供read和write方法供任务调用,在read方法里,咱们循环检查数据是否可用,若是数据还不可用,咱们就调用schedule()让出CPU进入等待;在write方法里,咱们往结构里写入数据,更改数据可用状态,而后返回;在schedule()里,咱们检查数据可用状态,若是可用,则激活须要读取此数据的任务,该任务继续循环检测数据是否可用,发现可用,读取,更改状态为不可用,返回。代码的简单逻辑以下:
struct chan { bool ready, int data }; int read (struct chan *c) { while (1) { if (c->ready) { c->ready = false; return c->data; } else { schedule(); } } } void write (struct chan *c, int i) { while (1) { if (c->ready) { schedule(); } else { c->data = i; c->ready = true; schedule(); // optional return; } } }
很显然,若是是多线程的话,咱们须要经过线程库或系统调用提供的同步机制来保护对这个结构体内数据的访问。
以上就是最简化的一个并发框架的设计考虑,在咱们实际开发工做中遇到的并发框架可能因为语言和运行库的不一样而有所不一样,在功能和易用性上也可能各有取舍,但底层的原理都是异曲同工。
譬如,glic里的getcontext/setcontext/swapcontext系列库函数能够方便的用来保存和恢复任务执行状态;Windows提供了Fiber系列的SDK API;这两者都不是系统调用,getcontext和setcontext的man page虽然是在section 2,但那只是SVR4时的历史遗留问题,其实现代码是在glibc而不是kernel;CreateFiber是在kernel32里提供的,NTDLL里并无对应的NtCreateFiber。
在其余语言里,咱们所谓的“任务”更多时候被称为“协程”,也就是Coroutine。譬如C++里最经常使用的是Boost.Coroutine;Java由于有一层字节码解释,比较麻烦,但也有支持协程的JVM补丁,或是动态修改字节码以支持协程的项目;PHP和Python的generator和yield其实已是协程的支持,在此之上能够封装出更通用的协程接口和调度;另外还有原生支持协程的Erlang等,笔者不懂,就不说了,具体可参见Wikipedia的页面:http://en.wikipedia.org/wiki/Coroutine
因为保存和恢复任务执行状态须要访问CPU寄存器,因此相关的运行库也都会列出所支持的CPU列表。
从操做系统层面提供协程以及其并行调度的,好像只有OS X和iOS的Grand Central Dispatch,其大部分功能也是在运行库里实现的。
5. goroutine
Go语言经过goroutine提供了目前为止全部(我所了解的)语言里对于并发编程的最清晰最直接的支持,Go语言的文档里对其特性也描述的很是全面甚至超过了,在这里,基于咱们上面的系统知识介绍,列举一下goroutine的特性,算是小结:
(1) goroutine是Go语言运行库的功能,不是操做系统提供的功能,goroutine不是用线程实现的。具体可参见Go语言源码里的pkg/runtime/proc.c
(2) goroutine就是一段代码,一个函数入口,以及在堆上为其分配的一个堆栈。因此它很是廉价,咱们能够很轻松的建立上万个goroutine,但它们并非被操做系统所调度执行
(3) 除了被系统调用阻塞的线程外,Go运行库最多会启动$GOMAXPROCS个线程来运行goroutine
(4) goroutine是协做式调度的,若是goroutine会执行很长时间,并且不是经过等待读取或写入channel的数据来同步的话,就须要主动调用Gosched()来让出CPU
(5) 和全部其余并发框架里的协程同样,goroutine里所谓“无锁”的优势只在单线程下有效,若是$GOMAXPROCS > 1而且协程间须要通讯,Go运行库会负责加锁保护数据,这也是为何sieve.go这样的例子在多CPU多线程时反而更慢的缘由
(6) Web等服务端程序要处理的请求从本质上来说是并行处理的问题,每一个请求基本独立,互不依赖,几乎没有数据交互,这不是一个并发编程的模型,而并发编程框架只是解决了其语义表述的复杂性,并非从根本上提升处理的效率,也许是并发链接和并发编程的英文都是concurrent吧,很容易产生“并发编程框架和coroutine能够高效处理大量并发链接”的误解。
(7) Go语言运行库封装了异步IO,因此能够写出貌似并发数不少的服务端,可即便咱们经过调整$GOMAXPROCS来充分利用多核CPU并行处理,其效率也不如咱们利用IO事件驱动设计的、按照事务类型划分好合适比例的线程池。在响应时间上,协做式调度是硬伤。
(8) goroutine最大的价值是其实现了并发协程和实际并行执行的线程的映射以及动态扩展,随着其运行库的不断发展和完善,其性能必定会愈来愈好,尤为是在CPU核数愈来愈多的将来,终有一天咱们会为了代码的简洁和可维护性而放弃那一点点性能的差异。