Ready to GO - Goroutine

Ready to GO - Goroutine

go不go哇?吼哇!前些天心惊胆战,隔壁组的小伙伴们可没少闹腾,本来打算一周一更也没完成(借口)。从元旦节写到现在,这篇文章也算是跨年文了。

目录

----- 说在前面

----- 协程的出身?

----- 协程 VS 线程

----- 要协程何用?

----- 并发 VS 并行

----- goroutine 调度

----- 举个最简单的例子

----- 后话



00. 说在前面

听go吹说协程说了这么久了,也只是知道协程怎么用,只明白“协程是轻量级用户态线程”。我还是纳闷,协程是什么呢?它和线程有啥区别呢?为什么最近协程这么火?协程真的有go吹吹得那么牛吗?

带着这几个问题,我们一步步往下看。


01. 协程的出身

协程是一个很古老的概念,早在上个世纪就有了。

事情是这样的,在古时候,COBOL语言编译器的使用的词法和语法的解析,一般情况下都是两个独立运行的步骤,但是有个特立独行的程序员有了新的思路,他把的词法和语法解析交织在一起交替运行。当词法解析器解析了一定的token时,就开始执行语法解析器,等语法分析器解析完所有的token之后,再把控制权让给词法解析器。值得注意的是,这里让出控制流时,需要它们记住自身的状态,以便从上次执行的位置开始恢复。这就是协程的前身,这里的协程,和正常子程序调用串行执行的区别就在于,其控制流能够主动让出和恢复。

Knuth爷爷说过:“子程序是协程的特例”。

虽然我没有亲自听过,但是大概就是这个意思。子程序是什么,就是我们平时开发所用的“函数”or “方法”,更书面化的定义是“能被其他程序调用,在实现某种功能后能自动返回到调用程序去的程序。其最后一条指令一定是返回指令,故能保证重新返回到调用它的程序中去。”,子程序在return了之后才能“让出”控制权,并且其调用栈的状态在返回时被丢弃,也就无法恢复了。

协程可以自由控制“让出”控制权,那么,假设协程在return之后“让出”给调用它的程序的话,并且丢弃其内部状态,这个协程就变成子程序了。因此Knuth爷爷说的很对。

那为什么协程这么久了才开始火起来?
古时候的命令式编程语言的开发理念是“自顶向下”,其主旨是,程序被切分为一个主程序和一些子模块,然后子模块又能拆分为更多的子模块,这种结构化编程能够让代码更加清晰。在“自顶向下”层次关系中,各个模块形成层次调用关系,我们的软件开发,其实就是去实现这些子过程。协程的思维方式和“自顶向下”的结构化思维格格不入,所以一直没能成为主流。

其实协程是因为近些年Lua、Go等语言支持协程之后才火起来的,因为他能够更好地去协调低速IO和高速CPU的不一致问题。总之一句话,时势造英雄啊!


02. 协程 VS 线程

线程是处理器调度的基本单位,在CPU切分时间片的前提下,操作系统进行抢占式调度。

线程我们可以理解为“内核级线程”,而协程是“用户级线程”,两者区别如下:

线程 VS 协程

总的来说,协程在处理并发上的性能优于普通线程。但是不足之处是因为协程是在单线程的基础上运行的(在单线程上创建多个协程),无法利用多核CPU的优势。至于应对方案,后续会提及。


03. 要协程何用?

就像你不回电话,你女朋友能火起来一样。协程能火也是有各种理由的。

  • 高并发处理。在用户空间切换上下文,不用陷入内核来做线程切换,避免不必要用户空间和内核空间的数据拷贝。
  • 用同步的方式去写异步代码,高效率且不容易出错(有同事300行写了一个http请求托管服务)
  • 非抢占式模型,能控制中断位置,不会发生由于强行切换线程导致的资源竞争。(极端情况下还是会执行抢占,防止协程长时间占用CPU,但这不是标准抢占式模型)

咱们今天主要说 协程怎么处理高并发。


04. 并发 VS 并行

前面说,协程能够解决并发问题,那么什么是并发呢?和并行又是怎样的关系?

先上图 :

  • 并发:处理器被划分为一个个时间分片,多个线程在处理器中交替执行,同一个时刻,只有一个线程被执行(通用地来说,支持并发是一种系统拥有交替执行多个任务的能力的表现)
  • 并行:多个线程,在多个处理器上同时执行。
举个最简单的例子,医院诊室看病。把病人当做线程,医生当做处理器。

并发:只有一个医生,病人A看了一会儿,医生让他下楼拍X光,然后病人B进来看诊,之后医生让B去做彩超,然后A此时回来了,医生继续给A看病。(任意瞬间,医生只在给其中一个人看病)

并行: 有3个医生,3个病人,一个病人对应一个医生,同时问诊。

如果并发交替的速度够快,就能达到“逻辑并行”的效果,对外看起来就和并行一样。

并发执行多线程并不能真的充分利用CPU,达到减少单个线程执行时间的效果,这种交替挂起执行的方式却能够给用户带来每个线程都在”同时执行“的感觉,从而增强了服务的响应速度。就像上面例子中的病人B不用一直排队等待 A拍完X光并且医生确定A的病看完了 才能去看病。


05. goroutine 调度

goroutine是协程,概念前面已经介绍了。更加有趣的goroutine的调度,let's begin~

我们从操作系统线程实现模型说起。线程实现有3种模型。

线程模型

前面 02章节(线程VS协程) 的比较,其实比较的就是 1 : 1模型 和 N:1模型了,理论上协程采用的是N:1模型,但是为了利用多核,一般工程实现还是选择N:M模型,我们的主角goroutine采用的就是N:M模型,既能够实现协程,又能利用多核。

  • 首先第一点,GO调度器包含了三种角色。


M代表系统线程,也就是前面说的普通线程。

P代表调度器,我们可以把它当做单线程的本地调度器。(是实现N:1 到 N:M的关键)

G代表goroutine,它包含了SP、PC寄存器,以及其它调度相关信息。

(注:GOMAXPROCS环境变量代表的个数是P的个数,推荐值为CPU的核心数)

2线程例子
上图是2个M(线程),每个线程对应一个处理器(P),M是必须关联P才能执行协程(G)的。图中蓝G代表的是运行中的goroutine,灰G表示的待执行的Goroutine,待执行的Goroutine存储在 P 中的一个局部队列中,此时P执行Goroutine会这个队列中取,不用加锁,提高了并发度。(Go1.0版本中,调度器取Goroutine是去一个全局队列中取,需要加锁,线程会经常阻塞等待锁)
  • 如果其中一个G执行的时候,发生了系统调用,阻塞了怎么办?
系统调用例子

上图左边,G0中陷入系统调用,导致M0阻塞。

此时,M0放弃了它的P,让M1去处理P中剩下的Goroutine。这里的M1可能是在线程缓存中取的,或者运行中生成的。

当M0从系统调用中恢复,它会去别的M中找P来执行G0(比如说别的M阻塞丢出了P),如果没有P,那么它会把G0放到全局队列中,并且把它自己放到线程缓存中。

全局队列保存了Goroutine,当各自P中的局部队列没有Goroutine时,P会到全局队列中取Goroutine。并且即使P中局部队列有Goroutine,也会周期性地从全局队列中取Goroutine,保持全局队列中的Goroutine能够尽快被执行。

处理系统调用,也是go程序为什么跑在多线程上的一个原因,即使GOMAXPROCS是1,也可能会有多个工作线程。


  • 还有一个问题,当P局部队列不均衡时怎么处理?如果有多个P,其中一个P的局部队列Goroutine执行完了。


如果一个P局部队列为空,那么它尝试从全局队列中取Goroutine,如全局队列为空,则会随机从其它P的局部队列中“挪”一半Goroutine到自己的队列当中, 以保证所有的M都是有任务执行的,间接做到负载均衡(可以参考go源码的findrunnable()函数 )
  • 最后一个问题,关于抢占。如果一个P连续执行长时间,没有切换G,怎么处理?
虽然协程强调的是协作式调度,但是如果其中一个协程不够“合作”,不主动让出控制权,那么会导致这个线程一直被占用,降低并发度。Go中有相应的处理方案~

首先,Go 在启动时会创建一个系统线程,这个系统线程会监控所有Goroutine的状态。它是通过遍历所有的P,如果P连续长时间执行,就会被抢占。表现为改P对应当前执行的G会被移除,放置到全局Goroutine队列中,然后P去捞新的G来执行。


06. 举个最简单的例子

既然是最简单的例子,我暂时也没有想出来,因为我脑海中存在着这个例子了。

猛戳: 这是最简单的例子


07. 后话

本篇文章是从无到有介绍协程,最后通过Goroutine调度来展示协程的一种调度方式。

希望读者朋友能有小小的收获,哪怕是其中一个概念也不亏。后续会从源码的角度来解读Goroutine以及它的同步方式Channel。一步步走向真-Go吹~

编辑于 2018-01-05

文章被以下专栏收录