协程详解以及网络IO的协程框架
协程详解以及网络IO的协程框架
简介:本文由异步编程引出协程,进而介绍了三种实现协程的方法。最后基于汇编方式仿造现有网络协程框架NtyCo仿写常用API。
引言
最简单的串行同步编程中,所有的代码顺序执行,当前面的代码还未完成后续的代码只能等待;而并行异步编程,将操作交由其他线程通过回调函数处理后续代码无需持续等待,大大提高了程序的并行处理能力。例如有程序调用DNS解析多个域名。串行编程会依次将域名传给POSIX接口等待返回数据后再处理下一个;而并行编程不关心当前操作是否返回数据,直接将解析域名操作丢给回调函数,这样就能同时处理多个解析操作做到了并发编程。但并发编程依赖于回调函数,在大型的开发环境下往往会陷入回调地狱大大降低了代码的可读性。在这种情况下协程应运而生,它做到了同步编程的简单又具有异步编程的并发性。针对于不同场景本文介绍了不同的协程实现方法,并通过现有的NtyCo框架仿写了基本API。
原理及架构
一、协程的原理
假设有如下场景:作为客户端通过dns服务器请求一系列域名的ip地址,分别使用串行和并行发起请求:
//串行
int count = sizeof(domain) / sizeof(domain[0]);
int i = 0;
for (i = 0;i < count;i ++) {
dns_client_commit(domain[i]);
}
可以看出在串行中仅仅通过循环调用提交函数,且提交函数为单线程,即只有当前请求收到回应并处理完成后才会进入下一个循环。
//并行
struct async_context *ctx = dns_async_client_init();
if (ctx == NULL) return -2;
int count = sizeof(domain) / sizeof(domain[0]);
int i = 0;
for (i = 0;i < count;i ++) {
dns_async_client_commit(ctx, domain[i], dns_async_client_result_callback);
//sleep(2);
}
在并行中,使用poll和pthread线程接管请求事件,将请求dns服务器交给poll接管。实现并发请求,此时主线程代码会持续运行,若有返回即会打印在屏幕上。
从最终运行结果也能看出来,串行会依次将结果打印出来,而并行因为是并发执行,会无序地将返回数据呈现。
注意到这一行代码:
dns_async_client_commit(ctx, domain[i], dns_async_client_result_callback);
向dns服务器发起请求的操作通过回调函数dns_async_client_result_callback()实现,在这个简单的例子里面使用一层的回调函数就可以解决问题。但在实际开发中,往往会出现回调嵌套回调,大大降低了代码的可读性。所以有没有一种同时具备串行的可读性,且有并行的性能呢?
所以协程应运而生,它就是解决这种情况下的工具。其本质就是在用户态进行切换的轻量级线程,通过串行的编码方式实现了异步的效果。在操作系统中,cpu作为资源被线程轮番抢占,程序在cpu上面运行随时都会出现中断;协程通过yield挂起主动将cpu让给其他协程,等待就绪后resume恢复。协就精确地体现了它的工作原理:程序员将需要等待的程序的执行权主动让出给其他欲执行程序,从而做到无需回调也能并发执行的效果。
二、三种协程
- setjmp/longjmp
最原始的协程方案,便于理解协程的切换原理。该方案中setjmp()用于保存并挂起当前环境,longjmp()恢复并运行程序。如下是一个简单的协程跳转示意:
jmp_buf env;
void func(int arg) {
printf("func: %d\n", arg);
longjmp(env, ++arg);
}
int main() {
int ret = setjmp(env);
if (ret == 0) {
func(ret);
} else if (ret == 1) {
func(ret);
} else if (ret == 2) {
func(ret);
} else if (ret == 3) {
func(ret);
}
return 0;
}
当主函数第一次调用setjmp(),程序保存当前环境到env中并返回0,此时ret=0。进入func()中,longjmp(env,1)调转回setjmp()处并使其返回1,ret=1。以此类推。最后结果如下:
func: 0
func: 1
func: 2
func: 3
这种方案的优点就是简单,同样也是缺点。实际生产环境严禁使用这种方案,因为func()函数在栈中的各种值并未被正确清理,多次跳转后会出现错误导致程序崩溃。
- ucontext
ucontext方案集中在上下文控制块上:
typedef struct ucontext {
struct ucontext *uc_link; // 当前上下文结束后跳转到的上下文
sigset_t uc_sigmask; // 上下文阻塞的信号掩码
stack_t uc_stack; // 上下文使用的栈信息
mcontext_t uc_mcontext; // 机器相关的寄存器状态(CPU上下文)
} ucontext_t;
一般情况下需要指定上下文中用于存放信息的栈。
可以简单地理解为每一个协程对应一个ucontext_t类型的上下文控制块。类似地,ucontext也有用于跳转与恢复执行的函数:
getcontext(&ctx) //保存当前上下文到ctx
setcontext(&ctx) //立即跳转到指定上下文(不返回)
makecontext(&ctx, func, argc, ...) //绑定上下文到函数,需先设置uc_stack和uc_link
swapcontext(&old, &new) //保存当前上下文到old,并跳转到new
假设有三个函数需要同步运行,为了实现协程操作需要初始化包括主函数在内的四个上下文控制块:
ucontext_t ctx[3];
ucontext_t main_ctx;
在主函数中初始化控制块的所有信息:
char stack1[2048] = {0};
getcontext(&ctx[0]);
ctx[0].uc_stack.ss_sp = stack1;
ctx[0].uc_stack.ss_size = sizeof(stack1);
ctx[0].uc_link = &main_ctx;
makecontext(&ctx[0], func1, 0);
按照一定规则切换协程:
while (count <= 30) { // scheduler
swapcontext(&main_ctx, &ctx[count%3]);
}
在每个协程中,执行完程序切换回主函数:
void func1(void) {
while (count ++ < 30) {
printf("1\n");
//swapcontext(&ctx[0], &ctx[1]);
swapcontext(&ctx[0], &main_ctx);
printf("4\n");
}
}
在这里我们可以留意到,协程的主动切换都是在主函数中执行,换句话说要想协程发挥作用就必须要有一个主动切换协程的模块。据此就引出了scheduler的概念:作为协程系统的大脑,负责协调多个协程执行顺序、分配资源等功能。
介绍完了简单的协程切换,那么协程在IO场景下是如何发挥作用的呢?例如在简单的文件读写场景下,系统提供给我们的API:read/write并不具有协程功能,此时需要通过Hook机制将系统API拦截,在原始API函数之上添加自定义功能。
定义两个API的函数指针,并将其初始化为空,便于后续操作:
typedef ssize_t (*read_t)(int fd, void *buf, size_t count);
read_t read_f = NULL;
typedef ssize_t (*write_t)(int fd, const void *buf, size_t count);
write_t write_f = NULL;
被替换掉的原始API的操作:
ssize_t read(int fd,void *buf,size_t count){
ssize_t ret = read_f(fd,buf,count);
printf("read: %s\n",(char *)buf);
return ret;
}
ssize_t write(int fd,const void *buf,size_t count){
printf("write: %s\n",(char *)buf);
return write_f(fd,buf,count);
}
此时被替换的read/write还未具有读写的功能,需要找到原始API函数地址:
void init_hoot(void){
if(!read_f){
read_f = dlsym(RTLD_NEXT,"read");
}
if(!write_f){
write_f = dlsym(RTLD_NEXT,"write");
}
}
dlsym(RTLD_NEXT,"read")找到API中系统调用的函数起点赋予read_f,从而实现了自定义替换read/write函数。
在主函数中,按照正常的API调用即可:
init_hoot();
int fd = open("a.txt",O_CREAT | O_RDWR);
if(fd < 0){
return -1;
}
char *str = "1234567890";
write(fd,str,strlen(str));
char buffer[1128] = {0};
read(fd,buffer,128);
综上,ucontext方案基于ucontext_t的文本上下文控制,通过几个API进行协程切换,对于一些系统API通过Hook机制进行自定义。所有的协程资源的控制、互相的切换都基于scheduler进行操作。
- 自定义汇编协程
- 协程的核心原语操作:create、yield、resume。
create用于创建一个协程,在函数中创建调度器实例并将实例作为线程的特定数据,存放在线程私有空间pthread_setspecific中;紧接着为coroutine分配内存空间,初始化用于存放上下文数据的栈等;最后将协程添加到就绪队列中。部分代码如下:
assert(pthread_once(&sched_key_once, nty_coroutine_sched_key_creator) == 0);
nty_schedule *sched = nty_coroutine_get_sched();
nty_coroutine *co = calloc(1, sizeof(nty_coroutine));
co->stack = NULL;
co->stack_size = 0;
co->sched = sched;
co->status = BIT(NTY_COROUTINE_STATUS_NEW); //
co->id = sched->spawned_coroutines ++;
co->func = func;
co->arg = arg;
co->birth = nty_coroutine_usec_now();
*new_co = co;
TAILQ_INSERT_TAIL(&co->sched->ready, co, ready_next);
yield被程序主动调用让出执行CPU。在本文中提供了两种切换功能:swapcontext、_switch等操作,但无论是哪种操作其核心功能都是将当前的执行状态进行保存。若使用swapcontext,则需要显式保存栈;而自定义的_switch操作在汇编阶段即将数据保存。
void nty_coroutine_yield(nty_coroutine *co) {
co->ops = 0;
#ifdef _USE_UCONTEXT
if ((co->status & BIT(NTY_COROUTINE_STATUS_EXITED)) == 0) {
_save_stack(co);
}
swapcontext(&co->ctx, &co->sched->ctx);
#else
_switch(&co->sched->ctx, &co->ctx);
#endif
}
resume恢复被挂起的协程,从保存栈中提取数据恢复现场。
int nty_coroutine_resume(nty_coroutine *co) {
...
#ifdef _USE_UCONTEXT
_load_stack(co);//恢复之前保存的栈内容
#endif
//设置调度器上下文
nty_schedule *sched = nty_coroutine_get_sched();
sched->curr_thread = co;
#ifdef _USE_UCONTEXT
swapcontext(&sched->ctx, &co->ctx);
#else
_switch(&co->ctx, &co->sched->ctx);
nty_coroutine_madvise(co);
#endif
sched->curr_thread = NULL;
...
}
在上述操作中,最核心的内容就是协程的上下文切换。一个协程的上下文往往包括:寄存器状态、占空间、其他协定数据。切换需要完成的功能:保存当前协程的CPU上下文、恢复目标协程的CPU上下文、跳转到目标协程的执行点继续执行。
x86_64位架构的CPU提供了16个64位的通用寄存器:
寄存器 | 别名 | 主要用途 | 调用约定(System V ABI) |
---|---|---|---|
RAX | EAX | 累加器/返回值 | 返回值寄存器 |
RBX | EBX | 基址寄存器 | 被调用者保存 |
RCX | ECX | 计数器/第4参数 | 第4个参数 |
RDX | EDX | 数据/第3参数 | 第3个参数 |
RSI | ESI | 源索引/第2参数 | 第2个参数 |
RDI | EDI | 目标索引/第1参数 | 第1个参数 |
RBP | EBP | 基址指针 | 被调用者保存 |
RSP | ESP | 栈指针 | 被调用者保存 |
R8 | - | 第5参数 | 第5个参数 |
R9 | - | 第6参数 | 第6个参数 |
R10 | - | 临时寄存器 | 调用者保存 |
R11 | - | 临时寄存器 | 调用者保存 |
R12 | - | 通用寄存器 | 被调用者保存 |
R13 | - | 通用寄存器 | 被调用者保存 |
R14 | - | 通用寄存器 | 被调用者保存 |
R15 | - | 通用寄存器 | 被调用者保存 |
其中%RSP指针指向栈顶;%RDI、%RSI、%RDX、%RCX、%r8、%r9分别对应函数的第1、2、3...个参数。%RBX、%RBP、%r12、%r13、%r14、%r15用于数据存储。所谓的协程切换就是将当前的寄存器值存储,将待执行的协程寄存器值赋予对应的寄存器。
因此协程上下文结构体依据x86_64位架构定义:
typedef struct _nty_cpu_ctx {
void *esp; //0栈指针
void *ebp; //8帧指针
void *eip; //16指令指针
void *edi; //24
void *esi; //32
void *ebx; //40
void *r1; //48 r12
void *r2; //56 r13
void *r3; //64 r14
void *r4; //72 r15
void *r5; //80 保留
} nty_cpu_ctx;
switch操作包含了两步主要操作:保存当前上下文(%rsi)、恢复目标上下文(%rdi)。
movq %rsp, 0(%rsi) # 保存栈指针到ctx->esp
movq %rbp, 8(%rsi) # 保存帧指针到ctx->ebp
movq (%rsp), %rax # 从栈顶读取返回地址
movq %rax, 16(%rsi) # 保存返回地址到ctx->eip
movq %rbx, 24(%rsi) # 保存RBX到ctx->ebx
movq %r12, 32(%rsi) # 保存R12到ctx->r1
movq %r13, 40(%rsi) # 保存R13到ctx->r2
movq %r14, 48(%rsi) # 保存R14到ctx->r3
movq %r15, 56(%rsi) # 保存R15到ctx->r4
恢复目标上下文:
movq 56(%rdi), %r15 # 恢复ctx->r4到R15
movq 48(%rdi), %r14 # 恢复ctx->r3到R14
movq 40(%rdi), %r13 # 恢复ctx->r2到R13
movq 32(%rdi), %r12 # 恢复ctx->r1到R12
movq 24(%rdi), %rbx # 恢复ctx->ebx到RBX
movq 8(%rdi), %rbp # 恢复ctx->ebp到RBP
movq 0(%rdi), %rsp # 恢复ctx->esp到RSP
movq 16(%rdi), %rax # 获取ctx->eip
movq %rax, (%rsp) # 将eip压入新栈顶
ret # 跳转到eip
2. **协程的定义**
作为协程的载体,协程的运行体必须包含运行状态、运行体回调函数、回调函数的参数、栈指针等参数。
typedef struct _nty_coroutine {
nty_cpu_ctx ctx;
proc_coroutine func;
void *arg;
size_t stack_size;
nty_coroutine_status status;
nty_schedule *sched;
uint64_t birth;
uint64_t id;
void *stack;
RB_ENTRY(_nty_coroutine) sleep_node;
RB_ENTRY(_nty_coroutine) wait_node;
TAILQ_ENTRY(_nty_coroutine) ready_next;
TAILQ_ENTRY(_nty_coroutine) defer_next;
} nty_coroutine;
新被创建的协程被放入就绪集合中,当协程被调用但因为其中的操作并未执行结束(例如某一协程用于IO操作,但该操作一时间无法得到返回值)此时协程处于等待状态,当协程操作执行完毕进入睡眠状态。基于上述特性分别使用队列存储就绪协程、使用红黑树存储等待和睡眠集合。
调度器是用于管理所有协程运行的组件,它必须包含协程上下文用于不同协程之间进行切换。除此之外还需要包含协程三种状态的数据结构,以及用于事件管理的epoll相关组件。
typedef struct _nty_schedule {
uint64_t birth;
nty_cpu_ctx ctx;
void *stack;
size_t stack_size;
int spawned_coroutines;
uint64_t default_timeout;
struct _nty_coroutine *curr_thread;
int page_size;
int poller_fd;
int eventfd;
struct epoll_event eventlist[NTY_CO_MAX_EVENTS];
int nevents;
int num_new_events;
pthread_mutex_t defer_mutex;
nty_coroutine_queue ready;
nty_coroutine_queue defer;
nty_coroutine_link busy;
nty_coroutine_rbtree_sleep sleeping;
nty_coroutine_rbtree_wait waiting;
} nty_schedule;
在上述结构体基础上协程有两种被调度的方式:
- 生产者消费者模式
将已经到期的睡眠协程作为生产者,放入就绪队列中。
nty_coroutine *expired = NULL;//睡眠集合
while ((expired = sleep_tree_expired(sched)) != NULL) {
TAILQ_ADD(&sched->ready, expired);
}
nty_coroutine *wait = NULL;//等待集合
int nready = epoll_wait(sched->epfd, events, EVENT_MAX, 1);
for (i = 0;i < nready;i ++) {
wait = wait_tree_search(events[i].data.fd);
TAILQ_ADD(&sched->ready, wait);
}
调度器作为消费者从就绪队列中取出协程执行。
while (!TAILQ_EMPTY(&sched->ready)) {
nty_coroutine *ready = TAILQ_POP(sched->ready);
resume(ready);
}
- 多状态运行
这种方案综合调度三种不同状态:睡眠状态、等待状态、就绪状态。调度器在这其中协调控制在合适的时机执行这些函数。
睡眠状态:
nty_coroutine *expired = NULL;
while ((expired = sleep_tree_expired(sched)) != NULL) {
resume(expired);
}
sleep_tree_expired从睡眠集合中查找定时器已到期的协程,进而resume恢复到期的协程执行权。
等待状态:
int nready = epoll_wait(sched->epfd, events, EVENT_MAX, 1);
for (i = 0; i < nready; i++) {
wait = wait_tree_search(events[i].data.fd);
resume(wait);
}
epoll_wait等待IO事件并将超时时间设为1ms,根据就绪的fd在等待集合中找到对应协程交付给resume恢复执行权。
就绪状态:
while (!TAILQ_EMPTY(sched->ready)) {
nty_coroutine *ready = TAILQ_POP(sched->ready);
resume(ready);
}
在该状态只需要简单地从就绪队列中取出协程执行即可。
三、基于协程的网络API
将非阻塞网络IO和事件驱动与协程结合,在少量线程的基础上处理大量的请求。与协程组合的关键在于Hook操作,将系统API(socket、accept、recv等)替换为协程版本。
- 协程的创建与初始化
nty_socket创建套接字并设置为非阻塞模式,在Hook机制下,socket系统调用被替换为协程版本。在socket中关联调度器。
int nty_socket(int domain, int type, int protocol) {
int fd = socket(domain, type, protocol);
int ret = fcntl(fd, F_SETFL, O_NONBLOCK);
int reuse = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, (char *)&reuse, sizeof(reuse));
return fd;
}
socket_t socket_f = NULL;
int socket(int domain, int type, int protocol) {
if (!socket_f) init_hook();
nty_schedule *sched = nty_coroutine_get_sched();
if (sched == NULL) {
return socket_f(domain, type, protocol);
}
int fd = socket_f(domain, type, protocol);
int ret = fcntl(fd, F_SETFL, O_NONBLOCK);
int reuse = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, (char *)&reuse, sizeof(reuse));
return fd;
}
- 网络IO操作
以recv为例:当recv阻塞时,使用nty_poll_inner将当前协程加入epoll监听,使用nty_coroutine_yeild让出控制权。当IO事件就绪时唤醒对应协程重新执行recv。
ssize_t nty_recv(int fd, void *buf, size_t len, int flags) {
struct pollfd fds;
fds.fd = fd;
fds.events = POLLIN | POLLERR | POLLHUP;
nty_poll_inner(&fds, 1, 1);
int ret = recv(fd, buf, len, flags);
return ret;
}
recv_t recv_f = NULL;
ssize_t recv(int fd, void *buf, size_t len, int flags) {
if (!recv_f) init_hook();
nty_schedule *sched = nty_coroutine_get_sched();
if (sched == NULL) {
return recv_f(fd, buf, len, flags);
}
struct pollfd fds;
fds.fd = fd;
fds.events = POLLIN | POLLERR | POLLHUP;
nty_poll_inner(&fds, 1, 1);
int ret = recv_f(fd, buf, len, flags);
return ret;
}
- 连接处理
仍然循环监听POLLIN事件,如有新连接加入,accpet以非阻塞方式获取连接。
int nty_accept(int fd, struct sockaddr *addr, socklen_t *len) {
int sockfd = -1;
int timeout = 1;
nty_coroutine *co = nty_coroutine_get_sched()->curr_thread;
while (1) {
struct pollfd fds;
fds.fd = fd;
fds.events = POLLIN | POLLERR | POLLHUP;
nty_poll_inner(&fds, 1, timeout);
sockfd = accept(fd, addr, len);
if (sockfd < 0) return -1;
else break;
}
int ret = fcntl(sockfd, F_SETFL, O_NONBLOCK);
int reuse = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, (char *)&reuse, sizeof(reuse));
return sockfd;
}
accept_t accept_f = NULL;
int accept(int fd, struct sockaddr *addr, socklen_t *len) {
if (!accept_f) init_hook();
nty_schedule *sched = nty_coroutine_get_sched();
if (sched == NULL) {
return accept_f(fd, addr, len);
}
int sockfd = -1;
int timeout = 1;
nty_coroutine *co = nty_coroutine_get_sched()->curr_thread;
while (1) {
struct pollfd fds;
fds.fd = fd;
fds.events = POLLIN | POLLERR | POLLHUP;
nty_poll_inner(&fds, 1, timeout);
sockfd = accept_f(fd, addr, len);
if (sockfd < 0) return -1;
else break;
}
int ret = fcntl(sockfd, F_SETFL, O_NONBLOCK);
int reuse = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, (char *)&reuse, sizeof(reuse));
return sockfd;
}
其余类似功能实现不再赘述。
问题与解决方案
能否解释什么是异步,举出常见应用中用到异步操作的十种场景?
如果顾客点餐取号后一直在前台等待到出餐后才能离开,即顾客此时被阻塞在前台。在客流量大的场景下,未取号的顾客只有一直等待未取餐的顾客。在这个场景下就是同步,所有前继的操作结束后才能执行后续操作。
如果顾客点餐取号后可以立即离开柜台去做自己的事情(执行其他任务),自己的点餐完成后前台通知(回调通知),此时顾客去取自己的餐(处理结果)。在这个过程中顾客没有把时间花在没有必要的等待上,可以同时做其他的事情。这种机制就是异步。
常见应用场景的异步操作:
- 网页请求数据:浏览器作为客户端向服务器请求数据,如果该网络交互速度较慢发送和回应耗时无法预测,采用同步等待浏览器就会在此处卡死,用户无法进行任何操作,大大降低用户体验;一般采用回调函数等操作。
- 用户界面交互:主线程一直监听用户操作(点击按钮,输入文本等),如果某一事件处理及其耗时也会影响用户体验;采用事件监听器等机制。
- 服务器处理请求:web服务器接收客户端HTTP请求。如果服务器采用一条主线程,如果客户端HTTP请求需要访问其余慢速IO(例如读写磁盘等),会造成服务器线程阻塞,后续所有请求都无法处理;多采用io_uring、协程等异步机制。
- 消息队列系统:生产者将消息发送到队列,消费者如果同步等待消费者,会造成资源浪费线程阻塞;采用异步机制将生产者消费者解耦,双方可以按照自己的节奏处理消息。
- 文件读写:应用程序读写本地磁盘文件,IO操作相对CPU来说十分缓慢,采用同步读写会大大拖累CPU效率;采用异步读写机制当IO就绪后中断通知CPU处理,将CPU从缓慢的IO操作中解放出来。
- 移动网络应用请求:在无线移动网路场景下,网络传输速率波动较大。App若采用同步请求,会导致用户界面阻塞严重影响用户体验。
- 推送通知:服务器向客户端推送实时推送通知,在大量客户端的场景下不能阻塞等待每个客户端响应;一般采用全双工通信、消息队列等解决方案。
- 定时任务:指定某个函数在指定时间后执行,此时主线程应该转而去执行后续代码,若同步等待会大大降低代码运行效率。
- 数据库操作:当数据库查询涉及到本地硬件的访问时,同步会导致整个应用程序阻塞;一般采用数据库驱动提供的异步操作接口。
- 游戏场景:在游戏中每帧场景都需要渲染画面,在追求高帧率的目的下,加载资源等操作处理上事件较长必须异步执行。
总结
基于对协程原理及网络IO协程框架的深入分析,本文系统性地阐释了协程作为用户态轻量级线程的核心价值:通过主动让出(yield)和恢复(resume)的执行权切换机制,在保持同步编程直观性的同时实现异步并发的性能。文章依次剖析了三种协程实现方案(setjmp/longjmp基础原语、ucontext上下文管理、自定义汇编级高性能切换),重点构建了基于调度器、协程控制块及事件驱动的协程框架,并通过Hook机制重写网络IO系统调用(如socket/recv/accept),使阻塞操作转化为非阻塞的协程切换,为高并发服务开发提供了兼具简洁性与性能的解决方案。