1. 开篇序言
Concurrency is not parallelism。
并发不等于并行。
--Rob.Pike
-- 罗布.派克
罗老爷子一直有着诸多广为流传脍炙人口的slogan。不过今天我要把老爷子的话当成砖来引出go的美玉goroutine。即使你没有深入的使用过go语言,也一定对goroutine的大名如雷贯耳,一看到它会条件反射般的扯上并发和并行。
说实话goroutine神马的大家应该是非常熟悉了,曹大,毛大,谢大等等大佬已经把这块给说烂了。网上一搜是一大片。鄙人开此篇纯粹是为了打发打发时间,在业务淡季,摸鱼划水已经已经消耗完了我的精神食粮,借着文字抒发一下情感,最后顺便做一个总结,如果鄙人的拙见对你有帮助,那是我的荣幸,如果觉得有什么地方是说错的或者是让你觉得非常confused的地方也欢迎在评论区交流。接下来要开始正题。
“Goroutine 是一个与其他 goroutines 并行运行在同一地址空间的 Go 函数或方法。一个运行的程序由一个或更多个 goroutine 组成。它与线程、协程、进程等不同。它是一个 goroutine”
—— Rob Pike
划重点,从罗老爷子的描述来看,老爷子说的goroutine就是goroutine,既不是进程也不是线程更不是协程,这里有意思的事情就来了,不管你是Google还是百度,一搜goroutine,铺天盖地的文章,他们的描述全是很自然的把goroutine == 协程 。这不就和罗老爷子说的矛盾了,到底谁是对的。我们需要深究下,或许你知道进程和线程的概念,但是对于协程,很多人就不清楚了,其实也好解释,因为操作系统OS层面,只有进程和线程,就没有协程这一说。进程就是二进制可执行文件在计算机内存里的一个运行实例,线程简单理解就是一个微进程,专门跑一个函数(逻辑流)。线程有两种类型,一种是由内核来管理和调度。另外一种线程,他的调度是由程序员自己写程序来管理的,对内核来说不可见。这种线程叫做用户空间线程。协程可以理解就是一种用户空间线程,协程的几个特点:
-
- 在用户态完成创建,切换和销毁
-
- 协同,因为是由程序员自己写的调度策略,其通过协作而不是抢占来进行切换
-
- 从编程角度上看,协程的思想本质上就是控制流的主动让出(yield)和恢复(resume)机制
所以从严格的概念上来看协程叫coroutine,为了区别协程所以叫goroutine,所以罗老爷子的表述是完全没问题的。不过goroutine和coroutine真的很像,从严格的定义上来看真的太像了。民间一直协程协程的称呼,长久的流传下来就默认的把goroutine说成了协程。所以结案了两种说法都没有错,只是此协程非彼协程。我好想花了很大的篇幅做了一件很无聊的事情,想要证明协程!=goroutine,最后证明得出了两种叫法都没错的结论。刚刚好前段时间读了鲁迅先生的《秋夜》,他在开头写道:“在我的后园,可以看见墙外有两株树,一株是枣树,还有一株也是枣树。一样的感觉乂(゚Д゚三゚Д゚)乂
go语言一向是以简单,高效地编写并发程序而闻名,这些都要归功于今天的主角goroutine(协程),蛮多人都将goroutine以协程自称,我其实不是很喜欢这种说法,不是说这种说法是错的,只是个人觉得把这个概念说的过于学术化了。因为如果用协程去描述goroutine,大概又要从线程,进程去铺开去说,蛮多的概念就此展开来了。我想能看到这篇文章的一定是有一定编程功底或者说是有一些操作系统概念的小伙伴,如果确实是新手,对这些概念还不清楚的,可以去看下操作系统的基础,去了解下具体的概念,我在上面的描述中大致的描述了一些区别。这些就是一些概念性的东西,属于知道就知道了,没看过的不知道就是知道的知识点,比较好理解。曹大对goroutine的描述是,goroutine只是处在用户态的计算单元。这种说法我觉得就比较写实,让人很好理解。如果你要问我什么是用户态,那我只能简单的说下,有用户态相对的就有一个内核态,这也是操作系统的术语,粗略的说就是两种角色在操作系统OS中的权限是不一样的,内核态的权限很大,可以使用一些外围设备如硬盘,网卡,cpu,而用户态则小的多,只能访问内存,是不允许访问外围设备的,占用cpu的能力都被剥夺。想进一步了解的,还是要去看下操作系统的知识充充电。
2. 进程与线程
协程与操作系统中的线程和进程具有紧密的联系。为了深入理解协程,必须对进程,线程以及上下文切换等概念有所了解。在计算机科学中,线程是可以又调度程序(这里的程序也就是操作系统的一部分)独立管理的最小程序指令集,二进程是程序运行的实例。
在大多数情况下,线程是进程的组成部分。一个进程中可以存在多个线程,这些线程可并发执行共享进程的内存(如全局变量)等资源。进程之间相互独立,不同进程具有不同的内存地址空间,代表程序运行的机器码和进程状态和操作系统资源描述符等。
这张也是一张老图了,很经典的诠释了单线程和多线程进程的模型的不同。在一个进程内部可能有多个线程同事被处理。不过我们一般来说都是用多线程进程模型。其实这是有原因的,因为从操作系统的角度来看,创建一个线程的代价比创建一个进程的代价要小的多,而且进程与进程之间是相互独立的,有独占的内存空间,这就使得资源共享就变的非常的麻烦。这是站在操作系统的角度上看的。如果从硬件CPU的角度上看,一个单核心(core)的处理器同时只能处理一个线程,这就意味着即使是多线程模型,依旧只能是以交织的方式运行,线程交替抢占CPU的时间片。现代的CPU处理器一般来说都是多核心,所以说可以实现并行处理。
3. 线程上下文切换
现代的多核处理器可以保证并行计算,不过实际情况中程序的数量以及实际运行的线程数量会比CPU的核心数要高出很多。因为,为了平衡每个线程都能被CPU处理到且最大程度的使用CPU资源,操作系统会通过一种叫做定时器终端的方式(Timer Interrupt)或者是I/O设备中断等方式执行上下文切换(Context Switch)。
当发生线程上下文切换时,需要从用户态转换到内核态,记录上一个线程的重要寄存器值(CPU的寄存器比较多,比如什么AX,BX,CX,DX,SP什么的,鄙人这块不是很精通,轻喷),进程状态等等的信息,这些信息存储在操作系统线程控制块(Thread Control Block)中。当切换到下一个要执行的线程时,需要加载CPU的寄存器的值并且从内核态切换到用户态。如果线程在上下文切换时属于不同的进程,那么需要更新额外信息,如状态信息,内存地址空间信息,可能还要再将Page Tables(PT页面转换表,它的存在是为了解决在有限的物理内存的环境下运行更大内存的程序的问题)导入内存。进程间的上下文切换的最大问题就是内存地址空间的切换导致缓存失效的问题,比如说CPU中用于缓存虚拟地址与物理地址之间映射的TLB表,这玩意儿起到一个cache的作用,缓存了部分可能会用到的页表项。TLB就是一个做地址查找的副本,TLB无法完成地址翻译任务时才会去内存查询页表,一定程度上减少了页表查询导致了处理器性能下降。大概的讲了下TLB的概念,我们再回过头来看,很明显了,因为找内存地址这么麻烦,所以说不同进程的切换明显要慢于同一进程的中线程的切换。现代的CPU有一种叫快速上下文切换的技术(Fast Context Switch Extension ,FCSE ) 如果两个进程使用了同样的虚拟地址空间,则对CPU而言,两个进程使用了同样的虚拟地址空间;快速上下文切换对各进程的虚拟地址进行变换,这样的系统中CPU之外其他的看到的是经过快速上下文切换变换的虚拟地址。快速上下文切换将进程的虚拟地址空间变换成不同的虚拟地址空间。这样在进行进程间切换时就不需要进行虚拟地址到物理地址的重映射。使用这种技术解决不同进程切换带来的缓存失效的问题。
4. Goroutine和Thread的区别
go语言中,goroutine被认为是轻量级的线程,和线程不同的是,操作系统内核是感知不到goroutine的存在,goroutine的管理也是完全依赖go的运行时(Runtime)。同时goroutine也是从属于某一个系统的线程的,后面在讲GMP模型的时候大家就可以深刻的理解这句话的含义了。大家在看待goroutine这个东西的时候有没有想过一个问题,为啥不直接用操作系统的线程非要用goroutine这种协程呢。我用线程在多核的cpu上一样可以并行处理。要完全回答这个问题我想就应该从线程和协程的区别作为切入点去讲。
4.1 内存占用
创建一个goroutine的栈开销是2KB(Linux amd64 go v1.4后)运行过程中,如果空间不够,goroutine可自己动扩缩容。最大可达1GB的内存空间。而创建一个Thread,为了尽量避免极端情况下操作系统的线程栈溢出,操作系统会默认分配一个较大的栈内存(1~8MB栈内存,线程标准POSIX Thread),而且还需要一个被称为guard page的区域用于和其他的线程的栈空间隔离。栈空间一旦被初始化之后就不能改变了,这决定了在某些特殊的场景下系统线程栈还是有溢出的风险的。
总结一下就是goroutine申请的栈空间比操作系统线程要小的多,goroutine可以进行扩缩容,而线程不行。
4.2 创建/销毁
POSIX线程(定义了创建和操纵线程的一套API)通常是在已有的进程模型中增加的逻辑拓展,所以线程控制和进程控制很相似。而进入内核调度所消耗的性能代价比较高,开销较大。goroutine 是用户态线程,是由 go runtime 管理,创建和销毁的消耗非常小。
总结一下就是线程是内核态的切换,goroutine是用户态级别的线程,所以在创建和销毁的代价上goroutine要远小于操作系统线程。
4.3 调度切换
抛开陷入内核,线程切换会消耗 1000-1500 纳秒(上下文保存成本高,较多寄存器,公平性,复杂时间计算统计),一个纳秒平均可以执行 12-18 条指令。所以由于线程切换,执行指令的条数会减少 12000-18000。goroutine 的切换约为 200 ns(用户态、3个寄存器),相当于 2400-3600 条指令。因此,goroutines 切换成本比 threads 要小得多。
总结一下就是 goroutine是用户态级别的线程,线程是内核态的,所以从调度的时间上看,goroutine比thread要快不少。
4.4 复杂性
线程的创建和退出复杂,多个thread间通讯复杂(share memory)。不能大量创建线程(参考早期的 httpd),成本高,使用网络多路复用,存在大量callback(参考twemproxy、nginx 的代码)。对于应用服务线程门槛高,例如需要做第三方库隔离,需要考虑引入线程池等。
总结一下就是系统内存资源有限,无法大量创建线程,goroutine的个数理论上看内存的上限,虽然线程也是这样,但是如果创建相同个数的goroutine和线程,那一定是goroutine所需要的系统资源更小,且更简单。
4.5 调度方式
goroutine的管理依赖go的runtime的调度器。go 创建 M 个线程(CPU 执行调度的单元,内核的 task_struct),之后创建的 N 个 goroutine 都会依附在这 M 个线程上执行,即 M:N 模型,就是多对多模型。它们能够同时运行,与线程类似,但相比之下非常轻量。因此,程序运行时,Goroutines 的个数应该是远大于线程的个数的。go的runtime可以将多个goroutine调度给一个系统线程执行,一个goroutine也可以切换到多个线程中去执行。后面将GMP模型的时候会重点说这块。
5 并发与并行
终于要来点题了,看到我在开篇序言里引的罗老爷子的话,Concurrency is not parallelism。罗老爷子为啥是说并发不等于并行。其实并发和并行的概念,这两个词我见过很多人就彻底把概念搞混淆,通俗的说,并发指同时处理多个任务的能力,这些任务是独立的执行单元。并发不是说同一时刻所有的任务都在执行,是指一个时间段内,所有的任务都可以被执行。你是开发者你是不是对什么时间段执行什么任务不关心。
如图,我买了一个1C的机器,只有一个核,并发能力基本等于没有啊,单位时间内我就只能执行一个线程,不管我怎么上下文切,切来切去还是只有一个线程能被cpu执行,多核的单位时间内就能执行多个线程啦。所以如果只有一个CPU核心,那并行和并发还真就没有区别,你也可以这么理解,对于只有一个核心的处理器,并行就等于并发了。
如果说是多核处理器,从宏观看,我有多个核心,每个核心都在处理线程,但是微观的看,我们把视线聚焦到一个cpu核心上,他还在是并发处理。所以并发和并行是共存的。
goroutine要依托内核线程才能被执行,线程内 go的runtime会切换多个协程执行,请问此时是并发还是并行,答案是并发。如果多个goroutine被分配给了不同的内核线程,此时就是并行啦。你就把这些内核线程理解成是CPU核心的抽象就好理解了。协程并发是一种很常见的现象,理论上内存有多少我就能有多少的goroutine,这么多的goroutine要怎么合理公平的调度执行,这就是go的runtime要关注的事情了。
6. 总结
goroutine与进程,线程,协程均不同,goroutine就是goroutine,当然你要称呼是协程也是可以的,因为大家都这么叫。只要能让大家的认知达成共识,叫啥无所谓。goroutine与系统线程还是有很多本质上的区别的,正式因为这些区别就使得go天生就带着高并发的标签,希望看了这篇文章能让大家对进程,线程,协程,goroutine,并发,并行能有一些深刻的了解,这样子我写这篇水文的目的也就达到饿了。
7. 鸣谢
要感谢郑建勋老师的《Go语言底层原理剖析》,写这篇文章时参考了郑老师第14章协程初探的排版,还有一些郑老师的理解。还要是要感谢毛剑老师的PPT,给我整了不少的素材,我不用去徒手画这么多的图。期待下次见面,下一次我会深入浅出的讲一讲GMP模型,哈哈。张口就是老学究了,满嘴八股文。