浅谈协程

2024-11-2200 分钟
type
status
date
slug
summary
tags
category
icon
password
最近经常被各种人问到一些关于协程的事情,例如 xx 语言的 xx 是不是协程,xx 语言和 xx 语言的协程有什么区别,我不得不一次次 share 出我的文章,索性直接发到 blog 上吧
 
首先,协程是什么?一开始应该抛开一切具体语言的实现去理解协程,给出一个明确定义,但协程的定义实在太多,并且扩展出了各种不同形态,这里我们简单定义协程就是一个「可以暂停和恢复的函数」
 

达夫设备

了解了协程的定义,就是实现问题了
来看一个 fib 协程
generator<int> fib() { int a = 1, b = 1; yield a; yield b; while(true) { int c = a + b; yield c; a = b; b = c; } }
对 fib 进行调用,会返回斐波那契数列 1,1,2,3,5,8…
yield 时,协程被暂停,再次调用,协程被恢复
 
现在我们要实现类似的效果,核心无非两点,如何暂停和如何恢复
一种简单的协程实现是达夫设备,它是一个状态机,状态机上的状态对应每个 yield 的位置
首先通过 static 变量存储状态(也可以通过一个外部的协程结构体来在任意地方存储),接着,yield 时直接 return 返回控制权,调用时通过 switch + goto 跳转回之前的位置,也就是上次 yield 后的位置,这就恢复了状态:
int fib() { static int state = 0; static int a = 1; static int b = 1; static int c = 0; switch (state) { case 0: state = 1; goto s0; case 1: state = 2; goto s1; case 2: state = 3; goto s2; case 3: goto s3; } s0: return a; s1: return b; s2: while(true) { c = a + b; return c; s3: a = b; b = c; } }
 

扩展

有了基本的协程后,就可以谈谈其它语言中的 generator、async / await、goroutine / virtual thread 等实现了
 
对于 generator 和 async / await 模式,实际上和我们上面的实现是类似的,都是一个状态机。但它们通过编译器来生成了这堆繁琐的部分,作为语言的一个语法来提供,可以说是真正能用的协程。例如这样一段 Rust 的 generator 代码
#![feature(generators, generator_trait)] use std::ops::Generator; use std::pin::Pin; fn main() { let mut gen = ||{ yield 1; yield 2; yield 3; return 4; }; let mut pin = Pin::new(&mut gen); for _ in 0..4 { println!("{:?}", pin.as_mut().resume(())); // Yielded(1) // Yielded(2) // Yielded(3) // Complete(4) } }
编译器会生成类似这样的实现,和我们前面的达夫设备基本基本没区别:
notion image
 
 
剩下的一些,就基本属于是对协程的「扩展」了
首先就是所谓的调度器,前面 generator 的模式是没有调度的,或者说,是用户手动控制调度,自己决定何时运行哪个协程,而且这里调度一个协程,就是执行它这个函数。
而 async / await 模式就会有调度器,例如一个函数在 await 另一个 async 函数,此时如果该函数并没有执行完成,await 这里就需要等待,控制流会被切换出去(类似 generator 模式的 yield),调度器会进行调度
在设计更复杂的情况下,调度器的工作会多很多,例如如果不是采用 return 和直接 call 来转移所有权,那就可能需要通过直接跳转的方式(插入汇编或 setjmp/longjmp 等)来进行协程的所有权转移。以及可能有更复杂的调度策略决定运行哪一个协程
南京大学操作系统课程的其中一个实验就是实现这个程度的协程
 
接着是一些协程设计上的问题。前面我们转移所有权的时候,yield 是定向地转移回所有权到 caller 上,因此可以用 return 实现。但更多场景是 yield 时协程 A 希望暂停自己的同时定向恢复协程 B 的运行,但又不希望 A 直接 call B(如果 B 也 call A,就会造成无限递归)。
一种实现是基于调度器的,称为非对称协程,A 返回到调度器,要求调度器 call B。这会带来一些性能损失,且不太能保证实时性
notion image
另一类是对称协程,需要一些特殊的指令,让 A 能够直接切换到 B,且 B 返回后直接回到了调度器,这个过程对调度器是不知情的,这种设计架空了调度器,实现上更加复杂,但性能更好更加灵活
notion image
 
设计上另外一个重要的区别是协程的栈帧,也就是有栈协程和无栈协程。前面实现的协程是无栈协程,它们两个的区别从概念上讲,只有是否保存完整栈帧。
从实现角度来看,无栈线程的「状态」只有局部变量,转移所有权的位置等,对于该协程来说都是固定的,所以每个无栈协程的帧通常是一个定制的结构体(当然编译器帮你实现了),这个结构体就直接代表了整个无栈协程的所有状态,例如前面 fib 的例子,它的这个结构体可能就是这样的
struct fib_state { int a; int b; int c; int state; }
而有栈协程对于帧的保存,无论是哪个协程,都是直接申请一大块内存,将其作为函数的调用栈来使用,一切状态都保存在内部。从这个角度来讲,有栈协程更接近普通函数
struct co { byte stack[STACK_SIZE]; }
 
在使用上的一个重要区别是,有栈协程可以在任意处挂起,包括嵌套函数。而无栈协程不可以,因为它的帧结构只保存了它自己特定的那几个的状态,所以无法恢复到嵌套函数内部。这使得有栈协程在使用上和普通函数几乎没有区别,就像线程一样
实现上的差异这也体现出无栈协程更加紧凑,开销更小,因为它只保存了必要的状态。而有栈协程需要保存的状态更多,而且通常是有些浪费的(当然也可以让这个栈更小,但要考虑动态扩展的问题)
 
现在对于各种语言的协程实现就很清晰了,generator 和 async / await 就是编译器层面上实现的无栈协程,以语法糖的方式来提供。而 goroutine / virtual thread 等更类似线程,这是因为他们是有栈协程,还实现了抢占式的调度器来控制协程的执行,当然这里对于这种抢占式设计是不是协程也有不少争议,因为看起来他们从根本上就违背了协程的「协作式」这一想法
 

性能

对于无栈协程,性能上的优势是明显的,首先它保存的状态是非常轻量的,只保存了必须的状态。其次,无栈协程的场景通常是「协作式」的,也就是 A 运行一会,然后轮到 B 运行
如果通过线程来做这样的操作,大部分时间是浪费在无意义的上下文切换和互斥开销当中了,而无栈协程主动切换控制权,使其每次切换都是有意义的
 
而对于带抢占式调度器的有栈协程,和线程那么像,或者说它就是一种用户线程,那究竟开销少在哪里?
首先,进程和线程的调度需要到内核态做上下文切换,会有以下开销:
  1. 切换页表全局目录
  1. 切换内核态堆栈
  1. 切换硬件上下文(进程恢复前,必须装入寄存器的数据统称为硬件上下文)
  1. 刷新TLB
  1. 内核调度器的执行
其中特别是跨 CPU 导致的 Cache 失效问题,会对性能有明显影响。CPU 之间也可以看作是一种分布式系统,在高性能场景中,跨 CPU 之间的交互就像跨节点的网络通信一样,相对于纯本地运算要慢得多,是要尽力避免的
 
协程调度也需要进行上下文切换,开销主要少在几个方面:
  1. 协程栈通常只有几 KB,比数 M 的线程栈要小得多(无栈协程连这部分也不需要,就只是一个普通的函数调用)
  1. 协程的上下文切换没有跨进程,也就不需要修改额外的内核数据结构(页表等),也不会造成 Cache 失效
  1. 协程调度在用户态,而线程调度在内核态,每次调度都会有额外的上下文切换(到内核态)。协程避免了大量线程频繁的内核态上下文切换,能充分利用分给一个线程的时间片
 

局限

协程的轻量和用户态调度带来很高的性能,那么代价是什么?
首先无栈协程的功能上有局限,例如无法在嵌套函数中挂起,使得它的编程模型还是和普通函数有些不同,也无法实现一些效果,像在大部分语言中 async / await 就会带来传染性
其次对于有栈协程/用户线程,正是因为它运行在用户态,当涉及需要内核支援的部分时就很被动,例如面对阻塞式 I/O 时,无法察觉导致整个内核线程上的所有协程都被阻塞,或者需要一些比较高级的 CPU 亲和性调度时也难以实现
以及如果当调度不是抢占式时,协程如果不让出执行权或者因为一些异常导致执行权没有正常让出,就会一直占用在那里,导致其他协程无法运行。当然 Go 都解决了上面这两个问题,但设计上更加复杂了
 

Ref

使用 C 语言实现协程
本文译自 PuTTY 的作者 Simon Tatham 的文章 Coroutines in C,作者在文中介绍了一种基于 达夫设备 的思想实现的协程。注意, 斜体部分为翻译过程中的补充 。考虑到译者的英文水平有限,部分语句的翻译与原文略有出入,强烈建议读者结合原文观看。 编写大型程序总是一件困难的事。其中常见的一个问题就是:如果你有一段代码正在生产数据,同时有另一段代码正在消费这些数据,它俩之间谁应该是 caller(调用者)谁应该是 callee(被调用者)呢 (译者注,即如何维护它俩之间的调用关系) ? 这里有一段非常简单的 decompressor 代码,以及一段非常简单的 parser 代码: 两段代码都非常简单易懂。前者通过调用 emit() 每次产生一个字符;后者通过调用 getchar() 每次消费一个字符。只需要调用 emit() 和 getchar() 就可以传送数据了,所以 decompressor 产生的数据可以很轻易地传送到 parser 中。 在很多现代操作系统中,你可以在两个进程或线程之间使用管道(pipe)传输数据。在 decompressor 的 emit() 向管道中写数据,在 parser 的 getchar() 从同一个管道中读数据。简单粗暴,同时也非常繁琐和浪费性能。尤其是在你不想因为要做类似的事就得把程序拆分为多线程时。 在本篇文章中,我为这类结构性问题提供一种极具创造性的解决方案。 一种常见的解决方案是重写通信渠道两端中的一端,使之成为一个可以被调用的函数。以下分别是 decompressor 和 parser
使用 C 语言实现协程
【协程革命】实现篇!无栈协程 有手就行?! 全程字幕_哔哩哔哩_bilibili
理解协程的最好方式就是实现它!最近面试开始流行问协程了,请同学们注意进厂时机???, 视频播放量 21613、弹幕量 46、点赞数 802、投硬币枚数 623、收藏人数 1338、转发人数 103, 视频作者 等疾风, 作者简介 C++职业拧螺丝;博客 Codesire-deng.github.io,相关视频:通过画图说一下协程的三种实现方式,我一直以为研究生一觉睡到中午自然醒是一件很小众的事情,大形势正在回暖,未来3-5年普通程序员能赶上的IT红利风口有哪些?卷对方向才能逆天改命!【马士兵】,从零开始刷力扣学C++——第二题:两数相加,MFC感觉已经被QT淘汰差不多了,但还是有很多企业在用,一些招聘要求还是提到MFC,怎么看这件事。,接下来登场的是有着职业生涯幻想大赛、评委拷问大赛、大学生卷王吹牛大赛、ppt模板美化大赛的全国大学生职业生涯规划大赛,从零开始的操作系统(21) 实现简单的任务/协程调度器,不知道做什么项目自我提高, 来试试LLVM教程的编译器项目,Bjarne Stroustrup :程序员需要学习的5种类型编程语言,研二/大三吃透C++大厂面试真题300问,7天学完,让你面试少走99%弯路!【存下吧,附精心整理的面试宝典,学完即可面试上岗】
【协程革命】实现篇!无栈协程 有手就行?! 全程字幕_哔哩哔哩_bilibili
有栈协程与无栈协程
如今协程已经成为大多数语言的标配,例如 Golang 里的 goroutine,JavaScript 里的 async/await。尽管名称可能不同,但它们都可以被划分为两大类,一类是有栈(stackful)协程,例如 goroutine;一类是无栈(stackless)协程,例如 async/await。 此处「有栈」和「无栈」的含义不是指协程在运行时是否需要栈,对于大多数语言来说,一个函数调用另一个函数,总是存在调用栈的;而是指协程是否可以在其 任意 嵌套函数中被挂起,此处的嵌套函数读者可以理解为子函数、匿名函数等。显然有栈协程是可以的,而无栈协程则不可以。似乎难以理解?不要慌,让我们先从函数调用栈开始讲起。 注意,文中所有讨论均基于 x86 平台,在 x86 平台中,调用栈的地址增长方向是从高位向低位增长的。并且本文选取 32 位系统作为讨论对象,因为 16 位已经过时了;而 64 位又稍显复杂,所占篇幅较大,但读者可以轻易地将本文内容推演至 64 位。 首先我们需要明确的是,调用栈是一段连续的地址空间,无论是 caller(调用方)还是 callee(被调用方)都位于这段空间之内。而调用栈中一个函数所占用的地址空间我们称之为「栈帧」(stack frame),调用栈便是由若干个栈帧拼接而成的。一个典型的调用栈模型如下图所示,图片来自 维基百科 : 图中涉及到几个关键点,Stack Pointer 即栈顶指针,总是指向调用栈的顶部地址,该地址由 esp 寄存器存储;Frame Pointer 即基址指针,总是指向当前栈帧(当前正在运行的子函数)的底部地址,该地址由 ebp 寄存器存储。Return Address 则在是 callee 返回后,caller 将继续执行的指令所在的地址;而指令地址是由 eip 寄存器负责读取的,且 eip 寄存器总是预先读取了 当前栈帧中 下一条将要执行的指令的地址。
有栈协程与无栈协程
 

下一篇

MemoryDB: Redis + Remote Log

Loading...