从根上理解高性能、高并发(五):深刻操做系统,理解高并发中的协程

本文原题“程序员应如何理解高并发中的协程”,转载请联系做者。php

一、系列文章引言

1.1 文章目的

做为即时通信技术的开发者来讲,高性能、高并发相关的技术概念早就了然与胸,什么线程池、零拷贝、多路复用、事件驱动、epoll等等名词信手拈来,又或许你对具备这些技术特征的技术框架好比:Java的Netty、Php的workman、Go的gnet等熟练掌握。但真正到了面视或者技术实践过程当中遇到没法释怀的疑惑时,方知自已所掌握的不过是皮毛。html

返璞归真、回归本质,这些技术特征背后的底层原理究竟是什么?如何能通俗易懂、绝不费力真正透彻理解这些技术背后的原理,正是《从根上理解高性能、高并发》系列文章所要分享的。java

1.2 文章源起

我整理了至关多有关IM、消息推送等即时通信技术相关的资源和文章,从最开始的开源IM框架MobileIMSDK,到网络编程经典巨著《TCP/IP详解》的在线版本,再到IM开发纲领性文章《新手入门一篇就够:从零开发移动端IM》,以及网络编程由浅到深的《网络编程懒人入门》、《脑残式网络编程入门》、《高性能网络编程》、《鲜为人知的网络编程》系列文章。node

越往知识的深处走,越以为对即时通信技术了解的太少。因而后来,为了让开发者门更好地从基础电信技术的角度理解网络(尤为移动网络)特性,我跨专业收集整理了《IM开发者的零基础通讯技术入门》系列高阶文章。这系列文章已然是普通即时通信开发者的网络通讯技术知识边界,加上以前这些网络编程资料,解决网络通讯方面的知识盲点基本够用了。python

对于即时通信IM这种系统的开发来讲,网络通讯知识确实很是重要,但回归到技术本质,实现网络通讯自己的这些技术特征:包括上面提到的线程池、零拷贝、多路复用、事件驱动等等,它们的本质是什么?底层原理又是怎样?这就是整理本系列文章的目的,但愿对你有用。git

1.3 文章目录

从根上理解高性能、高并发(一):深刻计算机底层,理解线程与线程池
从根上理解高性能、高并发(二):深刻操做系统,理解I/O与零拷贝技术
从根上理解高性能、高并发(三):深刻操做系统,完全理解I/O多路复用
从根上理解高性能、高并发(四):深刻操做系统,完全理解同步与异步
从根上理解高性能、高并发(五):深刻操做系统,理解高并发中的协程》(* 本文)
《从根上理解高性能、高并发(六):高并发高性能服务器究竟是如何实现的 (稍后发布..)》

1.4 本篇概述

接上篇《深刻操做系统,完全理解同步与异步》,本篇是高性能、高并发系列的第5篇文章。程序员

协程是高性能高并发编程中不可或缺的技术,包括即时通信(IM系统)在内的互联网产品应用产品中应用普遍,好比号称支撑微信海量用户的后台框架就是基于协程打造的(详见《开源libco库:单机千万链接、支撑微信8亿用户的后台框架基石》)。并且愈来愈多的现代编程语言都将协程视为最重要的语言技术特征,已知的包括:GoPythonKotlin等。github

所以了解和掌握协程技术对于不少程序员(尤为海量网络通讯应用的后端程序员)来讲是至关有必要的,本文正是为你解惑协程技术原理而写。算法

本文已同步发布于“即时通信技术圈”公众号,欢迎关注。公众号上的连接是:点此进入编程

二、本文做者

应做者要求,不提供真名,也不提供我的照片。

本文做者主要技术方向为互联网后端、高并发高性能服务器、检索引擎技术,网名是“码农的荒岛求生”,公众号“码农的荒岛求生”。感谢做者的无私分享。

三、正文引言

做为程序员,想必你多多少少听过协程这个词,这项技术近年来愈来愈多的出如今程序员的视野当中,尤为高性能高并发领域。当你的同窗、同事提到协程时若是你的大脑一片空白,对其毫无概念。。。

那么这篇文章正是为你量身打造的。

话很少说,今天的主题就是做为程序员,你应该如何完全理解协程。

四、普通的函数

咱们先来看一个普通的函数,这个函数很是简单:

def func():
   print("a")
   print("b")
   print("c")

这是一个简单的普通函数,当咱们调用这个函数时会发生什么?

  • 1)调用func;
  • 2)func开始执行,直到return;
  • 3)func执行完成,返回函数A。

是否是很简单,函数func执行直到返回,并打印出:

a
b
c

So easy,有没有,有没有!

很好!

注意这段代码是用python写的,但本篇关于协程的讨论适用于任何一门语言,由于协程并非某种语言特有的。而咱们只不过刚好使用了python来用做示例,因其足够简单。

那么协程是什么呢?

五、从普通函数到协程

接下来,咱们就要从普通函数过渡到协程了。和普通函数只有一个返回点不一样,协程能够有多个返回点。

这是什么意思呢?

void func() {
  print("a")
  暂停并返回
  print("b")
  暂停并返回
  print("c")
}

普通函数下,只有当执行完print("c")这句话后函数才会返回,可是在协程下当执行完print("a")后func就会因“暂停并返回”这段代码返回到调用函数。

有的同窗可能会一脸懵逼,这有什么神奇的吗?

我写一个return也能返回,就像这样:

void func() {
  print("a")
  return
  print("b")
  暂停并返回
  print("c")
}

直接写一个return语句确实也能返回,但这样写的话return后面的代码都不会被执行到了。

协程之因此神奇就神奇在当咱们从协程返回后还能继续调用该协程,而且是从该协程的上一个返回点后继续执行。

就比如孙悟空说一声“定”,函数就被暂停了:

void func() {
  print("a")
  定
  print("b")
  定
  print("c")
}

这时咱们就能够返回到调用函数,当调用函数何时想起该协程后能够再次调用该协程,该协程会从上一个返回点继续执行。

Amazing,有没有,集中注意力,千万不要翻车。

只不过孙大圣使用的口诀“定”字,在编程语言中通常叫作yield(其它语言中可能会有不一样的实现,但本质都是同样的)。

须要注意的是:当普通函数返回后,进程的地址空间中不会再保存该函数运行时的任何信息,而协程返回后,函数的运行时信息是须要保存下来的。

接下来,咱们就用实际的代码看一看协程。

六、“Talk is cheap,show me the code”

下面咱们使用一个真实的例子来说解,语言采用python,不熟悉的同窗不用担忧,这里不会有理解上的门槛。

在python语言中,这个“定”字一样使用关键词yield。

这样咱们的func函数就变成了:

void func() {
  print("a")
  yield
  print("b")
  yield
  print("c")
}

注意:这时咱们的func就再也不是简简单单的函数了,而是升级成为了协程,那么咱们该怎么使用呢?

很简单:

def A():
  co =func() # 获得该协程
  next(co)    # 调用协程
  print("in function A") # do something
  next(co)    # 再次调用该协程

咱们看到虽然func函数没有return语句,也就是说虽然没有返回任何值,可是咱们依然能够写co = func()这样的代码,意思是说co就是咱们拿到的协程了。

接下来咱们调用该协程,使用next(co),运行函数A看看执行到第3行的结果是什么:

a

显然,和咱们的预期同样,协程func在print("a")后因执行yield而暂停并返回函数A。

接下来是第4行,这个毫无疑问,A函数在作一些本身的事情,所以会打印:

a
in function A

接下来是重点的一行,当执行第5行再次调用协程时该打印什么呢?

若是func是普通函数,那么会执行func的第一行代码,也就是打印a。

但func不是普通函数,而是协程,咱们以前说过,协程会在上一个返回点继续运行,所以这里应该执行的是func函数第一个yield以后的代码,也就是 _print("b")_。

a
in function A
b

看到了吧,协程是一个很神奇的函数,它会本身记住以前的执行状态,当再次调用时会从上一次的返回点继续执行。

七、图形化解释

为了让你更加完全的理解协程,咱们使用图形化的方式再看一遍。

首先是普通的函数调用:

在该图中:方框内表示该函数的指令序列,若是该函数不调用任何其它函数,那么应该从上到下依次执行,但函数中能够调用其它函数,所以其执行并非简单的从上到下,箭头线表示执行流的方向。

从上图中咱们能够看到:咱们首先来到funcA函数,执行一段时间后发现调用了另外一个函数funcB,这时控制转移到该函数,执行完成后回到main函数的调用点继续执行。这是普通的函数调用。

接下来是协程:

在这里:咱们依然首先在funcA函数中执行,运行一段时间后调用协程,协程开始执行,直到第一个挂起点,此后就像普通函数同样返回funcA函数,funcA函数执行一些代码后再次调用该协程。

注意:协程这时就和普通函数不同了,协程并非从第一条指令开始执行而是从上一次的挂起点开始执行,执行一段时间后遇到第二个挂起点,这时协程再次像普通函数同样返回funcA函数,funcA函数执行一段时间后整个程序结束。

八、函数只是协程的一种特例

怎么样,神奇不神奇。和普通函数不一样的是,协程能知道本身上一次执行到了哪里。

如今你应该明白了吧,协程会在函数被暂停运行时保存函数的运行状态,并能够从保存的状态中恢复并继续运行。

很熟悉的味道有没有,这不就是操做系统对线程的调度嘛(见《深刻计算机底层,理解线程与线程池》),线程也能够被暂停,操做系统保存线程运行状态而后去调度其它线程,此后该线程再次被分配CPU时还能够继续运行,就像没有被暂停过同样。

只不过线程的调度是操做系统实现的,这些对程序员都不可见,而协程是在用户态实现的,对程序员可见。

这就是为何有的人说能够把协程理解为用户态线程的缘由。

此处应该有掌声。

也就是说如今程序员能够扮演操做系统的角色了,你能够本身控制协程在何时运行,何时暂停,也就是说协程的调度权在你本身手上。

在协程这件事儿上,调度你说了算。

当你在协程中写下 yield 的时候就是想要暂停该协程,当使用 _next()_ 时就是要再次运行该协程。

如今你应该理解为何说函数只是协程的一种特例了吧,函数其实只是没有挂起点的协程而已。

九、协程的历史

有的同窗可能认为协程是一种比较新的技术,然而其实协程这种概念早在1958年就已经提出来了,要知道这时线程的概念都尚未提出来。

到了1972年,终于有编程语言实现了这个概念,这两门编程语言就是Simula 67 以及Scheme。

但协程这个概念始终没有流行起来,甚至在1993年还有人考古同样专门写论文挖出协程这种古老的技术。

由于这一时期尚未线程,若是你想在操做系统写出并发程序那么你将不得不使用相似协程这样的技术,后来线程开始出现,操做系统终于开始原生支持程序的并发执行,就这样,协程逐渐淡出了程序员的视线。

直到近些年,随着互联网的发展,尤为是移动互联网时代的到来,服务端对高并发的要求愈来愈高,协程再一次重回技术主流,各大编程语言都已经支持或计划开始支持协程。

那么协程究竟是如何实现的呢?

十、协程究竟是如何实现的?

让咱们从问题的本质出发来思考这个问题:协程的本质是什么呢?

其实就是能够被暂停以及能够被恢复运行的函数。那么能够被暂停以及能够被恢复意味着什么呢?

看过篮球比赛的同窗想必都知道(没看过的也能知道),篮球比赛也是能够被随时暂停的,暂停时你们须要记住球在哪一方,各自的站位是什么,等到比赛继续的时候你们回到各自的位置,裁判哨子一响比赛继续,就像比赛没有被暂停过同样。

看到问题的关键了吗:比赛之因此能够被暂停也能够继续是由于比赛状态被记录下来了(站位、球在哪一方),这里的状态就是计算机科学中常说的上下文(context)。

回到协程。

协程之因此能够被暂停也能够继续,那么必定要记录下被暂停时的状态,也就是上下文,当继续运行的时候要恢复其上下文(状态)另外:函数运行时全部的状态信息都位于函数运行时栈中。

函数运行时栈就是咱们须要保存的状态,也就是所谓的上下文。

如图所示:

从上图中咱们能够看出:该进程中只有一个线程,栈区中有四个栈帧,main函数调用A函数,A函数调用B函数,B函数调用C函数,当C函数在运行时整个进程的状态就如图所示。

如今:咱们已经知道了函数的运行时状态就保存在栈区的栈帧中,接下来重点来了哦。

既然函数的运行时状态保存在栈区的栈帧中,那么若是咱们想暂停协程的运行就必须保存整个栈帧的数据,那么咱们该将整个栈帧中的数据保存在哪里呢?

想想这个问题:整个进程的内存区中哪一块是专门用来长时间(进程生命周期)存储数据的?是否是大脑又一片空白了?

先别空白!

很显然:这就是堆区啊(heap),咱们能够将栈帧保存在堆区中,那么咱们该怎么在堆区中保存数据呢?但愿你尚未晕,在堆区中开辟空间就是咱们经常使用的C语言中的malloc或者C++中的new。

咱们须要作的就是:在堆区中申请一段空间,让后把协程的整个栈区保存下,当须要恢复协程的运行时再从堆区中copy出来恢复函数运行时状态。

再仔细想想,为何咱们要这么麻烦的来回copy数据呢?

实际上:咱们须要作的是直接把协程的运行须要的栈帧空间直接开辟在堆区中,这样都不用来回copy数据了,以下图所示。

从上图中咱们能够看到:该程序中开启了两个协程,这两个协程的栈区都是在堆上分配的,这样咱们就能够随时中断或者恢复协程的执行了。

有的同窗可能会问,那么进程地址空间最上层的栈区如今的做用是什么呢?

答案是:这一区域依然是用来保存函数栈帧的,只不过这些函数并非运行在协程而是普通线程中的。

如今你应该看到了吧,在上图中实际上共有3个执行流:

  • 1)一个普通线程;
  • 2)两个协程。

虽然有3个执行流但咱们建立了几个线程呢?

答案是:一个线程。

如今你应该明白为何要使用协程了吧:使用协程理论上咱们能够开启无数并发执行流,只要堆区空间足够,同时尚未建立线程的开销,全部协程的调度、切换都发生在用户态,这就是为何协程也被称做用户态线程的缘由所在。

掌声在哪里?

所以:即便你建立了N多协程,但在操做系统看来依然只有一个线程,也就是说协程对操做系统来讲是不可见的。

这也许是为何协程这个概念比线程提出的要早的缘由,多是写普通应用的程序员比写操做系统的程序员最早遇到须要多个并行流的需求,那时可能都尚未操做系统的概念,或者操做系统没有并行这种需求,因此非操做系统程序员只能本身动手实现执行流,也就是协程。

如今你应该对协程有一个清晰的认知了吧。

十一、协程技术概念小结

正文内容用了较多调侃语气,目的是但愿能轻松诙谐地助你理解协程技术概念。那么,咱们从严肃专业知识来小结一下,到底什么是协程呢?

11.1 协程是比线程更小的执行单元

协程是比线程更小的一种执行单元,你能够认为是轻量级的线程。

之因此说轻:其中一方面的缘由是协程所持有的栈比线程要小不少,java当中会为每一个线程分配1M左右的栈空间,而协程可能只有几十或者几百K,栈主要用来保存函数参数、局部变量和返回地址等信息。

咱们知道:而线程的调度是在操做系统中进行的,而协程调度则是在用户空间进行的,是开发人员经过调用系统底层的执行上下文相关api来完成的。有些语言,好比nodejs、go在语言层面支持了协程,而有些语言,好比C,须要使用第三方库才能够拥有协程的能力(好比微信开源的Libco库就是这样的,见:《开源libco库:单机千万链接、支撑微信8亿用户的后台框架基石》)。

因为线程是操做系统的最小执行单元,所以也能够得出,协程是基于线程实现的,协程的建立、切换、销毁都是在某个线程中来进行的。

使用协程是由于线程的切换成本比较高,而协程在这方面颇有优点。

11.2 协程的切换到底为何很廉价?

关于这个问题,咱们回顾一下线程切换的过程:

  • 1)线程在进行切换的时候,须要将CPU中的寄存器的信息存储起来,而后读入另一个线程的数据,这个会花费一些时间;
  • 2)CPU的高速缓存中的数据,也可能失效,须要从新加载;
  • 3)线程的切换会涉及到用户模式到内核模式的切换,听说每次模式切换都须要执行上千条指令,很耗时。

实际上协程的切换之因此快的缘由我认为主要是:

  • 1)在切换的时候,寄存器须要保存和加载的数据量比较小;
  • 2)高速缓存能够有效利用;
  • 3)没有用户模式到内核模式的切换操做;
  • 4)更有效率的调度,由于协程是非抢占式的,前一个协程执行完毕或者堵塞,才会让出CPU,而线程则通常使用了时间片的算法,会进行不少没有必要的切换(为了尽可能让用户感知不到某个线程卡)。

十二、写在最后

写到这里,相信你已经理解协程究竟是怎么一回事了,关于协程更系统的知识能够自行查阅相关资料,我就再也不啰嗦了。

下一篇《从根上理解高性能、高并发(六):高并发高性能服务器究竟是如何实现的》,敬请期待!

附录:更多高性能、高并发文章精选

高性能网络编程(一):单台服务器并发TCP链接数到底能够有多少
高性能网络编程(二):上一个10年,著名的C10K并发链接问题
高性能网络编程(三):下一个10年,是时候考虑C10M并发问题了
高性能网络编程(四):从C10K到C10M高性能网络应用的理论探索
高性能网络编程(五):一文读懂高性能网络编程中的I/O模型
高性能网络编程(六):一文读懂高性能网络编程中的线程模型
高性能网络编程(七):到底什么是高并发?一文即懂!
以网游服务端的网络接入层设计为例,理解实时通讯的技术挑战
知乎技术分享:知乎千万级并发的高性能长链接网关技术实践
淘宝技术分享:手淘亿级移动端接入层网关的技术演进之路
一套海量在线用户的移动端IM架构设计实践分享(含详细图文)
一套原创分布式即时通信(IM)系统理论架构方案
微信后台基于时间序的海量数据冷热分级架构设计实践
微信技术总监谈架构:微信之道——大道至简(演讲全文)
如何解读《微信技术总监谈架构:微信之道——大道至简》
快速裂变:见证微信强大后台架构从0到1的演进历程(一)
17年的实践:腾讯海量产品的技术方法论
腾讯资深架构师干货总结:一文读懂大型分布式系统设计的方方面面
以微博类应用场景为例,总结海量社交系统的架构设计步骤
新手入门:零基础理解大型分布式架构的演进历史、技术原理、最佳实践
重新手到架构师,一篇就够:从100到1000万高并发的架构演进之路

本文已同步发布于“即时通信技术圈”公众号。
image
▲ 本文在公众号上的连接是:点此进入。同步发布连接是:http://www.52im.net/thread-3306-1-1.html