协程原理与实现

目录

[第一章 并发发展史](#第一章 并发发展史)

引言:从"等待"到"高效"的演化之路

[1.1 单任务时代](#1.1 单任务时代)

[1.2 多进程时代](#1.2 多进程时代)

[1.3 多线程时代](#1.3 多线程时代)

[1.4 协程时代](#1.4 协程时代)

[第二章 协程的原理](#第二章 协程的原理)

[第三章 协程切换的三种实现方式](#第三章 协程切换的三种实现方式)

[3.1 longjmp/setjmp](#3.1 longjmp/setjmp)

[3.2 ucontext](#3.2 ucontext)

[3.3 汇编实现上下文切换](#3.3 汇编实现上下文切换)

[3.4 协程切换三种方式的优缺点](#3.4 协程切换三种方式的优缺点)

[第四章 协程调度器的原理](#第四章 协程调度器的原理)

[4.1 为什么需要协程调度器](#4.1 为什么需要协程调度器)

[4.2 协程调度器的原理](#4.2 协程调度器的原理)

[第五章 Hook机制](#第五章 Hook机制)

[5.1 什么是Hook机制](#5.1 什么是Hook机制)

[5.2 为什么要有Hook机制](#5.2 为什么要有Hook机制)

[5.3 Hook机制的原理](#5.3 Hook机制的原理)

[5.4 Hook机制使用样例](#5.4 Hook机制使用样例)

[第六章 手写简易版协程库](#第六章 手写简易版协程库)


第一章 并发发展史

引言:从"等待"到"高效"的演化之路

在现代软件开发中,高并发是一个无法回避的话题。无论是支撑亿万用户的互联网应用,还是实时响应的游戏服务器,如何让计算机高效地处理大量并发任务,始终是开发者面临的核心挑战之一。

然而,高并发能力并非一蹴而就。从早期的单任务系统到现代的高性能服务器,人们为了解决"CPU在等待I/O时空转"这一根本问题,先后经历了多进程多线程 等并发模型的演进。每种模型都在试图回答同一个问题:如何在有限的硬件资源下,驾驭更大规模的并发任务?

但进程和线程并非终点。它们要么太重 (内存占用高、切换慢),要么太复杂 (锁竞争、数据竞争)。这促使人们去追求一种更轻量、更可控 的用户态并发方案。而协程,正是这场演进中诞生的答案。

在我们深入探讨协程的原理与实现之前,有必要先梳理一下并发模型的发展脉络。理解过去,才能更好地理解现在。

1.1 单任务时代

在早期的单任务操作系统(如 MS-DOS)中,系统一次只能运行一个程序。这个程序会独占所有资源,并从开始运行到结束,在此期间,下一个任务必须排队等待。

这种"一次一事"的模式,其最大的弊端就是无法实现并发。设想一下,如果今天的网络服务器沿用这种单任务模型,它一次只能处理一个客户端连接:当服务器正在为客户端A忙碌时,客户端B的连接请求将得不到丝毫响应,只能陷入无尽的等待。

1.2 多进程时代

为了突破单任务时代的串行瓶颈,早期开发者设计了多进程模型。

其核心思想是:操作系统为每个程序维护一个独立的进程 ,并按照一定的调度规则,为每个进程分配CPU时间片。由于当时的计算机大多只有单颗CPU,而进程数量往往远多于CPU核心数,因此任何时刻,CPU上只能运行一个进程

多进程实现的"并行",实际上是宏观层面的并发,微观层面依然是串行------操作系统通过极快的进程切换,让多个进程轮流使用CPU,从而营造出"同时运行"的假象。对于单CPU系统而言,真正的并行并不存在。

多进程的优点:

多进程成功解决了单任务时代的并发难题。仍以网络服务器为例:在多进程模型下,当客户端A向服务器发起请求时,主进程可以为该客户端创建一个独立的子进程,由子进程负责处理具体的业务逻辑,而主进程则继续接收新的网络连接。即每个请求对应一个进程

这种"一事一进程"的模式,让服务器具备了同时服务多个客户端的能力。

多进程的缺点:

尽管多进程带来了并发的可能,但其代价同样不容忽视:

1. 进程资源占用巨大

在 Linux/Unix 系统中,创建一个进程需要为其分配一个 task_struct 结构体,该结构体内部包含了大量内核数据结构(内存描述符、文件描述符表、信号处理表等)。这意味着,每多一个进程,系统就要多承担一份可观的内核内存开销。

2. 进程间数据共享困难且低效

每个进程拥有自己独立的虚拟地址空间,彼此天然隔离。如果两个进程需要共享数据,无法直接内存访问,必须借助第三方机制------如共享内存、管道、消息队列等。这些进程间通信(IPC)方式,无论是编程复杂度还是访问速度,都远不及直接内存共享。

1.3 多线程时代

多进程虽然解决了服务器并发从无到有的问题,但它的解决方式并不高效。围绕多进程带来的内存消耗与资源共享两大痛点,后续开发者提出了多线程的概念。

进程的创建之所以昂贵,根源在于内核数据结构的分配------每个进程都需要一套完整的 task_struct、内存描述符、文件描述符表等。然而在许多场景下,开发者并不需要多套独立的内核数据结构,只渴望获得进程那样的并发能力。

多线程的设计理念正是为此而生:创建线程时,不再为每个线程重新分配一套完整的内核数据结构,而是只创建一个新的 task_struct,并将其中的内核数据结构指针指向与父进程相同的物理地址 。这样既省去了内核数据结构的重复创建开销,又因为操作系统调度器实际调度的是 task_struct,所以线程天然继承了进程的并发能力。

多线程的优点:

多线程在继承进程并发能力的基础上,进一步降低了创建和管理执行流的开销,使得单机能够支撑的并发数量相比多进程有了显著提升。

此外,由于线程之间天然共享内核数据结构------换言之,同一进程内的多个线程共享相同的虚拟地址空间------线程间的数据通信是天生具备的,无需借助额外的进程间通信(IPC)机制。

多线程的缺点:

尽管多线程相比多进程在开销上已有巨大改善,但与后续出现的更轻量级方案(如协程)相比,其创建和维护成本仍然偏高。线程开销的主要来源是用户态与内核态之间的频繁切换

更为棘手的是,线程天生共享虚拟地址空间,这意味着多个线程可能同时访问同一份共享数据。这虽然带来了通信的便利,却也引出了并发编程中最棘手的难题:数据竞争、锁机制、死锁等同步问题。

1.4 协程时代

针对多线程存在的性能瓶颈与并发复杂性问题,后续开发者提出了协程 的概念。协程完全由用户程序在用户空间进行管理,其创建、调度与销毁均不涉及用户态与内核态之间的切换。同时,协程的调度方式不再是抢占式的,而是协作式------即协程主动让出 CPU,而非被内核强制剥夺。

协程的优点:

1. 无内核参与,资源占用极低

协程的维护、管理、调度全部在用户态完成,由程序员(或运行时)显式控制。相比线程,协程的资源占用显著更小:一个线程的栈空间通常为几兆字节(MB),而一个协程仅需几KB。这使得在相同硬件条件下,系统能够支撑的并发数量相比多线程模型进一步提升。

2. 协作式调度,规避数据竞争

协程采用协作而非抢占的调度方式。这意味着协程 A 会主动让出 CPU 给协程 B,而协程 B 不会强行抢占协程 A 的 CPU 时间。在同一个操作系统线程内部,同一时刻只有一个协程处于运行状态。这样一来,多线程编程中最令人头疼的数据竞争、锁等问题在协程之间自然消失------因为协程间的执行是串行的。

3. "同步编程,异步性能"

协程既拥有接近多线程的并发能力,又避免了复杂的同步机制,开发者可以用顺序、同步的思维方式编写代码,底层却实现非阻塞的异步 I/O 与高效调度。这正是协程被誉为 "同步编程,异步效率" 的原因。

协程的缺点:

协程跑在线程里,由用户自己调度。它在 I/O 密集型任务上很擅长,但拿到 CPU 密集型任务上就不太行了。首先,协程本身用不了多核------一个调度器只能占一个线程。其次,协程是协作式的,如果 A 一直埋头算数学题,不主动让出,那同线程里的 B、C 等协程就只能干等着,看起来像死了一样。

第二章 协程的原理

协程的核心原语操作主要有两个:yieldresume

  • yield:协程主动让出 CPU 资源,将自身状态保存为"等待",并把执行权交还给调度器。

  • resume:调度器恢复指定协程的上下文,使其从上次暂停处继续执行。

在 I/O 密集型任务中,程序大部分时间都在等待 I/O 操作完成。当协程执行过程中遇到需要等待 I/O 的事件(例如数据尚未到达)时,它会主动调用 yield 让出 CPU,并将自己的运行上下文保存到用户态的寄存器中;调度器则立刻 resume 另一个已就绪的协程,将其上下文加载到 CPU 中执行。待原先协程所等待的 I/O 就绪后,调度器再次通过 resume 重新激活它。这,便是协程实现高并发的基本原理。

那么,关键问题来了:如何获知一个协程所等待的 I/O 是否已经就绪?

在 Linux 下,答案就是 I/O 多路复用技术 ------ epollepoll 提供了 I/O 事件就绪的通知机制:当协程 yield 后,我们将该协程所等待的事件注册到 epoll 实例中;一旦 epoll_wait 返回该协程对应的就绪事件,调度器便会通过 resume 唤醒它继续执行。

第三章 协程切换的三种实现方式

在实现协程时,我们需要完成协程上下文的保存和切换。而在Linux中有三种方式完成这个步骤。分别为:longjmp/setjmp、ucontext、汇编

3.1 longjmp/setjmp

setjmplongjmp 是一对用于实现非局部跳转的函数,可以绕过正常的函数调用/返回栈,直接从一个深层函数跳转回之前设置的保存点。

  • setjmp :设置一个"跳转保存点"。首次调用时返回 0,并将当前 CPU 上下文(栈指针、程序计数器等)保存到 jmp_buf 中。

  • longjmp :跳转回之前由 setjmp 保存的位置。跳转后,setjmp 会再次返回,返回值由 longjmp 的第二个参数 val 决定。

函数原型如下:

cpp 复制代码
       int setjmp(jmp_buf env);
       void longjmp(jmp_buf env, int val);
  • jmp_buf 是一个用于存储上下文(寄存器、栈信息等)的不透明数据类型。

  • longjmp 的第一个参数 env 必须指向一个已经调用过 setjmp 且尚未返回的 jmp_buf 变量,用于指定跳回的目标。

  • longjmp 的第二个参数 val 会作为 setjmp 的返回值(若 val == 0,则 setjmp 实际返回 1,以区分初次调用和跳转返回)。

通过这种方式,setjmp / longjmp 可以实现类似"非本地异常处理"的控制流,但注意它们不负责保存/恢复信号掩码 (如需,请使用 sigsetjmp / siglongjmp),且跳转后局部变量是否恢复取决于其存储类型。

如下是一个案例代码:

cpp 复制代码
#include <stdio.h>
#include <setjmp.h>

jmp_buf env;

void func(int level) {
    printf("func: level = %d\n", level);
    if (level == 3) {
        printf("func: calling longjmp to jump back to main\n");
        longjmp(env, 42);  // 跳回 setjmp 处,并让 setjmp 返回 42
    }
    func(level + 1);
    printf("This line will never be executed\n");
}

int main() {
    int ret = setjmp(env);
    if (ret == 0) {
        printf("First call of setjmp, ret = 0\n");
        printf("Calling func(1)...\n");
        func(1);
        printf("Normal return from func\n");
    } else {
        printf("Returned from longjmp, ret = %d\n", ret);
        printf("We skipped several stack frames!\n");
    }
    return 0;
}

执行结果如下:

First call of setjmp, ret = 0

Calling func(1)...

func: level = 1

func: level = 2

func: level = 3

func: calling longjmp to jump back to main

Returned from longjmp, ret = 42

We skipped several stack frames!

调用流程图如下:

3.2 ucontext

ucontext 库是 POSIX 系统(Linux/Unix)中提供的一个用户态上下文切换 API。它允许程序在单个进程中保存和恢复执行状态(如堆栈、寄存器、指令指针),从而实现轻量级的协作式多任务。

简单理解:它相当于在用户空间模拟了操作系统的"线程调度",但切换权完全由程序员主动控制(非抢占式)。

ucontext库提供的API接口主要有四个,如下表

|-----------------|------------------|
| 函数 | 作用 |
| getcontext() | 保存当前上下文到变量 |
| setcontext() | 切换到指定上下文(函数不返回) |
| makecontext() | 修改上下文,使其执行新函数 |
| swapcontext() | 保存当前上下文并立即切换到另一个 |

如下代码是一个简单的使用ucontext进行上下文切换的代码,代码中详细介绍了上述四个接口

cpp 复制代码
#include <stdio.h>
#include <ucontext.h>

#define STACK_SIZE 8192

static ucontext_t ctx_main; /* main 函数的上下文,用于跳回 */
static ucontext_t ctx_a;    /* 手动构造的上下文 A        */
static ucontext_t ctx_b;    /* 手动构造的上下文 B        */
static char stack_a[STACK_SIZE];
static char stack_b[STACK_SIZE];

/* ctx_a 的入口函数 */
static void entry_a(void)
{
    printf("[A] 进入 entry_a\n");

    /*
     * swapcontext: 保存当前上下文到 ctx_a,恢复 ctx_b 继续执行。
     * ctx_b 原本是从 main 通过 swapcontext 跳过来的,所以从 entry_b 的
     * swapcontext 调用点之后开始执行。
     */
    printf("[A] swapcontext(&ctx_a, &ctx_b) → 切到 ctx_b\n");
    swapcontext(&ctx_a, &ctx_b);

    /* 从 ctx_b 切回来时,从这里继续执行 */
    printf("[A] 从 ctx_b 切回, setcontext(&ctx_main) → 跳回 main\n");
    setcontext(&ctx_main);
}

/* ctx_b 的入口函数 */
static void entry_b(void)
{
    printf("[B] 进入 entry_b\n");

    /*
     * swapcontext: 保存当前上下文到 ctx_b,恢复 ctx_a 继续执行。
     * ctx_a 的 swapcontext 调用点之后开始执行。
     */
    printf("[B] swapcontext(&ctx_b, &ctx_a) → 切回 ctx_a\n");
    swapcontext(&ctx_b, &ctx_a);

    /* 这行永远不会执行:ctx_a 最后通过 setcontext 跳回了 main,不会再来这里 */
    printf("[B] 这行不会被打印\n");
}

int main(void)
{
    printf("=== ucontext 四个接口示例 ===\n\n");

    /*
     * 1. getcontext --- 获取当前上下文
     *
     *    保存当前 CPU 寄存器、栈指针、信号掩码到 ucontext_t。首次调用返回 0。
     *    当后续通过 setcontext/swapcontext 恢复该上下文时,从 getcontext 处
     *    "再次返回",返回值变为非 0。
     */
    printf("[main] 调用 getcontext(&ctx_main) 保存 main 的上下文\n");
    int ret = getcontext(&ctx_main);
    if (ret == 0) {
        printf("[main] getcontext 首次返回 (ret=0),开始构造 ctx_a 和 ctx_b\n\n");
    } else {
        printf("[main] 从 ctx_a 跳回,getcontext 再次返回 (ret=%d)\n\n", ret);
        printf("=== 示例结束 ===\n");
        return 0;
    }

    /*
     * 2. makecontext --- 构造一个新的上下文
     *
     *    步骤:
     *    a) 先用 getcontext 获取模板上下文。
     *    b) 设置 uc_stack(独立栈空间)和 uc_link(返回后的后继上下文)。
     *    c) 调用 makecontext 绑定入口函数和参数。
     *
     *    makecontext(ctx, func, argc, arg1, arg2, ...):
     *      func --- 入口函数,签名为 void (*)(void)
     *      argc --- 传给 func 的 int 参数个数(最多 8 个)
     */
    getcontext(&ctx_a);
    ctx_a.uc_stack.ss_sp   = stack_a;
    ctx_a.uc_stack.ss_size = STACK_SIZE;
    ctx_a.uc_link          = NULL;
    makecontext(&ctx_a, entry_a, 0);

    printf("[main] ctx_a 构造完成, sp=%p\n", stack_a);

    getcontext(&ctx_b);
    ctx_b.uc_stack.ss_sp   = stack_b;
    ctx_b.uc_stack.ss_size = STACK_SIZE;
    ctx_b.uc_link          = NULL;
    makecontext(&ctx_b, entry_b, 0);

    printf("[main] ctx_b 构造完成, sp=%p\n\n", stack_b);

    /*
     * 3. swapcontext --- 保存当前上下文并切换到另一个上下文
     *
     *    swapcontext(old, new) 等价于 getcontext(old) + setcontext(new),
     *    但是是原子的。切换后,程序从 new 上下文开始执行。
     *    当某处通过 setcontext/swapcontext 恢复 old 时,程序从这里继续。
     */
    printf("[main] swapcontext(&ctx_main, &ctx_a) → 跳到 ctx_a\n\n");
    swapcontext(&ctx_main, &ctx_a);

    /*
     * 4. setcontext --- 直接跳转到目标上下文
     *
     *    与 swapcontext 不同,setcontext 不保存当前上下文,只是单纯跳走。
     *    entry_a 的最后使用了 setcontext 跳回 ctx_main,
     *    所以 main 中的这段代码能执行到,并最终通过 getcontext 的 else 分支退出。
     */
    printf("[main] 这段会被打印 (entry_a 用 setcontext 跳过了 swapcontext 的返回)\n");

    return 1;
}

如下为上述代码的执行结果

=== ucontext 四个接口示例 ===

main\] 调用 getcontext(\&ctx_main) 保存 main 的上下文 \[main\] getcontext 首次返回 (ret=0),开始构造 ctx_a 和 ctx_b \[main\] ctx_a 构造完成, sp=0x560819e32be0 \[main\] ctx_b 构造完成, sp=0x560819e34be0 \[main\] swapcontext(\&ctx_main, \&ctx_a) → 跳到 ctx_a \[A\] 进入 entry_a \[A\] swapcontext(\&ctx_a, \&ctx_b) → 切到 ctx_b \[B\] 进入 entry_b \[B\] swapcontext(\&ctx_b, \&ctx_a) → 切回 ctx_a \[A\] 从 ctx_b 切回, setcontext(\&ctx_main) → 跳回 main \[main\] 这段会被打印 (entry_a 用 setcontext 跳过了 swapcontext 的返回)

这段代码的调用流程图如下

3.3 汇编实现上下文切换

汇编实现协程上下文切换是这三种方法中最快的一种,在NtyCo这个协程框架中,就是采用汇编实现的上下文切换,但汇编代码受硬件限制较大,所以对于不同的硬件架构都有一套不同的代码,接下来我们以x86_64架构为例,了解一下如何使用汇编实现上下文切换

如下为ntyco中协程上下文切换相关代码

cpp 复制代码
int _switch(nty_cpu_ctx *new_ctx, nty_cpu_ctx *cur_ctx);

#ifdef __i386__
// ....
#elif defined(__x86_64__)

__asm__ (
"    .text                                  \n"
"       .p2align 4,,15                                   \n"
".globl _switch                                          \n"
".globl __switch                                         \n"
"_switch:                                                \n"
"__switch:                                               \n"
"       movq %rsp, 0(%rsi)      # save stack_pointer     \n"
"       movq %rbp, 8(%rsi)      # save frame_pointer     \n"
"       movq (%rsp), %rax       # save insn_pointer      \n"
"       movq %rax, 16(%rsi)                              \n"
"       movq %rbx, 24(%rsi)     # save rbx,r12-r15       \n"
"       movq %r12, 32(%rsi)                              \n"
"       movq %r13, 40(%rsi)                              \n"
"       movq %r14, 48(%rsi)                              \n"
"       movq %r15, 56(%rsi)                              \n"
"       movq 56(%rdi), %r15                              \n"
"       movq 48(%rdi), %r14                              \n"
"       movq 40(%rdi), %r13     # restore rbx,r12-r15    \n"
"       movq 32(%rdi), %r12                              \n"
"       movq 24(%rdi), %rbx                              \n"
"       movq 8(%rdi), %rbp      # restore frame_pointer  \n"
"       movq 0(%rdi), %rsp      # restore stack_pointer  \n"
"       movq 16(%rdi), %rax     # restore insn_pointer   \n"
"       movq %rax, (%rsp)                                \n"
"       ret                                              \n"
);
#endif

这段代码,总的来说可以分为三个步骤

第一部分:保存当前CPU状态到第二个参数指向的结构体

这一部分将当前协程的CPU寄存器值写入cur_ctx(通过%rsi传入)对应的内存位置。具体保存了:栈指针%rsp、栈帧基址%rbp、函数返回地址(从栈顶取出存入%rax后再保存)、以及%rbx%r12%r15这六个被调用者保存的寄存器。这些寄存器包含了当前协程执行所需的所有关键现场信息,保存完成后,当前协程就被完整地"冻结"在了cur_ctx中。

第二部分:从第一个参数指向的结构体中恢复下一个协程的CPU状态

这一部分从new_ctx(通过%rdi传入)中读取之前保存的寄存器值,并恢复到CPU中。恢复顺序与保存顺序对应:先恢复%r15%rbx这六个寄存器,再恢复栈帧基址%rbp,最后恢复栈指针%rsp。完成这一步后,CPU的寄存器状态已经完全切换为目标协程被挂起时的状态,栈空间也切换到了目标协程的栈。

第三部分:完成从当前函数到下一个协程的跳转

这一部分实现最后的执行流切换。代码从new_ctx中偏移16的位置取出目标协程之前保存的指令指针%rax(即目标协程被挂起时,即将执行的下一条指令的地址),然后将这个地址压入当前已切换过来的目标协程的栈顶。随后执行ret指令------ret会从栈顶弹出一个地址并跳转到该地址。由于栈顶已经被设置为目标协程的指令指针,因此ret执行后,CPU就跳转到了目标协程被挂起的位置继续执行。整个切换过程中,原_switch函数并没有返回到它的调用者,而是直接"穿越"到了另一个协程的执行流中。

3.4 协程切换三种方式的优缺点

|--------------------|--------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
| 方式 | 优点 | 缺点 |
| setjmp/longjmp | 1.纯 C 标准库,可移植性极高 2.接口简单,仅需一个 jmp_buf 3.切换开销小,无系统调用 | 1.无法切换栈,一旦 setjmp 函数返回,栈帧销毁,再 longjmp 将导致栈混乱、数据损坏 2.保存的上下文不完整(依赖实现,可能丢失浮点/向量寄存器) 3.无法实现真正协程(所有协程共享一个栈) |
| ucontext | 1.提供完整的用户态上下文(寄存器、信号掩码等) 2.支持独立栈,可实现真正协程 3.曾是 POSIX 标准,在 Unix-like 系统上可移植 | 1.已被 POSIX 废弃,未来可能被系统移除 2.性能较差(swapcontext 保存/恢复信号掩码通常触发系统调用) 3.makecontext 参数类型不安全,在 x86-64 上传指针易出错 4.依赖 glibc 或 libucontext,嵌入式环境不可用 |
| 手写汇编 | 1.性能最高(仅保存必要寄存器,无系统调用,切换 ~10 条指令) 2.完全可控(可定制保存哪些寄存器) 3.无外部依赖,适合裸机/嵌入式 4.工业级协程库的标准做法 | 1.可移植性差(每个 CPU 架构和操作系统 ABI 需单独实现) 2.开发调试难度大(需精通汇编、栈帧布局、调用约定) 3.维护成本高(每增加一个平台就要新增汇编代码) |

第四章 协程调度器的原理

4.1 为什么需要协程调度器

经过前文的介绍,我们知道在一个程序中协程不止一个,我们可以通过多个协程的调度来实现I/O密集型应用的效率提高。

对于多个协程来说,我们需要对他们先描述再组织。而描述实际上就是把协程进行封装,这部分工作很好做。我们的重点来到了如何组织协程?这就引出了协程调度器的概念。

协程调度器解决的问题主要是下面几个:

  1. 如何决定下一个执行的协程:当多个协程都处于就绪状态时,调度器需要按照某种策略(如 FIFO、优先级、时间片轮转)选出一个来运行,保证系统的公平性和响应速度。

  2. 如何处理协程的主动让出与阻塞:协程在等待 I/O、定时器或锁时,调度器需要将其挂起并移出就绪队列,待条件满足后再重新加入队列,从而避免整个线程被阻塞。

  3. 如何管理协程的生命周期:从创建、运行、挂起到销毁,调度器统一维护每个协程的状态(就绪、运行中、等待、死亡),并负责栈空间、上下文等资源的回收。

  4. 如何与异步事件循环集成:调度器通常需要监听 epoll、kqueue 或 IOCP 等事件、源,当描述符就绪或超时发生时,自动唤醒对应的等待协程,实现高效的 I/O 密集型任务处理。

  5. 如何防止优先级反转或饥饿:在协作式调度中,若某个协程长时间不主动让出 CPU,调度器可借助定时器或抢占机制强制切换,保证所有协程都能得到执行机会。

4.2 协程调度器的原理

对于一个最基本的基于协作式的协程调度器来说,他的处理流程主要经过如下几个步骤

  1. 初始化协程调度器,创建主协程(协程调度器)
  2. 单个协程支持主动让出CPU,当协程yield或者结束时,他会唤醒主协程(上下文切换到主协程)
  3. 选择一个非空闲的协程,将上下文从主协程切换到该协程
  4. 协程执行自己的任务
  5. 重复2-4

图示如下

上述这种协程调度的方法已经可以实现一个简单的写成调度器了,但他存在一些问题。

协程实际上是有状态的概念的,即对于一个协程来说他可能是就绪的,也可能处于运行状态,也可能处于等待I/O事件的状态

如果只维护一个协程队列,所有的协程都放入到这个协程队列进行管理。那么我们在找一个非空闲的协程时,需要遍历这个队列,这显然定位一个协程就比较慢了。于是我们把一个协程队列划分为多个不同状态的队列。对于同一状态的协程,我们统一放入到同一个队列进行管理。

对于常见的协程框架(如NtyCo)来说,它们实现的调度器内部至少需要存在三个队列:

  • 就绪队列:存储所有可运行(空闲/未阻塞)的协程,调度器会从中取出协程并执行。
  • 睡眠队列:存储因调用 sleep 或等待定时器而需要延时唤醒的协程。通常按唤醒时间排序,调度器在每次循环中检查超时并将其移回就绪队列。
  • 等待队列:存储因等待 I/O 事件、锁、信号量或其他同步条件而阻塞的协程。当条件满足(如 fd 可读/可写、锁被释放)时,协程会被重新放回就绪队列。

注意:当某个协程需要等待I/O资源,并且需要设置等待的超时时间时,需要在等待队列和睡眠队列中都添加该协程。若该协程等待超时,那么会在睡眠队列中检测出来,然后将该协程从睡眠和等待队列中移除添加到就绪队列中。若该协程I/O资源就绪但未超时,那么会首先将该协程移出等待队列再从睡眠队列中删除该协程。

如果睡眠队列维护的仅仅是个普通队列,那么我们需要轮询检测该队列中有哪个协程等待超时,时间复杂度O(N),显然是不合理的。我们可以将睡眠队列改为基于超时时间的睡眠优先级队列或者睡眠红黑树。NtyCo中采用的是红黑树

第五章 Hook机制

5.1 什么是Hook机制

Hook,中文常称为"钩子"或"劫持"。在协程框架中,Hook 机制指的是拦截程序对系统 API(如 read/write/...)的调用,并将其替换成协程友好的版本。

当用户的程序代码中调用对应的系统API接口时,我们可以让他不直接调用实际的POSIX API接口,而是先调用我们自己实现的一个自定义read函数,然后再由我们的read函数调用对应的系统POSIX API

图示解释了普通的调用和hook调用一个read函数的区别

5.2 为什么要有Hook机制

Hook本质上是一种技术方案,我们不能脱离具体的业务场景来聊,我们以协程框架为什么需要有Hook为例。

没有 Hook 的协程框架几乎无法直接投入到真实的网络服务器开发中。原因如下:

1. 现有代码都是"阻塞思维"写出来的

绝大多数第三方库(MySQL 客户端、Redis 客户端、HTTP 库等)内部都是按照同步阻塞模型设计的。如果没有 Hook,这些库会直接调用系统的 read/write,导致整个进程阻塞,所有协程都无法调度,协程的优势荡然无存。

2. 改写已有代码成本极高

如果要求开发者将所有阻塞 API 手动替换为非阻塞 + 回调/轮询的方式,那无异于回到异步回调地狱。协程带来的"同步写法"红利就完全丢失了。

而有了Hook以后我们不需要改写旧项目的逻辑,而是把对应的系统POSIX API "hook住" 即可

3. 实现"同步代码,异步性能"的终极目标

Hook 机制让开发者可以用最自然的方式编写顺序逻辑,而协程调度器在背后自动将阻塞点转换成异步等待。这是协程框架能够落地的核心基础。

5.3 Hook机制的原理

Linux中Hook机制的实现实际上依赖于一个环境变量LD_PRELOAD和一个库函数dlsym

首先我们先聚焦在实现Hook的问题在哪里。

1.我们需要让程序保证首先调用我们自定义的函数接口,而不是系统调用

这实际上依赖于环境变量LD_PRELOAD。

这个环境变量的作用是:当程序启动时,动态链接器会按照一定顺序加载共享库。LD_PRELOAD 指定的库会被最先加载,从而使得该库中的符号(函数名)优先级最高。

2.当程序进入自定义函数时,后续如何保证能回调回系统POSIX API,从而不会发生递归调用

这依赖于glibc中提供的库函数dlsym

dlsym 是 Linux 动态链接库接口中的一个函数,它的作用非常简单:在运行时,根据符号名(函数名/变量名)获取它在内存中的地址。

它的接口介绍如下

cpp 复制代码
void *dlsym(void *handle, const char *symbol);

symbol参数支持如下格式的符号:

  • C函数名称:如"recv" 、"send"等
  • 变量名称

注意:symbol参数不支持C++函数名称,因为C++的函数底层符号表通常会带有参数信息。而dlsym是纯C的库。如果需要支持C++函数名称需要extern "C",使其避免修饰

handler参数指的是查找规则,它支持如下方式:

|----------------|-------------------------------|---------------------------------------------|
| 句柄类型 | 作用 | 使用场景 |
| dlopen() 返回值 | 只在指定的那个动态库及其依赖中查找符号。 | 需要精确调用某个特定库中的函数时使用。 |
| RTLD_DEFAULT | 全局查找,范围覆盖主程序、已加载库等。 | 在全局范围内检查某个函数是否存在。 |
| RTLD_NEXT | 在当前库之后加载的对象中查找,可以找到"下一个"同名函数。 | 这是实现Hook的核心,可在LD_PRELOAD的so中找到并调用真正的系统函数。 |

5.4 Hook机制使用样例

样例介绍:接下来我们将hook住glibc的sleep函数。

首先是编写一个动态库。如下代码

cpp 复制代码
// hook_sleep.c
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <unistd.h>

// 声明原始 sleep 函数
typedef unsigned int (*sleep_func_t)(unsigned int seconds);

unsigned int sleep(unsigned int seconds) {
    // 获取真正的 sleep 函数地址
    sleep_func_t original_sleep = (sleep_func_t)dlsym(RTLD_NEXT, "sleep");
    
    printf("[Hook] sleep(%u) 被调用了,但实际只睡 1 秒\n", seconds);
    
    // 实际只睡 1 秒(如果要完全跳过睡眠,可以写 return 0)
    return original_sleep(1);
}

使用如下指令将上述代码打包成动态库

bash 复制代码
gcc -shared -fPIC -o libhook_sleep.so hook_sleep.c -ldl

接下来编写程序主函数,主函数逻辑如下

cpp 复制代码
// test.c
#include <unistd.h>
#include <stdio.h>
#include <time.h>

int main() {
    time_t start = time(NULL);
    printf("开始 sleep(5)...\n");
    sleep(5);
    printf("结束,实际耗时 %ld 秒\n", time(NULL) - start);
    return 0;
}

使用如下bash指令编译一下主函数,如下

bash 复制代码
gcc -o test test.c

接下来我们使用普通调用,即不链接我们的动态库,如下为输出结果

开始 sleep(5)...

结束,实际耗时 5 秒

我们再使用hook机制来调用sleep,即使用LD_PRELOAD前向加载我们的动态库,使用如下指令运行程序

bash 复制代码
LD_PRELOAD=./libhook_sleep.so ./test

如下为输出结果

开始 sleep(5)...

Hook\] sleep(5) 被调用了,但实际只睡 1 秒 结束,实际耗时 1 秒

第六章 手写简易版协程库

如下是运用本篇博文学习的知识用C++自主实现的一个协程库,代码地址参考:简易版协程库

相关推荐
Byron Loong3 天前
【逆向】AT Hook 与 Inline Hook 对比
c语言·汇编·c++
iCxhust5 天前
微机原理课程设计大综合---计数器
汇编·单片机·嵌入式硬件·课程设计·微机原理
xxjj998a6 天前
PHP与汇编:从Web到硬件的编程差异
开发语言·汇编·php
陈eaten7 天前
汇编使用AES指令集实现AES解密
汇编·python·aes解密·aes指令集
顾鉴行思7 天前
10 字符串常量到底存在哪里?
c语言·汇编·经验分享
iCxhust7 天前
在 emu8086 中可以直接编译运行的完整汇编程序,演示数组的定义、遍历、求和、求最大值。
开发语言·前端·javascript·汇编·单片机·嵌入式硬件·算法
浩浩测试一下7 天前
堆栈中的 参数与局部变量 (逆向分析)
汇编·逆向·免杀·堆栈·windows编程·pe壳
iCxhust8 天前
微机原理实践教程(C语言篇)---A001闪烁灯
c语言·开发语言·汇编·单片机·嵌入式硬件·51单片机·微机原理
浩浩测试一下9 天前
抬栈 恢复上下文 (逆向分析)
汇编·逆向·堆栈·windows核心编程