协程--轻量级的用户态线程
协程(Coroutine)是一种轻量级的用户态线程。简单来说,进程(Process), 线程(Thread)的调度是由操作系统负责,线程的睡眠、等待、唤醒的时机是由操作系统控制,开发者无法精确的控制它们。使用协程,开发者可以自行控制程序切换的时机,可以在一个函数执行到一半的时候中断执行,让出CPU,在需要的时候再回到中断点继续执行。因为切换的时机是由开发者来决定的,就可以结合业务的需求来实现一些高级的特性。
先来抽象的理解一下进程和线程
术语简介
上下文: 指的是程序在执行中的一个状态。通常我们会用调用栈来表示这个状态——栈记载了每个调用层级执行到哪里,还有执行时的环境情况等所有有关的信息。
调度: 指的是决定哪个上下文可以获得接下去的CPU时间的方法。
进程: 是一种古老而典型的上下文系统,每个进程有独立的地址空间,资源句柄,他们互相之间不发生干扰。
线程: 是一种轻量进程,实际上在linux内核中,两者几乎没有差别,除了线程并不产生新的地址空间和资源描述符表,而是复用父进程的。 但是无论如何,线程的调度和进程一样,必须陷入内核态。
详细的知识介绍请参考《深入理解计算机操作系统》一书,这里只是简单的说一些概念;
早期时候,CPU都是单核的,由于单核 CPU 无法被平行使用(多个程序同时运行在一个CPU上)。为了创造共享CPU的假象,设计人员就搞出了一个叫做时间片的概念,将时间分割成为连续的时间片段,让多个程序在这些连续的时间片中交叉获得CPU使用权限,这样看起来就好像多个程序在同时运行一样。后来,给任务分配时间片并进行调度的调度器成为了操作系统的核心组件;
时间被分割为连续的时间片段后,在调度不同程序运行的时候,如果不对内存进行管理(上下文切换),那么切换时间片的时候会造成程序上下文的互相污染(A程序在运行时,突然发现自己内存中多了好多变量,一脸萌比。其实这些变量全是B程序产生的,如果两个程序存在同名变量,那更是两脸萌比了)。但是手工管理物理地址难度巨大,因此设计大牛们又引入了虚拟地址的概念,共包含三个部分:
- CPU 增加了内存管理单元模块,来进行虚拟地址和物理地址的转换;
- 操作系统加入了内存管理模块,负责管理物理内存和虚拟内存;
- 发明了一个概念叫做进程。进程的虚拟地址一样,经过操作系统和 MMU 映射到不同的物理地址上。
经过前面两部的演变,进程就产生了。进程是由一大堆元素组成的一个实体,其中最基本的两个元素就是代码和能够被代码控制的资源(包括内存、I/O、文件等);一个进程从产生到消亡,可以被操作系统调度。掌控资源和能够被调度是进程的两大基本特点。但是进程作为一个基本的调度单位有点不人性:假如我想一边循环输出 hello world,一边接收用户输入计算加减法,就得起俩进程,那随便写个代码都像 chrome 一样变成内存杀手了。这个时候,设计大牛们又想着能不能有一种轻量级的进程呢,这种‘轻量级的进程’不需要自己的独立的内存,IO等资源,而是共享已有的资源,紧接着诞生了线程的概念,线程在进程内部,处理并发的逻辑,拥有独立的栈,却共享线程的资源。使用线程作为 CPU 的基本调度单位显得更加有效率,但也引发各种抢占资源的问题,活生生变成了一把双刃剑.
通过上面的介绍,我们现在在来说一下协程产生的背景。上面的介绍中,我们知道进程掌握着独立资源,线程享受着基本调度。一个进程里可以跑多个线程处理并发。但纯粹的内核态线程有一个问题就是性能消耗:线程切换的时候,进程需要为了管理而切换到内核态,状态转换的消耗有点严重。为此又产生了一个概念,唤做用户态线程(协程)。用户态线程就是程序自己控制状态切换,进程不用陷入内核态,开发者可以按照程序的特性来选择更适合的调度算法,协程属于语言级别的调度算法实现。
协程、子例程与生成器的区别
协程的概念产生的非常早,Melvin Conway 早在 1963 年就针对编译器的设计提出一种将”语法分析”和”词法分析”分离的方案,把 token 作为货物,将其转换为经典的生产者-消费者问题。编译器的控制流在词法和语法解析之间来回切换:当词法模块读入足够多的 token 时,控制流交给语法分析;当语法分析消化完所有 token 后,控制流交给词法分析。(有兴趣的童鞋可以去看看编译原理,推荐龙书)从这一概念提出的环境我们可以看出,协程的核心思想在于:
控制流的主动让出和恢复。
这一点和文章开始提到的用户态线程有几分相似,但是用户态线程多在语言层面实现,对于使用者还是不够开放,无法提供显示的调度方式。但是协程做到了这一点,用户可以在编码阶段通过类似 yieldto 原语对控制流进行调度。
子例程和协程的区别。
子例程和协程产生,需要我们先明确命令式编程与函数式编程这两种不同的编程范式对逻辑控制方式的差异。刚开始产生程序编码这一行业时,使用的是命令式编程,命令式编程围绕着自顶向下的开发理念,将子例程调用作为唯一的控制结构。而函数式编程则产生在命令式编程之后,属于前人痛定思痛的一种产物(具体的细节感兴趣的话可以WIKI一下)。介于篇幅问题,这里直接抛出概念。实际上,子例程就是没用使用 yield 的协程, 程序设计大师 Donald E. Knuth 也曾经将子例程定义为:
子例程是协程的一种特例。
但不进行让步和恢复的协程(子例程),终究失掉了协程的灵魂内核,不能称之为协程。直到后来出现了一个叫做迭代器(Iterator)的神奇的东西。迭代器的出现主要还是因为数据结构日趋复杂,以前用 for 循环就可以遍历的结构需要抽象出一个独立的迭代器来支持遍历,用 js 为例。迭代器的遍历会搞成下面这个样子: