协程设计原理与汇编实现:从原语到网络IO Hook
一、为什么需要协程在高并发网络编程中我们面临一个经典矛盾同步编程简单但性能差异步编程性能高但代码复杂。协程的出现正是为了用同步的写法获得异步的性能。1.1 同步与异步的本质同步串行执行一个任务完成后才能进行下一个。比如发送一个HTTP请求线程阻塞直到收到响应期间无法处理其他请求。异步并行执行任务发起后立即返回结果就绪时通过回调通知。性能高但回调嵌套导致代码难以维护。1.2 一个典型的Server场景以微信Server端为例多个客户端A、B、C同时连接。Server主循环中使用epoll_wait监听所有客户端fd当某个fd可读时读取请求然后可能需要操作数据库如查询用户信息、写入消息记录待数据库操作成功后再返回结果给客户端。在这个过程中数据库操作往往耗时较长。如果用同步方式线程会被阻塞无法处理其他客户端的请求。而协程可以在发起数据库操作或任何I/O时主动让出CPU切换到其他就绪协程待数据库就绪后再恢复执行。凡是需要等待结果的地方都可以用协程优化。1.3 协程的核心思想IO未就绪时主动切换yield出CPU让调度器执行其他协程。IO就绪时被调度器恢复resume继续运行。对开发者而言协程代码看起来是同步的发起读操作等待数据处理数据写回。底层却是非阻塞的。二、协程实现原理与三种原语方案协程切换的本质是函数调用栈的保存与恢复。实现方式主要有三种setjmp/longjmp、ucontext、汇编。2.1 setjmp / longjmp#include setjmp.h jmp_buf env; if (setjmp(env) 0) { // 第一次调用保存当前上下文 longjmp(env, 1); // 跳转回 setjmp 处返回值为1 } else { // 跳转回来的逻辑 }缺点只能保存部分寄存器多线程环境下每个线程的栈独立jmp_buf不能跨线程使用逻辑复杂。2.2 ucontextucontext提供了getcontext、makecontext、swapcontext等系统级用户态上下文切换接口。#include ucontext.h ucontext_t main_ctx, co_ctx; char stack[8192]; void func() { printf(in coroutine\n); // 主动让出回到main } getcontext(co_ctx); co_ctx.uc_stack.ss_sp stack; co_ctx.uc_stack.ss_size sizeof(stack); co_ctx.uc_link main_ctx; // 协程结束后回到main makecontext(co_ctx, func, 0); swapcontext(main_ctx, co_ctx); // 切换到协程优点较标准支持自定义栈。缺点切换开销较大且协程之间不能随意互切必须通过调度器统一管理。2.3 汇编实现性能最佳汇编实现直接操作CPU寄存器使用mov指令保存和恢复上下文。以x86-64为例需要保存16个通用寄存器rax, rbx, rcx, rdx, rdi, rsi, rbp, rsp, r8~r15以及指令指针rip。切换时将当前协程的寄存器值保存到内存结构体中从下一个协程的内存结构体中加载寄存器值跳转到下一个协程的指令指针性能对比汇编 setjmp ucontext。汇编实现完全可控无冗余操作因此大多数高性能协程库如NtyCo、libco均采用汇编实现切换。三、协程与调度器的数据结构设计3.1 协程控制块struct coroutine每个协程需要保存自己的状态、栈、上下文、关联的fd等。struct coroutine { int fd; // 协程绑定的文件描述符用于IO等待 ucontext_t ctx; // 协程上下文栈、寄存器等 void *arg; // 入口函数参数 int status; // 状态READY, RUNNING, WAIT, SLEEP, DEAD struct list_node ready_node; // 就绪队列节点链表 struct rb_node wait_node; // 等待IO红黑树节点 struct rb_node sleep_node; // 睡眠超时红黑树节点 // ... 其他成员 };为什么等待队列和睡眠队列使用红黑树协程等待某个fd的IO就绪时需要快速根据fd找到对应的协程。红黑树wait_rb按fd为键插入、删除、查找的时间复杂度为O(log n)。协程需要定时睡眠如超时控制使用红黑树sleep_rb按超时时间排序调度器每次可以快速找到最早超时的协程。相比哈希表红黑树支持范围查询如获取所有超时时间小于当前时间的节点且不需要预先分配大数组。3.2 调度器struct schedule调度器是整个协程系统的核心管理所有协程的生命周期和事件循环。struct schedule { int epfd; // epoll文件描述符 struct epoll_event *events; // epoll事件数组 struct list_head ready_queue; // 就绪协程队列链表 struct rb_root wait_rb; // 等待IO的协程红黑树keyfd struct rb_root sleep_rb; // 睡眠协程红黑树key超时时间戳 // ... 其他成员 };调度器的工作流程初始化epfd创建epoll实例。当协程执行IO操作发现资源未就绪时将该协程的fd注册到epoll并将协程节点插入wait_rb或sleep_rb然后主动yield。调度器主循环调用epoll_wait等待任意fd就绪同时检查sleep_rb中的协程是否超时。将有事件发生或超时的协程从等待树中删除加入ready_queue。从ready_queue中取出一个协程resume执行。四、IO Hook让同步代码异步化为了不修改现有业务代码协程库通常会对阻塞系统调用进行hook劫持。以recv和send为例4.1 定义函数指针类型typedef ssize_t (*recv_t)(int sockfd, void *buf, size_t len, int flags); typedef ssize_t (*send_t)(int sockfd, const void *buf, size_t len, int flags); recv_t recv_f NULL; send_t send_f NULL;4.2 初始化Hook使用dlsym获取原函数#include dlfcn.h void init_hook(void) { if (!recv_f) { recv_f (recv_t)dlsym(RTLD_NEXT, recv); } if (!send_f) { send_f (send_t)dlsym(RTLD_NEXT, send); } }4.3 实现自己的recvssize_t recv(int sockfd, void *buf, size_t len, int flags) { init_hook(); struct pollfd fds[1] {0}; fds[0].fd sockfd; fds[0].events POLLIN; // 非阻塞检测poll立即返回 int ret poll(fds, 1, 0); if (ret 0 (fds[0].revents POLLIN)) { // 数据已就绪直接调用原函数 return recv_f(sockfd, buf, len, flags); } else { // 数据未就绪将当前协程与fd绑定注册到epoll然后切换 struct coroutine *co get_current_coroutine(); co-fd sockfd; // 将fd加入epoll监听EPOLLIN事件 struct epoll_event ev {.events EPOLLIN, .data.ptr co}; epoll_ctl(schedule-epfd, EPOLL_CTL_ADD, sockfd, ev); // 将协程加入等待红黑树 rb_insert(schedule-wait_rb, sockfd, co); // 切换出当前协程回到调度器主循环 swapcontext(co-ctx, schedule-main_ctx); // 当协程再次被resume时说明fd已就绪此时重新调用原函数 return recv_f(sockfd, buf, len, flags); } }类似地可以hooksend、read、write、connect、accept等阻塞函数。通过这种方式已有同步代码无需修改即可运行在协程之上开发者感知不到底层的异步切换。五、协程调度器的执行流程完整的调度器主循环如下void schedule_run(struct schedule *sched) { while (1) { // 1. 将就绪队列中的协程逐个执行 while (!list_empty(sched-ready_queue)) { struct coroutine *co list_pop(sched-ready_queue); co-status COROUTINE_RUNNING; swapcontext(sched-main_ctx, co-ctx); // 切换到协程 // 协程让出后检查其状态 if (co-status COROUTINE_DEAD) { free_coroutine(co); } else if (co-status COROUTINE_WAIT) { // 已经加入等待红黑树不做额外处理 } else { // 其他情况重新加入就绪队列 list_push(sched-ready_queue, co); } } // 2. 没有就绪协程时等待epoll事件 int nfds epoll_wait(sched-epfd, sched-events, MAX_EVENTS, next_timeout()); for (int i 0; i nfds; i) { struct coroutine *co (struct coroutine*)sched-events[i].data.ptr; // 从等待红黑树中删除 rb_erase(sched-wait_rb, co-fd); // 加入就绪队列 list_push(sched-ready_queue, co); co-status COROUTINE_READY; } // 3. 处理超时协程检查sleep红黑树 handle_timeout(sched); } }六、汇编实现协程切换x86-64以NtyCo为例核心切换函数ctx_swap使用内联汇编或单独汇编文件实现。以下为关键伪代码ATT语法# void ctx_swap(ucontext_t *from, ucontext_t *to); ctx_swap: # 保存当前上下文到 from movq %rax, (RAX_OFFSET)(%rdi) movq %rbx, (RBX_OFFSET)(%rdi) movq %rcx, (RCX_OFFSET)(%rdi) movq %rdx, (RDX_OFFSET)(%rdi) movq %rsi, (RSI_OFFSET)(%rdi) movq %rdi, (RDI_OFFSET)(%rdi) movq %rbp, (RBP_OFFSET)(%rdi) movq %rsp, (RSP_OFFSET)(%rdi) movq %r8, (R8_OFFSET)(%rdi) movq %r9, (R9_OFFSET)(%rdi) movq %r10, (R10_OFFSET)(%rdi) movq %r11, (R11_OFFSET)(%rdi) movq %r12, (R12_OFFSET)(%rdi) movq %r13, (R13_OFFSET)(%rdi) movq %r14, (R14_OFFSET)(%rdi) movq %r15, (R15_OFFSET)(%rdi) movq (%rsp), %rax # 返回地址存入rip movq %rax, (RIP_OFFSET)(%rdi) # 恢复 to 的上下文 movq (RIP_OFFSET)(%rsi), %rax movq (RAX_OFFSET)(%rsi), %rax movq (RBX_OFFSET)(%rsi), %rbx movq (RCX_OFFSET)(%rsi), %rcx movq (RDX_OFFSET)(%rsi), %rdx movq (RSI_OFFSET)(%rsi), %rsi movq (RDI_OFFSET)(%rsi), %rdi movq (RBP_OFFSET)(%rsi), %rbp movq (RSP_OFFSET)(%rsi), %rsp movq (R8_OFFSET)(%rsi), %r8 movq (R9_OFFSET)(%rsi), %r9 movq (R10_OFFSET)(%rsi), %r10 movq (R11_OFFSET)(%rsi), %r11 movq (R12_OFFSET)(%rsi), %r12 movq (R13_OFFSET)(%rsi), %r13 movq (R14_OFFSET)(%rsi), %r14 movq (R15_OFFSET)(%rsi), %r15 pushq %rax # 将rip压栈然后ret跳转 retx86-64相比于x86寄存器数量从8个增加到16个且宽度为64位因此需要保存更多的寄存器。汇编实现直接操作硬件没有任何多余函数调用性能最高。七、多核模式探讨单线程调度器只能利用一个CPU核心。为了充分利用多核有以下几种常见模式7.1 多线程 CPU亲缘性创建N个线程NCPU核心数每个线程独立运行一个调度器。每个线程绑定到固定的CPU核心pthread_setaffinity_np避免线程迁移带来的缓存失效。每个客户端连接被分配到一个线程调度器上该连接上的所有协程都在同一个线程中运行无需加锁。优点无锁设计性能高。缺点连接与线程绑定可能导致负载不均。7.2 多进程模式每个进程一个调度器进程间通过SO_REUSEPORT选项监听同一端口内核自动分发连接。优点隔离性更强一个进程崩溃不影响其他进程实现简单无需考虑线程同步。缺点进程间通信成本较高不适合需要共享大量数据的场景。7.3 全局就绪队列 工作线程池一个全局的就绪队列多个工作线程竞争取任务执行。需要精细的锁或无锁队列设计实现复杂度高容易产生竞争。对于大多数协程库如NtyCo、腾讯libco推荐采用多线程每线程一个调度器的方式配合CPU亲缘性既简单又能发挥多核性能。八、总结协程通过用户态切换和IO Hook完美结合了同步编程的简单性和异步编程的高性能。本文从原语实现、数据结构、调度策略、Hook机制到多核扩展系统梳理了协程库的设计要点。核心原语汇编实现 setjmp ucontext高性能协程库普遍采用汇编。关键数据结构协程控制块包含栈、上下文、状态调度器epoll 就绪队列 红黑树等待/睡眠队列。Hook机制利用dlsym劫持阻塞系统调用实现非阻塞切换业务代码无感知。多核扩展多线程每线程一个调度器 CPU亲缘性实现线性性能扩展。掌握这些原理后你可以自己动手实现一个轻量级协程库或者更深入地理解NtyCo、libco等开源框架的源码。协程不仅是一种技术更是一种改变并发编程思维的方式。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2491523.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!