「什么是协程?」几乎是现在面试的必考题。
一方面,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 年的论文 "Design of a separable transition-diagram compiler" 中,明确了协程的概念 —— “可以暂停和恢复执行”的函数。
我们知道 1960 年代出现进程的概念,那根据这里提到的年份,可以推断,协程的概念应该是早于进程的,那也就更早于线程。
但是协程不符合当时 (一直持续到 1990 年代) 以 C 语言为代表的命令式编程语言所崇尚的“自顶向下”的程序设计思想,因此对协程的使用和讨论一直都很低迷。
直到近些年,随着互联网的发展,尤其是移动互联网时代的到来,服务端对高并发的要求越来越高,也就是我们需要高性能的网络服务器,以 C10K 为代表,需要单机同时支持 1 万个并发连接,到 2013 年时候的单机要支持 1 千万并发连接 (C10M 问题,微信开源 libco:简单易用高性能的协程库 中 libco 通过共享栈支持单机千万连接),协程开始重新进入视野。
如果要满足单机 1 千万的并发连接,我们先来看一下以下两种方案:
- 第一种是,多线程同步模型:
对应 C10K 中的 “一个服务器线程服务一个客户端,使用同步阻塞 IO”,即预先创建出很多个处理线程,每个线程采用同步阻塞 IO 的方式串行处理请求,由操作系统通过线程切换来实现并发处理。这种方式开发者编码简单,但由于线程堆栈占用空间大,内存消耗太快,同时线程切换代价高,导致系统整体性能较差,现实开发中很少有人会使用这种模型。
- 第二种是,基于事件驱动的异步网络模型,以 Nginx 为代表,Nginx 将事件驱动+异步回调做到了极致:
对应 C10K 中的 “一个线程服务多个客户端,使用非阻塞 IO 和就绪时通知机制”,这种方式由应用框架来实现事件驱动和状态切换,可以充分利用 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 Wikipieda
协程是一类程序组件,它是对子过程概念的泛化,并且是属于非抢占的多任务处理。
这里有两个关键概念,分别对应 co-routine 的两个部分:
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."
接下来我们展开对这两个概念的讲解:函数和多任务处理,并且讨论这两个概念与协程的关系。
函数与函数调用
函数,是我们日常开发中最常用到的一种封装手段,它将一组实现特定功能的代码段封装起来,接受一些输入参数,返回一些输出参数。
例如,下面这段简短的 代码片段 (跳转过去看看汇编,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); // 序言 (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 #include #include // 调用栈太小的话,在执行函数调用时会报错 (printf) // fish: Job 1, './call-hello' terminated by signal SIGSEGV (Address boundary // error) #define STACK_SIZE (64 * 1024) // libc.so;
这样应用程序就会调用步骤 1 中实现的 recv()。 但我们最终还是要调用系统调用 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 实现。
首先,获取系统调用的 recv() 的符号地址;
然后,判断当前协程是否开启了 Hook,如果没有开启,则使用系统调用;libco 中,所有协程初始化时,Hook 都是没有开启的;
根据 fd 获取对应的 rpc 信息,包含协程的结构体信息,也就是前面提到的 fd 与协程的映射关系;
如果没有开启非阻塞,直接调用系统调用;
设置超时时间,通过 poll 等待读完成(poll 也被 hook 了),这里会切换到父协程,父协程会在读事件/超时事件触发时回到该协程;
有数据了,或者超时,协程恢复执行,读取数据,调用的是系统调用,会判断是否读取成功;
这就是一个同步的写法了,看起来就很清爽。
ssize_t recv(int socket, void* buffer, size_t length, int flags) {
// 获取系统调用的recv()
的符号地址
HOOK_SYS_FUNC(recv); // 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 了 // 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); // 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 }
Case 2: 在协程中使用线程锁,yield前未释放
看下面这段代码片段:
// 示例 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 scope }
协程 1 在持有线程锁的同时,yield 让出 CPU;同一个线程中的其他协程,获取得到 CPU,同样执行这一段代码,就会尝试去获取锁,但一直得不到锁,就阻塞了,从而整个线程都阻塞了。
Locks the mutex. 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; //