「什么是协程?」几乎是现在面试的必考题。一方面,Donald E. Knuth 说「子过程是协程的一种特殊表现形式」;另一方面,由于 coroutine 的中文翻译「协程」中包含有「程」字,因此一般会拿来与「进程」、「线程」进行比较,称为「轻量级线程」。 第一部分介绍协程的历史; 第二部分主要是介绍函数调用和协作式多任务处理,虽然其他介绍协程的文章中也都讲解了函数调用,在本文中,我在构思如何进行分享时,特意使用汇编来实现函数调用 (汇编实现 main 调用 hello),为后面实现简单的协程库做好铺垫,而这正是理解协程切换的关键,推荐大家阅读; 第三部在实现了一个简单的协程库后,通过对比来加深理解,然后介绍 libco hook 的实现; 第四部分介绍使用协程时需要注意的一些问题。
在本文中,我将试着去回答以下四个问题:
Q1 (Why):为什么需要协程?
我们会一起回顾协程出现的历史背景,当时要解决什么问题;同时,现在是什么场景,需要使用协程来进行处理?为什么进程或者线程不能够很好地满足当下的使用场景?
Q2 (What):到底什么是协程?
我们一直在谈论协程。由于协程中包含有「程」这个字眼,因此经常被拿来与进程线程进行对比,称协程为「用户态线程」;但又有人从协程实现的角度,说「协程是一种泛化的函数」。这就把我们给绕晕了。我们不禁要问,到底什么是协程?在本次分享中,我会试着进行回答。
Q3 (How):怎么实现协程 (库)?
在回答了协程是什么之后,第三个问题就紧随而来,我们可以自己去实现一个简单的协程或者协程库吗?如果可以实现,那我们应该怎么实现呢?
Q4 (Usage):使用协程时需要注意什么?
通过实际编码实现一个简单的协程库后,我们再来看 libco 的实现,就会清晰明了。我们会在第四部分介绍使用协程时需要注意的一些问题。
这就是我本次分享想要达成的目标 —— 回答这四个问题。
首先我们来看第一个问题,为什么需要协程?
Q1: 为什么需要协程?
在 1958 年,协程概念的提出者 Melvin Conway,想要为 COBOL 高级编程语言去实现一个 one-pass 的编译器。
Melvin Conway,康威定律 (Conway's Law) 的提出者,「设计系统的架构受制于产生这些设计的组织的沟通结构。」
这里的 pass,我们可以简单地理解为,一个 pass 就是对输入进行一次完整的扫描。也就是说他希望只扫描一次输入就实现编译,他通过借助协程来完成了这一工作。
为什么需要实现 one-pass 的 COBOL 编译器呢?目前找到的原因如下,来源 : COBOL 语言的限制:COBOL 不是 LL-parse 型语法 使用磁带作为存储设备,磁带存储设备只支持顺序存储,不支持随机访问 依次执行编译步骤并依靠中间文件通信的设计是不现实的,各步骤必须同步前进。
在 Conway 的设计里,词法分析和语法分析是交织在一起。编译器的控制流在词法分析、语法分析之间来回进行切换:当词法分析读入足够多的 token,控制流就交给语法分析;当语法分析消化完所有 token,控制流就交给词法分析。有点类似于我们所熟知的生产者消费者模式。词法分析模块和语法分析模块需要分别独立维护自身的运行状态。他所构建的这种协同工作机制,需要参与者主动让出控制权,同时记住自身状态,以便在控制权返回时能够从上次让出的位置继续往下执行。
他的这些思想发表在 1963 年的论文 "" 中,明确了协程的概念 —— “可以暂停和恢复执行”的函数。
我们知道 1960 年代出现进程的概念,那根据这里提到的年份,可以推断,协程的概念应该是早于进程的,那也就更早于线程。
但是协程不符合当时 (一直持续到 1990 年代) 以 C 语言为代表的命令式编程语言所崇尚的“自顶向下”的程序设计思想,因此对协程的使用和讨论一直都很低迷。
直到近些年,随着互联网的发展,尤其是移动互联网时代的到来,服务端对高并发的要求越来越高,也就是我们需要高性能的网络服务器,以 为代表,需要单机同时支持 1 万个并发连接,到 2013 年时候的单机要支持 1 千万并发连接 (, 中 libco 通过共享栈支持单机千万连接),协程开始重新进入视野。
如果要满足单机 1 千万的并发连接,我们先来看一下以下两种方案:
第一种是,多线程同步模型:
对应 C10K 中的 “”,即预先创建出很多个处理线程,每个线程采用同步阻塞 IO 的方式串行处理请求,由操作系统通过线程切换来实现并发处理。这种方式开发者编码简单,但由于线程堆栈占用空间大,内存消耗太快,同时线程切换代价高,导致系统整体性能较差,现实开发中很少有人会使用这种模型。
第二种是,基于事件驱动的异步网络模型,以 Nginx 为代表,Nginx 将事件驱动+异步回调做到了极致:
对应 C10K 中的 “”,这种方式由应用框架来实现事件驱动和状态切换,可以充分利用 CPU,性能较高,但因为处理都是基于回调,逻辑代码过于分散,导致代码开发效率不高,代码逻辑不易懂也容易出错。
那是否有一种方式,可以综合二者的优点,同时又不会引入太高的复杂度,就可以解决 C10K 乃至 C10M 的问题,解决好服务器充斥着的大量的 IO 请求的问题呢?
也就是,我既要「同步编程风格」,能够让业务开发人员很便捷地去开发业务逻辑代码,同时能够达到「异步回调模型的性能」。
那就是协程大展拳脚的场景了 (协程 + IO Hook)。那什么是协程呢?
“其实不应该把协程和多线程做类比,协程更多的是取代异步状态机的数据结构,如果明确这点,就能够清晰使用场景了。” —— from libco 的实现者Q2: 到底什么是协程?
首先我们来看一下维基百科对协程的定义:
Coroutines are computer program components that generalize subroutines for non-preemptive multitasking, by allowing execution to be suspended and resumed. —— from
协程是一类程序组件,它是对子过程概念的泛化,并且是属于非抢占的多任务处理。
co-routine泛化的子过程 (generalize subroutines),也就是说协程是泛化的函数 (subroutines, alias procedures, functions, methods, handlers etc) -- 那协程在哪些方面,相较函数更为泛化呢?这里也有提到,就是说协程可以暂停和恢复执行的 (allowing execution to be suspended and resumed),与我们前面讲到的 Conway 对协程概念的定义是一致的;
非抢占的多任务处理 (non-preemptive multitasking) -- 这里说明了协程的作用,也就是协作式多任务处理 (cooperative multitasking),同时也点明了协程的特点,协作,因此需主动让出 CPU。
CSAPP Section 3.7 Procedures. "Procedures come in many guises in different programming languages—functions, methods, subroutines, handlers, and so on—but they all share a general set of features."
接下来我们展开对这两个概念的讲解:函数和多任务处理,并且讨论这两个概念与协程的关系。
函数与函数调用
函数,是我们日常开发中最常用到的一种封装手段,它将一组实现特定功能的代码段封装起来,接受一些输入参数,返回一些输出参数。
mainhellohelloworldint world(int num) {return 42;int hello(int num) {int x = 32;int* y = &x;// ....return world(num);}int main() {int num = 5;hello(num); // <--·return 0;}函数调用的关键点 (CSAPP Section 3.7 Procedures):
控制权转移
函数调用
函数返回
数据传递
函数入参
函数返回值
内存的分配和释放
需要遵循 calling conventions (ABI),。
说起来比较抽象,我们直接看看对应的汇编代码:
mainhelloworldpushq %rbpmovq %rsp, %rbprbprsprbpmainhellomainrbprbphellohelloworldhelloworldmainhelloworldpopq %rbprsppoprbpworldrsppoprbprbphello因此,我们说,函数调用就是创建栈帧,函数返回就是弹出栈帧。
我们继续看看如何实现控制权的跳转:
mainhellocall hellocall helloeip这样我们就明白了,要调用一个函数,主要就是两个比较重要的事项:
eiprsprsppushpoprsp其他的函数入参和返回值,直接按照 ABI 规范来就可以了。
实际使用函数时,我们不用太在意这里的细节。但理解这些细节,可以帮助我们更好的理解协程。
学习这一部分的内容,还会有一些额外收获: 为什么函数参数最好控制在 6 个参数内,因为 x86-64 的函数调用,前 6 个参数是通过寄存器传递的,多余的参数通过入栈传递; 不要直接传递大对象,使用指针或引用 ...
mainhello使用汇编实现函数调用
riprsphelloripcall hellohellohellomallocmallocmainhellohellomainrbprspmallocrsprdicall hellopop# store sth, for later use --> 序言 (prologue)# store old `%rsp`movq hello_stack_sp, %rsp # 设置 `hello` 调用栈 !!!movq $0x5, %rdi # 设置入参call hello # 1) 保存返回地址# 2) 跳转到 `hello` 执行# restore and resume --> 后记 (epilogue)# resume old `%rsp`借助下面的图方便进行理解。记住,这里是理解协程的关键。
从这里我们也可以看出,其实函数调用栈就是一块内存,它不一定需要连续 ("")。
按照这个思路,接下来我们看一下编码实现。
#include#include#include// 调用栈太小的话,在执行函数调用时会报错 (printf)// fish: Job 1, './call-hello' terminated by signal SIGSEGV (Address boundary// error)#define STACK_SIZE (64 * 1024) // <-- caution !!!uint64_t world(uint64_t num) {printf("hello from world, %ld\n", num);return 42;}uint64_t hello(uint64_t num) { return world(num); }int main() {uint64_t num = 5, res = 0;char *stack = malloc(STACK_SIZE); // 分配调用栈 !!!char *sp = stack + STACK_SIZE;asm volatile("movq %%rcx, 0(%1)\n\t""movq %1, %%rsp\n\t""movq %3, %%rdi\n\t""call *%2\n\t": "=r"(res) /* output */: "b"((uintptr_t)sp - 16), /* input */"d"((uintptr_t)hello),"a"((uintptr_t)num));asm volatile("movq 0(%%rsp), %%rcx" : :); // 这里的 restore 可以删除掉printf("num = %ld, res = %ld\n", num, res);return 0;}// hello from world, 5// num = 5, res = 42mainhellohello(5)rsp本质就是,实现控制权的转移,同时在程序运行时一直满足 ABI 的要求。
,特别注意 ❗️:x86-64 要求调用栈按照 16 字节对齐 (当然这就是 ABI 的要求了,我们需要查询手册)
讲了这么多关于函数和函数调用的知识点,基本上都是我们熟知的。大家可能会纳闷,函数调用和我们今天要讲的协程有什么关系呢?
前面我们提到「协程是泛化的函数」,其实这话是高德纳说的:
Subroutines are special cases of more general program components, called coroutines. In contrast to the unsymmetric relationship between a main routine and a subroutine, there is complete symmetry between coroutines, which call on each other. -- Donald E. Knuth, Art of Computer Programming - Volume 1 (Fundamental Algorithms), 1.4.2. Coroutines
与函数相比,协程要更通用一些,即函数是协程的一种特殊情况。
mainhellohelloworldworldhellohellohellomainmainmainhellomainyieldresumehellohellohelloyieldreturnresumemainmainyieldhelloyieldreturnyieldhelloyieldhelloyieldmainhellorsprip回到函数与协程,此时,我们可以说,函数是协程的一种特例,协程切换和函数调用,二者的操作大体相同。
coroutine多任务处理:并发地执行多个任务的能力
多任务是操作系统提供的特性,指能够并发地执行多个任务。比如现在我使用 chrome 在进行投影,同时后台还运行着微信、打开着终端。这样看上去好像多个任务是在并行运行着,实际上,一个 CPU 核心在同一时间只能执行一条指令。图示中一个矩形框表示一个 CPU 核心。
那如何在单核 CPU 上执行多任务呢?这依赖于分时系统,它将 CPU 时间切分成一段一段的时间片,当前时间片执行任务 1,下一个时间片执行任务 2,操作系统在多个任务之间快速切换,推进多个任务向前运行。由于时间片很短,在绝大多数情况下我们都感觉不到这些切换。这样就营造了一种“并行”的错觉。
那真实的并行是怎么样的呢?需要有多个 CPU 核心,每一个核心负责处理一个任务,这样在同一个时间片下就会同时有多条指令在并行运行着 (每个核心对应一条指令),不需要在任务之间进行上下文切换。
Rob Pike,也就是 Golang 语言的创始人之一,在 2012 年的一个分享 (Concurrency is not Parallelism, Rob Pike, 2012, ) 中就专门讨论了并发和并行的区别,很直观地解释了二者的区别:
Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once. -- Rob Pike
并发是一种同时处理很多事情的能力,并行是一种同时执行很多事情的手段。
我们把要做的事情拆分成多个独立的任务进行处理,这是并发的能力。在多核多 CPU 的机器上同时运行这些任务,是并行的手段。可以说,并发是为并行赋能。当我们具备了并发的能力,并行就是水到渠成的事情。
所以我们平时都谈论高并发处理,而不会说高并行处理(这是高性能计算中的内容)。
今天,在这里,我们主要讨论的是单核 CPU 上的多任务处理,涉及到几个问题:
任务是什么,怎样抽象任务这样一个概念?
多个任务之间需要进行切换,把当前任务的上下文先保存起来,把另一个任务的上下文恢复,那么任务的上下文都包含哪些东西呢,如何进行上下文的保存和恢复呢?
什么情况下进行任务切换?
下面我们来看一下任务包含哪几个层次的抽象。
任务抽象:进程、线程、协程
从今天的实现看,任务的抽象并不唯一。
我们熟悉的进程和线程,以及今天讨论的协程,都可以作为这里任务的抽象。这三类对象都可被 CPU 核心赋予执行权,因此每个抽象本身至少需要包含下一条将要执行的指令地址,以及运行时的上下文。
任务抽象 上下文 进程 PCB 线程 TCB 协程 use-defined
从任务的抽象层级来看:对于进程,其上下文保存在进程控制块中;对于线程,其上下文保存在线程控制块中;而对于协程,上下文信息由程序员自己进行维护。
但如果我们换一个角度,从 CPU 的角度来看,这里所说的任务的上下文表示什么呢?我们都知道,冯诺依曼体系结构计算机,执行程序主要依赖的是内置存储:寄存器和内存,这就构成了程序运行的上下文 (context)。
寄存器的数量很少且可以枚举,我们直接通过寄存器名进行数据的存取。在将 CPU 的执行权从任务 1 切换到任务 2 时,要把任务 1 所使用到的寄存器都保存起来 (以便后续轮到任务 1 继续执行时进行恢复),并且寄存器的值恢复到任务 2 上一次执行时的值,然后才将执行权交给任务 2。
再看内存,不同的任务可以有不同的地址空间,通过不同的地址映射表来体现。如何切换地址映射表,也是修改寄存器。
所以,任务的上下文就是一堆寄存器的值。要切换任务,只需要保存和恢复一堆寄存器的值就可以了。针对进程、线程、协程,都是如此。
这样,我们就回答了上一页中,什么是任务以及任务的上下文是什么,如何进行保存和恢复。
接下来我们来看上一页中的第三个问题,任务在什么时候进行切换?一个任务占用着 CPU 核心正在运行着,有两种方式让它放弃对 CPU 的控制,一个是主动,一个是被动。
主动和被动,在计算机中有它的专有用词,抢占式和协作式。
抢占式 (preemptive) 是被动的,由操作系统来控制任务切换的时机。
在每次中断后,操作系统都能够重新获得 CPU 的控制权。上图展示了当一个硬件中断到达时,操作系统进行任务切换。
while (true) { }抢占式的问题也很明显,因为有操作系统的参与,每次进行任务切换,都会从用户态切换到内核态,还需要保存任务的上下文信息,因此上下文切换开销比较大。同时由于每个任务都有单独的栈空间,在启动过多任务时,内存占用大,会限制系统支持运行的任务数量。
与抢占式多任务强制性地暂停任务的执行不同,协作式 (cooperative) 多任务允许任务一直运行,直至该任务主动放弃 CPU 的执行权。
强调任务之间的协作,这样任务更加灵活,可以在其适当的时间点暂停自身的运行,让出 CPU,避免 CPU 时间的浪费 (例如任务在等待 IO 操作完成时),然后当等待的条件满足时,能够再次被调度执行。这也有问题,依赖程序的实现,如果程序一直不让出 CPU,我们是没有任何办法的,只能等待让出。
实际上,调度方式并无高下,完全取决于应用场景:
抢占式多任务需要 CPU 硬件的支持,操作系统运行在内核态 level 0,而用户程序运行在用户态 level 3,因此操作系统可以剥夺进程的执行权限,抢占控制流,天然适合与用户有交互的场景,因为调度器可以优先保证对用户交互;
协同式多任务适用于那些没有处理器权限支持的场景,这些场景包含资源受限的嵌入式系统和实时系统。在这些系统中,程序均以协程的方式运行。调度器负责控制流的让出和恢复。通过协程的模型,无需硬件支持,我们就可以在一个“简陋”的处理器上实现一个多任务的系统。我们见到的许多智能设备,如运动手环,基于硬件限制,都是采用协同调度的架构。
co-routine函数,协程就是一个函数,只是它支持多次的暂停和恢复执行,需要我们自己手动维护调用栈和其他的一些信息;
协作式多任务处理,协程主动让出 CPU,天然就支持协作式多任务。
那现在,我们可以说什么是协程了吗?
那协程到底是什么呢?
其实,就是 Conway 最开始给协程下的定义,协程就是 “可以暂停和恢复执行” 的函数,其“全部精神就在于控制流的主动让出和恢复”。
当然,也有人认为只有 goroutine 那样的才是完备的协程,认为一个完备的协程库就是一个用户态的操作系统,而协程就是用户态操作系统里面的 “进程” (from 许式伟的架构课)。
本次分享中,我们还不会涉及到这么复杂的内容。控制流的主动让出和恢复,就是我们理解协程的关键了。
知道了协程是什么,那我们如何实现一个协程呢?
Q3: 怎么实现协程 (库)?
这部分内容实现一个简单的协程库,来源于南京大学《操作系统:设计与实现》 - 。
在分享中,进行了详细的代码实现解释,基于 jyy 老师的课程要求,这里不直接在文章中贴对应的实现代码了,大家可以点击 南京大学《操作系统:设计与实现》 - ,jyy 老师给到了详细的实现 notes,我从里面学习到了很多,相信你也会和我有一样的感受。实现的过程中,如果有疑问,可以私信我进行交流。
假设你已经完成了实验 (在阅读本文的时候,还没有做实验,也没关心,不影响后续内容的理解),让我们一起来做一个技术总结。
setjmplongjmpstack_switch_callucontext(有栈) 协程 独立栈,每个协程有单独的调用栈libco 同时支持独立栈和共享栈
mallocfree,自动回收内存co_free协程栈溢出检测 不支持mprotectyield后的控制流 对称协程,调度器选择一个可运行的协程非对称协程,只能返回调用者 → libco
1:N调度 (单线程调度),双向链表管理,round-robin1:NM:N按照表格从上往下:
setjmplongjmpstack_switch_callmainhelloucontextmallocfreeco_waitco_yield1:NM:N更详细的介绍,可以看
通过比较我们实现的协程,与其他的一些协程实现,可以加深我们对协程的理解。
但这里,我们可能还有其他的一些问题,还可以利用协程做什么呢?
还可以做什么?
Mutexman 3 pthread_getspecific今天限于时间和篇幅,先主要讲解一下 libco 的 IO hook,如果大家多这里其他的点也很感兴趣可以告诉我,我看看可以再补充一下内容,作为协程分享 2.0。
接下来我们看看 libco 的 IO hook。
libco Hook
acceptrecvsend// echo server, 删除了 setup 和 error handling 的代码int main(int argc, char *argv[]) {int server_fd, client_fd;struct sockaddr_in server, client;char buf[BUF_SIZE];server_fd = socket(AF_INET, SOCK_STREAM, 0);bind(server_fd, (struct sockaddr *)&server, sizeof(server));listen(server_fd, 1024);while (1) {socklen_t client_len = sizeof(client);client_fd = accept(server_fd, (struct sockaddr *) &client, &client_len); // blockwhile (1) {int read = recv(client_fd, buf, BUF_SIZE, 0); // blocksend(client_fd, buf, read, 0); // block}}return 0;}很明显,如果这段代码直接出现在 svrkit 框架代码中,那即使配合使用 libco 也救不回来 (假设 libco 未实现 hook socket),一个阻塞操作,整个服务就暂停了。
epollkqueuerecv()client_fdclient_fdepoll()EPOLLINco_yield()epoll()fd就这么简单,如 libco 的实现者所说 “设计应该是简单的,非常简单”。
那 libco 是如何实现 Hook 的呢?
libco 使用 对系统调用进行了 Hook,包含一些几类函数, 大家可以点击这里的链接,跳转过去直接查看代码:
socketpolldlysmman 3 recv#includessize_t recv(int socket, void *buffer, size_t length, int flags);// `basic/colib/co_hook_sys_call.h` 中与系统调用签名相同的函数,例如 `recv()`typedef ssize_t (*recv_pfn_t)(int socket, void* buffer, size_t length, int flags);recv()librecvhook.sorecv()librecvhook.so > libc.sorecv()recvdlsymRTLD_NEXT// 使用 `dlsym` 查找动态库中 `recv` symbol 的地址// `RTLD_NEXT` - 跳过找到的第一个地址,获取该 symbol 对应的第二个地址static recv_pfn_t g_sys_recv_func = (recv_pfn_t)dlsym(RTLD_NEXT, "recv");libco 是如何将 dlsym hook 系统调用与协程有机结合起来的呢?
recvrecv()pollpollssize_t recv(int socket, void* buffer, size_t length, int flags) {
// 获取系统调用的 `recv()` 的符号地址
HOOK_SYS_FUNC(recv); // <-- 1) here
// 判断当前协程是否开启了 Hook,如果没有开启,则使用系统调用;libco 中,所有协程初始化时,Hook 都是没有开启的
if (!co_is_enable_sys_hook()) {
return g_sys_recv_func(socket, buffer, length, flags);
// 根据 fd 获取对应的 rpc 信息,包含协程的结构体信息rpchook_t* lp = get_by_fd(socket); // <-- 2) here// 如果没有开启非阻塞,直接调用系统调用if (!lp || (O_NONBLOCK & lp->user_flag)) {return g_sys_recv_func(socket, buffer, length, flags);}int timeout = (lp->read_timeout.tv_sec * 1000) + (lp->read_timeout.tv_usec / 1000); // 超时时间// 通过 `poll` 等待读完成,这里会切换到父协程,父协程会在读事件/超时事件触发时回到该协程struct pollfd pf = {0};pf.fd = socket;pf.events = (POLLIN | POLLERR | POLLHUP);poll(&pf, 1, timeout); // `poll` 也被 hook 了 // <-- 3) yield here !!!// 有数据了,或者超时,读取数据,调用的是系统调用,会判断是否读取成功ssize_t readret = g_sys_recv_func(socket, buffer, length, flags); // <-- 4) hereif (readret < 0) {return readret;}return readret;}libco 源码,
至此,我们就讲完了协程以及 libco 的原理部分了。
那理解这些,对我们使用协程有什么用呢?这就进入了第四部分。
Q4: 说了这么多,在工作中有什么用呢? Case 1: 协程栈溢出
malloclibco 协程栈的内存分配代码,:
struct stCoRoutine_t* co_create_env(/* ... */) {// 协程栈默认 128KB,最大 8MBif (at.stack_size <= 0) {at.stack_size = 128 * 1024;} else if (at.stack_size > 1024 * 1024 * 8) {at.stack_size = 1024 * 1024 * 8;// ...stCoRoutine_t* lp = (stCoRoutine_t*)malloc(sizeof(stCoRoutine_t));stStackMem_t* stack_mem = NULL;stack_mem = co_alloc_stackmem(at.stack_size);lp->stack_mem = stack_mem;lp->ctx.ss_sp = stack_mem->stack_buffer;lp->ctx.ss_size = at.stack_size;return lp;}stStackMem_t* co_alloc_stackmem(unsigned int stack_size) {stStackMem_t* stack_mem = (stStackMem_t*)malloc(sizeof(stStackMem_t));stack_mem->occupy_co= NULL;stack_mem->stack_size = stack_size;stack_mem->stack_buffer = (char*)malloc(stack_size); // <-- herestack_mem->stack_bp = stack_mem->stack_buffer + stack_size;return stack_mem;}协程栈默认 128KB,最大 8MB。
在进行业务开发时,如果直接在协程栈上分配一块大内存,就直接爆栈了。
int send() {char raw_buffer[400000 + 8]; // 400KB// ... do sth with raw_bufferyield看下面这段代码片段:
// 示例 1: 显式使用线程锁,`co_yield` 协程切换时未释放该线程锁std::mutex g_mutex;void hello() {const std::lock_guard lock(g_mutex);co_yield(); // 请求 rpc,或其他 libco hook 了的非阻塞 IO// `g_mutex` is released when lock goes out of scopeyieldLocks the . If another thread has already locked the mutex, a call to lock will block execution until the lock is acquired.
这个是显式使用线程锁,比较容易发现。
我们再来看一个隐式使用线程锁的例子,下面这段代码,一个经典的单例模式的实现:
// 示例 2: Meyers Singleton Pattern,隐式使用线程锁class Singleton {public:static Singleton &GetInstance() {static Singleton instance; // <--· here !!!return instance;private:Singleton() { co_yield(); } // <--· here !!!void hello() {auto conf = Singleton::GetInstance();// ... do sth with confstaticstaticyield使用 从编译器 (clang) 的角度看看代码,文档 ,。
yieldhttps://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=fd3b0f156a35dc9c64add06e8118b04e
yield到这里第四部就结束了。
接下来,对今天的分享做一个总结。
总结
在今天的协程分享中,我尝试去回答了以下四个问题:
Q1 (Why):为什么需要协程?
Q2 (What):到底什么是协程?
Q3 (How):怎么实现协程 (库)?
Q4 (Usage):同时,列举了我们在使用协程时需要注意的一些问题?
主要的一个思想是,协程的概念其实很简单 --协程就是一个 “可以暂停和恢复执行” 的函数,其“全部精神就在于控制流的主动让出和恢复”。
在现在的高级语言中,实现协程思想的方法很多,这些实现间并无高下之分,所区别的仅仅是其适合的应用场景。
理解这一点,我们对于各种协程的分类,如对称/非对称协程、有栈与无栈协程,就能够更加明白其本质,而无需在实现细节上过于纠结。
最后是一些个人思考。
从 Conway 在 1958 年协程思想,到现在各种协程实现和框架的遍地开发,经历了很长的时间。
在看看 docker 的思想,涉及到的主要技术,例如 Linux Namespace、CGroup、AUFS,DeviceMapper,等技术也是在 2010 年前就已经存在,通过整合后就出现了 2013 年的 docker,以及现在的云原生。
还有神经网络,最早的神经网络思想可以追溯到 1940 年,1975 年提出的反向传播算法,到 2012 年 AlexNet 在 ImageNet 上取得优秀的效果,就开始突然又火了起来,再到现在的 transformers。
这些都是「新瓶装旧酒 The New “Old Stuff”」的典型例子,这样装了之后衍生出了很酷很有用的技术,例如 libco,将协程与 io hook 绑在一起 (libco == coroutine + socket hooking),完全焕发新生
最后是在整理分享内容时,看到的一些比较有用的资料推荐。
至此,我的分享结束,谢谢大家。
更多资料
课程推荐:
南京大学《 》 jyyyyds,
,第 1-2, 33-34, 38-40 讲
,第 11 讲
Tutorials:
Writing an OS in Rust,
Videos:
A Curious Course on Coroutines and Concurrency, ,
What is a Coroutine Anyway?, ,
Concurrency is not Parallelism, , bilibili
Go Concurrency Patterns, ,
分享中提到的一些工具
查看汇编
从编译器 (clang) 的角度看 C++ 代码,
画图