Callback 与 Coroutine 协程概念说明

小谈阻塞非阻塞

阻塞非阻塞概念都是对于线程, 进程这种粒度来说的, 因为只有他们才是内核有感知的, 协程是你内核无感知, 是你用户自己实现的.

例如在 Golang 中, resp, err := client.Do(req) 看着是阻塞的写法(有网络 IO), 但是 Go 的 Http 包是异步的, 这边在协程粒度是阻塞住了, 但是线程该干嘛就干嘛去了, 对于系统来说, 就是非阻塞. 应用程序觉得我遇到阻塞, 比如 I/O什么的, 我就 yield 出去, 把控制权交出去.

分清楚内核层和应用层是关键

阻塞还是非阻塞 应用程序的调用是否立即返回. 被挂起无法执行其他操作的则是阻塞型的, 可以被立即「抽离」去完成其他「任务」的则是非阻塞型的.

但是挂起就不能干事情了吗, 答案是否定的, 一个线程读文件,被阻塞了,资源会出让(陈力就列, 不能者止). coroutine也是, 但是比如 goroutine, go的调度器会把处于阻塞的的go程上的资源分配给其他go程. 但是这里的重点就是线程切换的代价比协程切换的代价高很多(线程切换涉及到内核态和用户态的切换)

协程线程 调度一个主动(协作式调度, 应用程序自己调度) 一个被动(抢占式调度, 操作系统调度)

异步和同步 数据 copy 时进程是否阻塞. 同步:应用层自己去想内核询问(轮询?); 异步:内核主动通知应用层数据. IO 操作分为两个过程:内核等待事件, 把数据拷贝到用户缓冲区. 这两个过程只要等待任何一个都是同步 IO

在异步非阻塞模型中, 没有无谓的挂起、休眠与等待, 也没有盲目无知的问询与检查, 应用层做到不等候片刻的最大化利用自身的资源, 系统内核也十分「善解人意」的在完成任务后主动通知应用层来接收任务成果.

进程、线程、协程

1. 进程

操作系统中最核心的概念是进程, 分布式系统中最重要的问题是进程间通信.

进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位. 每个进程都有自己的独立内存空间, 不同进程通过进程间通信来通信. 由于进程比较重量, 占据独立的内存, 所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大, 但相对比较稳定安全.

进程是“程序执行的一个实例” , 担当分配系统资源的实体. 进程创建必须分配一个完整的独立地址空间.

进程切换只发生在内核态, 两步:1. 切换页全局目录以安装一个新的地址空间 2. 切换内核态堆栈和硬件上下文. 另一种说法类似:1 保存CPU环境(寄存器值、程序计数器、堆栈指针); 2. 修改内存管理单元MMU的寄存器; 3. 转换后备缓冲器TLB中的地址转换缓存内容标记为无效

事件模型

基于事件的模型, 一个进程处理多个请求, 并且通过epoll机制来通知用户请求完成, 事件驱动适合于IO密集型服务, 多进程或线程适合于CPU密集型服务

Nginx 在一个工作进程中处理多个连接和请求, 因为满负载进程的数量很少(通常每核CPU只有一个)而且恒定, 所以任务切换只消耗很少的内存, 而且不会浪费CPU周期, 当然前提是没有阻塞

为什么不使用很多进程: 每个进程都消耗额外的内存, 而且每次进程间的切换都会消耗CPU周期并丢弃CPU高速缓存中的数据(所有处理过程是在一个简单的循环中, 由一个线程完成)

1.1 创建进程的过程

  1. 创建一个PCB
  2. 赋予一个统一进程标识符
  3. 为进程映象分配空间
  4. 初始化进程控制块
  5. 许多默认值 (如: 状态为 New, 无I/O设备或文件…)
  6. 设置相应的链接. 如: 把新进程加到就绪队列的链表中

1.2 进程切换步骤(PCB/TLB):

  1. 保存被中断进程的处理器现场信息
  2. 修改被中断进程的进程控制块的有关信息, 如进程状态等
  3. 把被中断进程的PCB加入有关队列
  4. 选择下一个占有处理器运行的进程
  5. 修改被选中进程的PCB的有关信息
  6. 根据被选中进程设置操作系统用到的地址转换和存储保护信息
  7. 根据被选中进程恢复处理器现场

进程上下文:

进程上下文:操作系统中把进程物理实体和支持进程运行的环境合称为进程上下文(context). 进程实体+运行环境.

  • 用户级上下文:由用户程序块、用户数据块和用户堆栈组成的进程地址空间.
  • 系统级上下文:由进程控制块、内存管理信息、进程环境块, 及系统堆栈等组成的进程地址空间.
  • 寄存器上下文:由PSW寄存器和各类控制寄存器、地址寄存器、通用寄存器组成、用户栈指针等组成.

进程阻塞过程

停止当前进程的执行;保存该进程的CPU现场信息;将进程状态改为阻塞态, 并将其PCB入相应的阻塞队列;转进程调度程序.

进程唤醒过程

首先把被阻塞的进程从等待该事件的阻塞队列中移出, 将其PCB中的现行状态由阻塞改为就绪, 然后再将该PCB插入到就绪队列中.
进程切换:中断处于运行态的进程运行, 让出处理器, 恢复新进程的状态, 使新进程投入运行. 当系统调度新进程占有处理器时, 新老进程随之发生上下文切换. 进程的运行被认为是在进程的上下文中执行的.

2. 线程

线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源. 线程间通信主要通过共享内存, 上下文切换很快, 资源开销较少, 但相比进程不够稳定容易丢失数据.

一旦创建完线程, 你就无法决定他什么时候获得时间片, 什么时候让出时间片了, 你把它交给了内核. 因为程序的使用涉及大量的计算机资源配置, 把这活随意的交给用户程序, 非常容易让整个系统分分钟被搞跪, 资源分配也很难做到相对的公平. 所以核心的操作需要陷入内核(kernel), 切换到操作系统, 让老大帮你来做.

线程上下文一般只包含CPU上下文及其他的线程管理信息. 线程创建的开销主要取决于为线程堆栈的建立而分配内存的开销, 这些开销并不大. 线程上下文切换发生在两个线程需要同步的时候, 比如进入共享数据段. 切换只CPU寄存器值需要存储, 并随后用将要切换到的线程的原先存储的值重新加载到CPU寄存器中去.

有的时候碰着I/O访问, 阻塞了后面所有的计算. 空着也是空着, 老大就直接把CPU切换到其他进程, 让人家先用着. 当然除了I\O阻塞, 还有时钟阻塞等等.但是一切换进程得反复进入内核, 置换掉一大堆状态. 进程数一高, 大部分系统资源就被进程切换给吃掉了. 后来搞出线程的概念, 这个地方阻塞了, 但我还有其他地方的逻辑流可以计算, 这些逻辑流是共享一个地址空间的, 不用特别麻烦的切换页表、刷新TLB, 只要把寄存器刷新一遍就行, 能比切换进程开销少点.

3. 协程

当出现IO阻塞的时候,由协程的调度器进行调度,通过将数据流立刻yield掉(主动让出),并且记录当前栈上的数据,阻塞完后立刻再通过线程恢复栈,并把阻塞的结果放到这个线程上去跑,这样看上去好像跟写同步代码没有任何差别,这整个流程可以称为coroutine,而跑在由coroutine负责调度的线程称为Fiber。比如Golang里的 go关键字其实就是负责开启一个Fiber,让func逻辑跑在上面。

由于协程的暂停完全由程序控制,发生在用户态上;而线程的阻塞状态是由操作系统内核来进行切换,发生在内核态上。
因此,协程的开销远远小于线程的开销,也就没有了ContextSwitch上的开销。

一个Coroutine如果处于block状态, 可以交出执行权, 让其他的coroutine继续执行(这个是由其实现调度的,对用户透明的).

Coroutines使得开发者可以采用阻塞式的开发风格,却能够实现非阻塞I/O的效果隐式事件调度

函数其实是协程的特例, 从Knuth老爷子的基本算法卷上看“子程序其实是协程的特例”. 子程序是什么?子程序(英语:Subroutine, procedure, function, routine, method, subprogram), 就是函数嘛!所以协程也没什么了不起的, 就是种更一般意义的程序组件, 那你内存空间够大, 创建多少个函数还不是随你么?

协程可以通过yield来调用其它协程. 通过yield方式转移执行权的协程之间不是调用者与被调用者的关系, 而是彼此对称、平等的. 协程的起始处是第一个入口点, 在协程里, 返回点之后是接下来的入口点. 子例程的生命期遵循后进先出(最后一个被调用的子例程最先返回);相反, 协程的生命期完全由他们的使用的需要决定. (continuation)

协程是一种用户态的轻量级线程, 协程的调度完全由用户控制. 协程拥有自己的寄存器上下文和栈. 协程调度切换时, 将寄存器上下文和栈保存到其他地方, 在切回来的时候, 恢复先前保存的寄存器上下文和栈, 直接操作栈则基本没有内核切换的开销, 可以不加锁的访问全局变量, 所以上下文的切换非常快.

协程可以通过yield来调用其它协程. 通过yield方式转移执行权的协程之间不是调用者与被调用者的关系, 而是彼此对称、平等的. 协程的起始处是第一个入口点, 在协程里, 返回点之后是接下来的入口点. 子例程的生命期遵循后进先出(最后一个被调用的子例程最先返回);相反, 协程的生命期完全由他们的使用的需要决定.

协程和线程的区别是:协程避免了无意义的调度, 由此可以提高性能, 但也因此, 程序员必须自己承担调度的责任, 同时, 协程也失去了标准线程使用多CPU的能力

协程编写者可以有一是可控的切换时机, 二是很小的切换代价. 从操作系统有没有调度权上看, 协程就是因为不需要进行内核态的切换, 所以会使用它

协程好处

  • 无需线程上下文切换的开销
  • 线程的默认Stack大小是1M,而协程更轻量,接近1K。
  • 由于在同一个线程上,因此可以避免竞争关系而使用锁, 无需原子操作锁定及同步的开销
  • 状态机:在一个子例程里实现状态机, 这里状态由该过程当前的出口/入口点确定;这可以产生可读性更高的代码.
  • 角色模型:并行的角色模型, 例如计算机游戏. 每个角色有自己的过程(这又在逻辑上分离了代码), 但他们自愿地向顺序执行各角色过程的中央调度器交出控制(这是合作式多任务的一种形式).
  • 产生器:它有助于输入/输出和对数据结构的通用遍历
  • 适用于被阻塞的,且需要大量并发的场景。但不适用于大量计算的多线程,遇到此种情况,更好实用线程去解决。

缺点

  • 不能同时将 CPU 的多个核.不过现在使用协程的语言都用到了多调度器的架构, 单进程下的协程也能用多核了

说了这么多, 无非是说明, coroutine 是从另一个方向演化而来, 它是对 continuation 概念的简化. Lua 设计者反复提到, coroutine is one-shot semi-continuation.

其他说明

  1. 历史上先有协程,OS模拟多任务并发, 非抢占式
  2. 线程能利用多核达到真正的并行计算, 说线程性能不好是因为设计的不好, 有大量的锁/切换/等待. 同一时间只有一个协程拥有运行权, 所以相当于单线程的能力
  3. 说协程性能好的,真正原因是瓶颈在IO上面,这时候发挥不了线程的作用
  4. 事件驱动, Callback也挺不错
  5. 协程可以用同步的代码感觉 写出异步的效果

分割线


进程、线程、协程的关系和区别:

方面: cpu调度, 上下文切换, 数 据共享, 多核cup利用率, 资源占用角度.

线程进程都是同步机制, 而协程则是异步,协程能保留上一次调用时的状态, 每次过程重入时, 就相当于进入上一次调用的状态(continuation)

线程和进程主要区别是在轻量级和重量级, 他们调度都是系统来的; 协程和线程的区别是在调度:协程避免了无意义的调度, 由此可以提高性能, 但也因此, 程序员必须自己承担调度的责任, 同时, 协程也失去了标准线程使用多CPU的能力.

  • 进程拥有自己独立的堆和栈, 既不共享堆, 亦不共享栈, 进程由操作系统调度. 一个进程死亡对其他进程没有影响

  • 线程拥有自己独立的栈和共享的堆, 共享堆, 不共享栈, 线程亦由操作系统调度(标准线程是的), 没有自己独立的地址空间!!! 默认情况下, 线程栈的大小为1MB. 线程私有栈, 一个线程挂掉将导致整个进程挂掉, 因为线程没有自己单独的内存地址空间. 当一个线程向非法地址读取或者写入(伴随栈溢出、读取或者访问了非法地址), 无法确认这个操作是否会影响同一进程中的其它线程, 所以只能是整个进程一起崩溃. (注意 这个崩溃不是 java 的异常哦, jvm 做了很多保护)

  • 协程和线程一样共享堆, 不共享栈, 协程由程序员在协程的代码里显示调度. 栈内存(大概是4~5KB)

  1. 需要频繁创建销毁的优先用线程. 实例:web服务器. 来一个建立一个线程, 断了就销毁线程. 要是用进程, 创建和销毁的代价是很难承受的.

  2. 需要进行大量计算的优先使用线程. 所谓大量计算, 当然就是要消耗很多cpu, 切换频繁了, 这种情况先线程是最合适的. 实例:图像处理、算法处理.

  3. 强相关的处理用线程, 弱相关的处理用进程 什么叫强相关、弱相关?理论上很难定义, 给个简单的例子就明白了. 一般的server需要完成如下任务:消息收发和消息处理. 消息收发和消息处理就是弱相关的任务, 而消息处理里面可能又分为消息解码、业务处理, 这两个任务相对来说相关性就要强多 了. 因此消息收发和消息处理可以分进程设计, 消息解码和业务处理可以分线程设计.

  4. 可能扩展到多机分布的用进程, 多核分布的用线程

总结一下就是IO密集型一般使用多线程或者多进程, CPU密集型一般使用多进程, 强调非阻塞异步并发的一般都是使用协程, 当然有时候也是需要多进程线程池结合的, 或者是其他组合方式. Nginx 用一个进程可以跑满整个机器的 cpu(都是非阻塞的操作, 没有比这更快的了, Nginx 最近也引入了线程池, 对付会阻塞的任务),

比较项 线程 协程
占用资源 初始单位为1MB,固定不可变 初始一般为 2KB,可随需要而增大
调度所属 由 OS 的内核完成 由用户完成
切换开销 涉及模式切换(从用户态切换到内核态)、16个寄存器、PC、SP…等寄存器的刷新等 只有三个寄存器的值修改 - PC / SP / DX.
性能问题 资源占用太高,频繁创建销毁会带来严重的性能问题 资源占用小,不会带来严重的性能问题
数据同步 需要用锁等机制确保数据的一直性和可见性 不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。

进程与线程的区别

  1. 线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;
  2. 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;
  3. 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号),某进程内的线程在其它进程不可见;
  4. 调度和切换:线程上下文切换比进程上下文切换要快得多。

分割线


Continuation

在计算机科学和程序设计中, 延续性(continuation)是一种对程序控制流程/状态的抽象表现形式. 延续性使程序状态信息具体化, 也可以理解为, 一个延续性以数据结构的形式表现了程序在运行过程中某一点的计算状态, 相应的数据内容可以被编程语言访问, 不被运行时环境所隐藏掉.
延续性包含了当前程序的栈(包括当前周期内的所有数据, 也就是本地变量), 以及当前运行的位置. 一个延续的实例可以在将来被用做控制流, 被调用时它从所表达的状态开始恢复执行.

协程wiki

适用于实现彼此熟悉的模块:合作式多任务, 迭代器, 无限列表和管道.

var q := new queue

生产者协程

1
2
3
4
5
loop
while q is not full
create some new items
add the items to q
yield to consume

消费者协程

1
2
3
4
5
loop
while q is not empty
remove some items from q
use the items
yield to produce

每个协程在用yield命令向另一个协程交出控制时都尽可能做了更多的工作. 放弃控制使得另一个例程从这个例程停止的地方开始, 但因为现在队列被修改了所以他可以做更多事情. 尽管这个例子常用来介绍多线程, 实际没有必要用多线程实现这种动态:yield语句可以通过由一个协程向另一个协程直接分支的方式实现.

注意 在yield的时候,队列已经被改变了,下一个生产者(或者消费者)执行的时候,直接在这个中间状态上执行就好了.

详细比较

因为相对于子例程, 协程可以有多个入口和出口点, 可以用协程来实现任何的子例程. 事实上, 正如Knuth所说:“子例程是协程的特例. ”
每当子例程被调用时, 执行从被调用子例程的起始处开始;然而, 接下来的每次协程被调用时, 从协程返回(或yield)的位置接着执行.
因为子例程只返回一次, 要返回多个值就要通过集合的形式. 这在有些语言, 如Forth里很方便;而其他语言, 如C, 只允许单一的返回值, 所以就需要引用一个集合. 相反地, 因为协程可以返回多次, 返回多个值只需要在后继的协程调用中返回附加的值即可. 在后继调用中返回附加值的协程常被称为产生器.
子例程容易实现于堆栈之上, 因为子例程将调用的其他子例程作为下级. 相反地, 协程对等地调用其他协程, 最好的实现是用continuations(由有垃圾回收的堆实现)以跟踪控制流程.

与subroutine(子例程 函数?)比较

当一个subroutine被invoked,execution begins at the start,当它退出时就结束.一个subroutine的实例值返回一次,不持有状态between invocation.

coroutine可以退出通过调用其他coroutine,可能过会会回到在原coroutine调用的那个point(which may return to the point where they were invoked in the original coroutine).从coroutine的视角来看,它并没有退出,只是调用了其他的coroutine(yield).因此,一个coroutine实例holds了state,并且在调用之间会有不同.

两个coroutine yield to each other是对称的(平等的),而且不是调用和被调用的关系(caller-callee 像函数的调用栈?)

所有的subroutine都可以被认为是没有yield的coroutine

要实现subroutine只需要一个简单的栈,可以预先分配,当程序执行的时候.但是coroutine要对等的调用对方,最好的实现是使用continuation

与generators相比

Generators,也叫semicoroutines.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var q := new queue

generator produce
loop
while q is not full
create some new items
add the items to q
yield consume
generator consume
loop
while q is not empty
remove some items from q
use the items
yield produce
subroutine dispatcher
var d := new dictionary(generator → iterator)
d[produce] := start produce
d[consume] := start consume
var current := produce
loop
current := next d[current]

与相互递归相比

Using coroutines for state machines or concurrency is similar to using mutual recursion with tail calls

coroutine 使用yield而不是返回,可以复用execution,而不是重头开始运行.

递归必须使用共享变量或者参数传入状态,每一个递归的调用都需要一个新的栈帧.而在coroutine之间的passing control可以使用现存的contexts,可以简单的被实现为一个jump

coroutines yield rather than return, and then resume execution rather than restarting from the beginning, they are able to hold state, both variables (as in a closure) and execution point, and yields are not limited to being in tail position;

(很久以前的博客了, 不是原创, 参考了很多资料, 加着自己的理解)