「什么是协程?」几乎是现在面试的必考题。
在本文中,我将试着去回答以下四个问题:
这就是我本次分享想要达成的目标 —— 回答这四个问题。
首先我们来看第一个问题,为什么需要协程?
在 1958 年,协程概念的提出者 Melvin Conway,想要为 COBOL 高级编程语言去实现一个 one-pass 的编译器。
这里的 pass,我们可以简单地理解为,一个 pass 就是对输入进行一次完整的扫描。也就是说他希望只扫描一次输入就实现编译,他通过借助协程来完成了这一工作。
在 Conway 的设计里,词法分析和语法分析是交织在一起。编译器的控制流在词法分析、语法分析之间来回进行切换:当词法分析读入足够多的 token,控制流就交给语法分析;当语法分析消化完所有 token,控制流就交给词法分析。有点类似于我们所熟知的生产者消费者模式。词法分析模块和语法分析模块需要分别独立维护自身的运行状态。他所构建的这种协同工作机制,需要参与者主动让出控制权,同时记住自身状态,以便在控制权返回时能够从上次让出的位置继续往下执行。
他的这些思想发表在 1963 年的论文 "Design of a separable transition-diagram compiler" 中,明确了协程的概念 —— “可以暂停和恢复执行”的函数。
我们知道 1960 年代出现进程的概念,那根据这里提到的年份,可以推断,协程的概念应该是早于进程的,那也就更早于线程。
但是协程不符合当时 (一直持续到 1990 年代) 以 C 语言为代表的命令式编程语言所崇尚的“自顶向下”的程序设计思想,因此对协程的使用和讨论一直都很低迷。
直到近些年,随着互联网的发展,尤其是移动互联网时代的到来,服务端对高并发的要求越来越高,也就是我们需要高性能的网络服务器,以 C10K 为代表,需要单机同时支持 1 万个并发连接,到 2013 年时候的单机要支持 1 千万并发连接 (C10M 问题,微信开源 libco:简单易用高性能的协程库 中 libco 通过共享栈支持单机千万连接),协程开始重新进入视野。
如果要满足单机 1 千万的并发连接,我们先来看一下以下两种方案:
对应 C10K 中的 “一个服务器线程服务一个客户端,使用同步阻塞 IO”,即预先创建出很多个处理线程,每个线程采用同步阻塞 IO 的方式串行处理请求,由操作系统通过线程切换来实现并发处理。这种方式开发者编码简单,但由于线程堆栈占用空间大,内存消耗太快,同时线程切换代价高,导致系统整体性能较差,现实开发中很少有人会使用这种模型。
对应 C10K 中的 “一个线程服务多个客户端,使用非阻塞 IO 和就绪时通知机制”,这种方式由应用框架来实现事件驱动和状态切换,可以充分利用 CPU,性能较高,但因为处理都是基于回调,逻辑代码过于分散,导致代码开发效率不高,代码逻辑不易懂也容易出错。
那是否有一种方式,可以综合二者的优点,同时又不会引入太高的复杂度,就可以解决 C10K 乃至 C10M 的问题,解决好服务器充斥着的大量的 IO 请求的问题呢?
也就是,我既要「同步编程风格」,能够让业务开发人员很便捷地去开发业务逻辑代码,同时能够达到「异步回调模型的性能」。
那就是协程大展拳脚的场景了 (协程 + IO Hook)。那什么是协程呢?
首先我们来看一下维基百科对协程的定义:
协程是一类程序组件,它是对子过程概念的泛化,并且是属于非抢占的多任务处理。
这里有两个关键概念,分别对应 co-routine 的两个部分:
接下来我们展开对这两个概念的讲解:函数和多任务处理,并且讨论这两个概念与协程的关系。
函数,是我们日常开发中最常用到的一种封装手段,它将一组实现特定功能的代码段封装起来,接受一些输入参数,返回一些输出参数。
例如,下面这段简短的 代码片段 (跳转过去看看汇编,AT&T 汇编语法),main 调用 hello,hello 调用 world:
int 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),System V ABI AMD64, pdf。
说起来比较抽象,我们直接看看对应的汇编代码:
因此,我们说,函数调用就是创建栈帧,函数返回就是弹出栈帧。
我们继续看看如何实现控制权的跳转:
这样我们就明白了,要调用一个函数,主要就是两个比较重要的事项:
其他的函数入参和返回值,直接按照 ABI 规范来就可以了。
实际使用函数时,我们不用太在意这里的细节。但理解这些细节,可以帮助我们更好的理解协程。
这里很多事情都是编译器帮我们干了,如果我们要自己动手,使用汇编实现 main 调用 hello,我们应该怎么做呢?
只需要维护好前面提到的两个比较重要的寄存器 rip 和 rsp 就可以了。
首先,要调用 hello,就包含有控制权的转移,需要修改 rip,这个比较简单,直接call hello 就可以了。
其次控制权转移到 hello 后,函数开始执行,需要为 hello 分配好调用栈,直接使用 malloc 在堆上分配。需要注意 malloc 返回的地址是低地址,需要加上分配的内存大小获取得到高地址。这是由于我们要在动态分配的这块内存上模拟栈的行为,而在 x86-64 中栈底是高地址,栈从高地址往低地址进行增长。
这样我们的栈帧就不是连续的了,main 的栈帧在栈上,hello 的栈帧在堆上。但其实这是没有影响的,只需要能够维护好栈帧的链式关系就可以了。在进入 hello 时,和之前一样会保存好 main 的 rbp,因此我们不需要额外关注链式结构。
我们只需要手动维护 rsp 就可以了。
用一段伪 (汇编) 代码描述一下,将 malloc 分配的内存,其高地址设置到 rsp 中,将参数设置到 rdi 中,call hello 进行调用。可以在前后做一些操作,将后续会用到的数据保存到栈中,后面 pop 出来继续使用。
# 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`
借助下面的图方便进行理解。记住,这里是理解协程的关键。
从这里我们也可以看出,其实函数调用栈就是一块内存,它不一定需要连续 ("the stack is a simple block of memory")。
按照这个思路,接下来我们看一下编码实现。
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
// 调用栈太小的话,在执行函数调用时会报错 (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
", 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)
"
"movq %1, %%rsp
"
"movq %3, %%rdi
"
"call *%2
"
: "=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
", num, res);
return 0;
}
// hello from world, 5
// num = 5, res = 42
主要关注 main 函数中调用 hello 的这段内联汇编,查看其对应的汇编代码 (代码片段 (跳转过去看看汇编,同时本地用 gdb 调试一下)),与直接调用 hello(5) 的汇编代码基本一致。这里我们未实现 restore old rsp,需要注意。
本质就是,实现控制权的转移,同时在程序运行时一直满足 ABI 的要求。
讲了这么多关于函数和函数调用的知识点,基本上都是我们熟知的。大家可能会纳闷,函数调用和我们今天要讲的协程有什么关系呢?
前面我们提到「协程是泛化的函数」,其实这话是高德纳说的:
与函数相比,协程要更通用一些,即函数是协程的一种特殊情况。
还是来看看函数调用 (左上角的这张图),main 函数调用 hello,hello 再调用 world,会形成一个函数调用栈。当 world 执行完毕后,返回 hello,hello 继续执行,hello 执行完毕后,返回 main,main 继续执行,直至执行完成。
这都是站在 main 函数的角度在理解。
我们可以试着换一个视角,站在 hello 函数的角度来看看 (左下角的这张图)。
就可以这么理解 —— main yield 主动让出执行权,resume hello,hello 获得执行权,开始执行,执行完成后 hello yield 让出执行权 (return 语句),resume main 函数继续执行。这样来看,main 总是从上一次 yield 的地方继续向下执行。
但协程又与函数调用有些不同,协程能够多次被暂停和恢复 (右上角的这张图)。hello 可以 yield 主动让出控制权 (这就是与普通函数调用不一样的地方,普通函数只能在 return 时 yield 让出控制权,不会再次恢复执行),再次唤醒时,hello 从最近一次 yield 的地方继续往下执行。
我们只需要在 hello yield 时保存执行的上下文,后面重新获取 CPU 控制权时,恢复保存的上下文。如何实现控制流的主动让出和返回 —— 这个比较容易实现,类比 main 通过汇编调用 hello,维护好 rsp 和 rip 等寄存器就可以了。会在第三部分实现一个简单的协程时,详细进行说明。
回到函数与协程,此时,我们可以说,函数是协程的一种特例,协程切换和函数调用,二者的操作大体相同。
接下来,我们继续来看一下 coroutine 的第二个关键点,多任务处理。
多任务是操作系统提供的特性,指能够 并发 地执行多个任务。比如现在我使用 chrome 在进行投影,同时后台还运行着微信、打开着终端。这样看上去好像多个任务是在并行运行着,实际上,一个 CPU 核心在同一时间只能执行一条指令。图示中一个矩形框表示一个 CPU 核心。
那如何在单核 CPU 上执行多任务呢?这依赖于分时系统,它将 CPU 时间切分成一段一段的时间片,当前时间片执行任务 1,下一个时间片执行任务 2,操作系统在多个任务之间快速切换,推进多个任务向前运行。由于时间片很短,在绝大多数情况下我们都感觉不到这些切换。这样就营造了一种“并行”的错觉。
那真实的并行是怎么样的呢?需要有多个 CPU 核心,每一个核心负责处理一个任务,这样在同一个时间片下就会同时有多条指令在并行运行着 (每个核心对应一条指令),不需要在任务之间进行上下文切换。
Rob Pike,也就是 Golang 语言的创始人之一,在 2012 年的一个分享 (Concurrency is not Parallelism, Rob Pike, 2012, slide) 中就专门讨论了并发和并行的区别,很直观地解释了二者的区别:
并发是一种 同时处理 很多事情的能力,并行是一种 同时执行 很多事情的手段。
我们把要做的事情拆分成多个独立的任务进行处理,这是并发的能力。在多核多 CPU 的机器上同时运行这些任务,是并行的手段。可以说,并发是为并行赋能。当我们具备了并发的能力,并行就是水到渠成的事情。
所以我们平时都谈论高并发处理,而不会说高并行处理(这是高性能计算中的内容)。
今天,在这里,我们主要讨论的是单核 CPU 上的多任务处理,涉及到几个问题:
下面我们来看一下任务包含哪几个层次的抽象。
从今天的实现看,任务的抽象并不唯一。
我们熟悉的进程和线程,以及今天讨论的协程,都可以作为这里任务的抽象。这三类对象都可被 CPU 核心赋予执行权,因此每个抽象本身至少需要包含下一条将要执行的指令地址,以及运行时的上下文。
任务抽象上下文进程PCB线程TCB协程use-defined
从任务的抽象层级来看:对于进程,其上下文保存在进程控制块中;对于线程,其上下文保存在线程控制块中;而对于协程,上下文信息由程序员自己进行维护。
但如果我们换一个角度,从 CPU 的角度来看,这里所说的任务的上下文表示什么呢?我们都知道,冯诺依曼体系结构计算机,执行程序主要依赖的是内置存储:寄存器和内存,这就构成了程序运行的上下文 (context)。
寄存器的数量很少且可以枚举,我们直接通过寄存器名进行数据的存取。在将 CPU 的执行权从任务 1 切换到任务 2 时,要把任务 1 所使用到的寄存器都保存起来 (以便后续轮到任务 1 继续执行时进行恢复),并且寄存器的值恢复到任务 2 上一次执行时的值,然后才将执行权交给任务 2。
再看内存,不同的任务可以有不同的地址空间,通过不同的地址映射表来体现。如何切换地址映射表,也是修改寄存器。
所以,任务的上下文就是一堆寄存器的值。要切换任务,只需要保存和恢复一堆寄存器的值就可以了。针对进程、线程、协程,都是如此。
这样,我们就回答了上一页中,什么是任务以及任务的上下文是什么,如何进行保存和恢复。
接下来我们来看上一页中的第三个问题,任务在什么时候进行切换?一个任务占用着 CPU 核心正在运行着,有两种方式让它放弃对 CPU 的控制,一个是主动,一个是被动。
主动和被动,在计算机中有它的专有用词,抢占式和协作式。
抢占式 (preemptive) 是被动的,由操作系统来控制任务切换的时机。
在每次中断后,操作系统都能够重新获得 CPU 的控制权。上图展示了当一个硬件中断到达时,操作系统进行任务切换。
我们可以看到,抢占式多任务中操作系统就是“老大”,操作系统能够完全控制每个任务的执行时间,从而保证每个任务能够获得一个相对公平的 CPU 时间片,使得运行不可靠的用户态程序成为可能,例如 while (true) { } 这样的死循环。
抢占式的问题也很明显,因为有操作系统的参与,每次进行任务切换,都会从用户态切换到内核态,还需要保存任务的上下文信息,因此上下文切换开销比较大。同时由于每个任务都有单独的栈空间,在启动过多任务时,内存占用大,会限制系统支持运行的任务数量。
与抢占式多任务强制性地暂停任务的执行不同,协作式 (cooperative) 多任务允许任务一直运行,直至该任务主动放弃 CPU 的执行权。
强调任务之间的协作,这样任务更加灵活,可以在其适当的时间点暂停自身的运行,让出 CPU,避免 CPU 时间的浪费 (例如任务在等待 IO 操作完成时),然后当等待的条件满足时,能够再次被调度执行。这也有问题,依赖程序的实现,如果程序一直不让出 CPU,我们是没有任何办法的,只能等待让出。
实际上,调度方式并无高下,完全取决于应用场景:
维基百科中关于 co-routine 的两个关键概念,我们就已经全部分析清楚了
那现在,我们可以说什么是协程了吗?
其实,就是 Conway 最开始给协程下的定义,协程就是 “可以暂停和恢复执行” 的函数,其“全部精神就在于控制流的主动让出和恢复”。
当然,也有人认为只有 goroutine 那样的才是完备的协程,认为一个完备的协程库就是一个用户态的操作系统,而协程就是用户态操作系统里面的 “进程” (from 许式伟的架构课)。
本次分享中,我们还不会涉及到这么复杂的内容。控制流的主动让出和恢复,就是我们理解协程的关键了。
知道了协程是什么,那我们如何实现一个协程呢?
这部分内容实现一个简单的协程库,来源于南京大学《操作系统:设计与实现》 - M2: 协程库 (libco)。
在分享中,进行了详细的代码实现解释,基于 jyy 老师的课程要求,这里不直接在文章中贴对应的实现代码了,大家可以点击 南京大学《操作系统:设计与实现》 - M2: 协程库 (libco),jyy 老师给到了详细的实现 notes,我从里面学习到了很多,相信你也会和我有一样的感受。实现的过程中,如果有疑问,可以私信我进行交流。
假设你已经完成了实验 (在阅读本文的时候,还没有做实验,也没关心,不影响后续内容的理解),让我们一起来做一个技术总结。
关注点 | 当前实现 | 其他实现 |
协程上下文切换 | setjmp / longjmp,配合汇编 stack_switch_call |
|
(有栈) 协程 | 独立栈,每个协程有单独的调用栈 |
|
独立栈内存管理 | malloc / free,自动回收内存 |
|
协程栈溢出检测 | 不支持 |
|
yield 后的控制流 | 对称协程,调度器选择一个可运行的协程 |
|
协程调度 | 1:N 调度 (单线程调度),双向链表管理,round-robin |
|
按照表格从上往下:
通过比较我们实现的协程,与其他的一些协程实现,可以加深我们对协程的理解。
但这里,我们可能还有其他的一些问题,还可以利用协程做什么呢?
今天限于时间和篇幅,先主要讲解一下 libco 的 IO hook,如果大家多这里其他的点也很感兴趣可以告诉我,我看看可以再补充一下内容,作为协程分享 2.0。
接下来我们看看 libco 的 IO hook。
先来看一个 echo server 的例子,在 accept 、recv、send 都会 阻塞,整个进程都会暂停执行,等待 IO 完成。
// 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); // block
while (1) {
int read = recv(client_fd, buf, BUF_SIZE, 0); // block
send(client_fd, buf, read, 0); // block
}
}
return 0;
}
很明显,如果这段代码直接出现在 svrkit 框架代码中,那即使配合使用 libco 也救不回来 (假设 libco 未实现 hook socket),一个阻塞操作,整个服务就暂停了。
很简单的一个想法,非阻塞 IO + IO 多路复用 (epoll 或者 kqueue 等),也就是我们第一部分讲解到的,但这里我们将协程也添加进来。
以 recv() 接收数据为例,在真实的读取数据前,执行操作:
就这么简单,如 libco 的实现者所说 “设计应该是简单的,非常简单”。
那 libco 是如何实现 Hook 的呢?
libco 使用 dlysm 对系统调用进行了 Hook,包含一些几类函数,完整列表 大家可以点击这里的链接,跳转过去直接查看代码:
如何用 dlysm (man 3 recv) 对系统调用进行 Hook 呢?按照以下四个步骤:
#include <sys/socket.h>
ssize_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 的,那如何实现直接调用系统调用呢?
将 dlsym 的第一个参数指定 RTLD_NEXT - 这样就会跳过找到的第一个 symbol 地址,获取该 symbol 对应的第二个地址,这样就获取得到了真实的系统调用入口地址。
// 使用 `dlsym` 查找动态库中 `recv` symbol 的地址
// `RTLD_NEXT` - 跳过找到的第一个地址,获取该 symbol 对应的第二个地址
static recv_pfn_t g_sys_recv_func = (recv_pfn_t)dlsym(RTLD_NEXT, "recv");
libco 是如何将 dlsym hook 系统调用与协程有机结合起来的呢?
来看代码,libco 的 recv 实现。
ssize_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) here
if (readret < 0) {
return readret;
}
return readret;
}
至此,我们就讲完了协程以及 libco 的原理部分了。
那理解这些,对我们使用协程有什么用呢?这就进入了第四部分。
libco 的协程栈,是调用 malloc 分配的堆上内存,这里称为「栈」,只是一种惯例的称谓。
libco 协程栈的内存分配代码,co_create_env:
struct stCoRoutine_t* co_create_env(/* ... */) {
// ...
// 协程栈默认 128KB,最大 8MB
if (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); // <-- here
stack_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_buffer
}
看下面这段代码片段:
// 示例 1: 显式使用线程锁,`co_yield` 协程切换时未释放该线程锁
std::mutex g_mutex;
void hello() {
const std::lock_guard<std::mutex> lock(g_mutex);
co_yield(); // 请求 rpc,或其他 libco hook 了的非阻塞 IO
// `g_mutex` is released when lock goes out of scope
}
协程 1 在持有线程锁的同时,yield 让出 CPU;同一个线程中的其他协程,获取得到 CPU,同样执行这一段代码,就会尝试去获取锁,但一直得不到锁,就阻塞了,从而整个线程都阻塞了。
这个是显式使用线程锁,比较容易发现。
我们再来看一个隐式使用线程锁的例子,下面这段代码,一个经典的单例模式的实现:
// 示例 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 conf
}
在进行单例构造时,使用 static 修饰,编译器为保证线程安全会为 static 的初始化添加线程锁 (默认开启,可添加编译选项 -fno-threadsafe-statics 关闭)。但业务开发在实现的时候,在对应的构造函数中调用 rpc yield 让出了 CPU;同样的,同一个线程中的其他协程也走到这一段代码,尝试获取单例,就出现了死锁。这样就与第一个例子中显示使用线程锁,所导致的问题,是一致的了。
在 Rust 中提供了专门用于异步运行时的 mutex,再次尝试获取时,如果获取不到,就会直接 yield
https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=fd3b0f156a35dc9c64add06e8118b04e
可以很好的解决这里的问题,协程 1 持有了锁,主动让出 CPU,协程 2 尝试获取锁,肯定获取不到,就会主动 yield,协程 1 继续执行,执行完后,释放锁,协程 2 就可以获取得到锁,继续执行。
到这里第四部就结束了。
接下来,对今天的分享做一个总结。
在今天的协程分享中,我尝试去回答了以下四个问题:
主要的一个思想是,协程的概念其实很简单 -- 协程就是一个 “可以暂停和恢复执行” 的函数,其“全部精神就在于控制流的主动让出和恢复”。
在现在的高级语言中,实现协程思想的方法很多,这些实现间并无高下之分,所区别的仅仅是其适合的应用场景。
理解这一点,我们对于各种协程的分类,如对称/非对称协程、有栈与无栈协程,就能够更加明白其本质,而无需在实现细节上过于纠结。
最后是一些个人思考。
从 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),完全焕发新生
最后是在整理分享内容时,看到的一些比较有用的资料推荐。
至此,我的分享结束,谢谢大家。