参考资料:
https://www.bilibili.com/video/BV1KD4y1U7Rr?vd_source=c744ec928a14e81c8bf974e8d2d7e80f
https://www.bilibili.com/video/BV1H541187UH?vd_source=c744ec928a14e81c8bf974e8d2d7e80f
https://www.bilibili.com/video/BV1b5411b7SD?vd_source=c744ec928a14e81c8bf974e8d2d7e80f
https://www.bilibili.com/video/BV1a5411b7aZ?vd_source=c744ec928a14e81c8bf974e8d2d7e80f
前置知识
1、什么是虚拟内存?
1.1 虚拟内存和物理内存
当一个可执行文件加载到内存中执行时,就成为了一个运行的程序,也就是一个进程。在DOS时期采用的是实地址模式,进程直接使用物理地址,但是这种模式下进程可以任意修改物理内存,很容易发生占用其他进程内存的情况,甚至可能会覆盖操作系统使用的内存。

所以为了处理这种意外修改的情况,所以就出现了另外一种隔离思想的**"保护模式"**,即进程不直接使用物理内存地址,而是使用虚拟的内存地址,这些地址被称为线性地址(或者逻辑地址),操作系统负责把虚拟内存映射到物理内存。

1.2 虚拟内存与物理内存的映射
这个**"保护模式"** 是这样实现的:首先要知道保护模式提供内存分页机制,比如在32位系统下,物理内存中每4K作为一页,从虚拟内存到物理内存是以页为单位映射的;

然后要知道操作系统会以链表的形式记录各个进程的控制信息,这在Windows中称为进程控制块(PCB),在Linux中对应task_struct结构体。

每个进程的控制信息中都有这样一个指针,存储的是当前进程页目录的物理地址。

页目录也是一个内存页,存储的是一系列指针,指向同样用来存储物理内存页起始地址的页表,32位下一个内存地址占4字节,一个页目录就可以寻址1024个页表(一个内存页4K,4K / 4byte = 1024个),而每个页表又可以寻址1024个物理内存页,每个页4K,这样正好等于4G,也就是说32位下只需要有这样两级页表,就足够寻址4G大小的内存空间了。
如何定位到一个具体的内存呢?一个32位的线性地址中 ,这10位可以从页目录中选择一个页表(10位是因为页目录总共也就1024个页表),接下来的10位又可以从对应页表中锁定一个物理内存页(10位是因为页表的指向总共也就1024个),最后剩下的12位会用来存储一个相对于内存页起始地址的偏移值(一个内存页有4K大小,那么就有有4K个地址,定位到一个具体的地址只需要12位就行了)。12位正好覆盖4K个偏移值,也就足够定位到这一页的每一个地址了。这样就实现了虚拟内存中一个内存页到一个物理内存页的映射。

这样就实现了虚拟内存中一个内存页到一个物理内存页的映射。不仅没有在进程中直接使用物理内存地址,而且每个进程对应自己的页表,这样在不同进程中相同的线性地址也会被映射到不同的物理地址,从而实现进程地址空间的隔离。还可以通过把同一组物理页面映射到不同进程的页表中来实现进程间共享内存。

1.3 页目录、页表的存储内容
页目录:是一个内存页,存储的是一系列指针,存的是页表的地址指向页表。
页表:页表中存储的记录,它们也不只是内存页起始地址 这么简单,因为内存页大小都是4K,所以内存页的起始地址一定是4K的整数倍,也就是说它的低12位一定是0,因此页表里每一条记录都有12位的空闲空间可以使用,它们可以用来标识对应物理内存页是否可读、可写、可执行等信息。其中就有一位用于标识该物理内存页是否已经映射。

1.4 虚拟内存的申请与映射
为什么页表存储的地址中有一位用于标识该物理内存页是否已经映射?因为进程向操作系统申请映射内存时,通常不会一申请就立马分配,操作系统会先记个账,例如Linux中通过进程对应的task_struct可以找到记录内存分配的链表,每个链表项都是一个VMA结构体,里面记录着该进程已经申请的一段连续内存地址区间,例如进程申请地址区间a到b这段内存,它的VMA链表就会增加一项或者对相邻区间进行扩张。标记上地址区间a到b已经申请了,但是真正的映射要到进程访问这段内存时才会进行。
所以说进程的虚拟地址空间只是它可以申请使用的一个范围,只有真正被映射到物理内存才算是能够合法使用的虚拟内存,而没有被映射到物理内存的部分不属于合法的线性地址,要使用就必须先映射。

既然进程使用的都是线性地址,那么程序执行时CPU拿到的也是这样的线性地址,而线性地址到物理地址的转换会交由CPU中的内存管理单元(MMU)负责,当前进程持有的页目录的物理地址会被保存到特定寄存器,这样CPU就可以借助页目录和页表把线性地址转换成物理地址了。此外,由于频繁查页表会影响效率,CPU会把当前进程已经转换过的地址映射关系缓存到TLB中,需要转换地址时,先去TLB中查找,没有的话再去查页表,然后写入TLB。如果要切换到另一个进程执行,那么寄存器存储的页目录也会改变,之前的TLB缓存就会失效,需要重新查询页表,建立新的缓存数据,这也是进程切换代价比较高的一个原因(TLB缓存失效)。

如果CPU查页表时发现对应物理内存页还没有完成映射,就会发生page fault(缺页异常)处理这个异常的page fault handler 就会去进程控制信息task_struct中找到记录内存分配的VMA链表,查询该进程是否申请了这段内存,如果已经申请了,就实际分配物理页面并完成页表映射,然后就可以正常使用了。若没有申请过,就会发生内存访问异常。这样设计的目的是为了保障系统运行效率,毕竟内存映射比较耗时。
2、进程与线程
2.1 线程
虽然每个进程都有自己的虚拟地址空间,但是为了进一步保障系统运行安全,虚拟地址空间被划分为用户空间 和内核空间 。操作系统运行在内核空间,用户程序运行在用户空间。

内核空间由所有进程的地址空间共享,但是用户程序不能直接访问内核空间。操作系统保存的进程控制信息自然是在内核空间,这里除了页目录以外,还可以找到很多重要的内容,例如进程和父进程的ID 状态,打开文件句柄表等等。


线程就是进程中的执行体 ,它要有指定的执行入口,通常会是某个函数的指令入口。线程执行时要使用从进程虚拟地址空间中分配的栈空间来存储数据,这被称为线程栈 。在创建线程时,操作系统会使用用户空间和内核空间分别分配两段栈,就是通常所说的用户栈和内核栈。线程切换到内核态执行时会使用内核栈,目的是不允许用户代码对其进行修改以保证安全。
操作系统也会记录每个线程的控制信息,例如执行入口、线程栈(用户栈和内核栈)、线程ID等等。在Windows中,线程控制信息对应TCB,在PCB中可以找到进程拥有的线程列表。同一个进程内的线程会共享进程的地址、空间和句柄表等资源。

在 Linux 内核中,无论是进程还是线程,都是用 task_struct 这个结构体来表示的。创建线程使用的底层函数和进程一样,都是 clone,区别在于创建时刻意共享了大量的资源(如虚拟内存空间,句柄表等资源)。因此,Linux 下的线程常被称为轻量级进程(Light-Weight Process, LWP) 。

2.2 线程执行
如果接下来要执行进程a中的线程a1,指定执行入口,CPU的指令指针就会指向线程的执行入口,当前执行用户空间的程序指令,所以栈基和栈指-针寄存器会记录用户栈的位置。可以看到程序执行时CPU面向的是某个线程,所以才说线程是操作系统调度与执行的基本单位。
栈基:EBP/FP,指向当前函数栈帧的底部(高地址),在函数执行期间通常固定不变。
栈指针:ESP/SP,指向当前函数栈帧的顶部(低地址),随着数据入栈和出栈而动态移动。
因此栈基和栈指针的协调工作清晰地界定出一个栈帧的范围。

一个进程中至少要有一个线程,它要从这个线程开始执行,这被称为它的主线程。可以认为主线程是进程中的第一个线程,一般是由父进程或操作系统创建的,而进程中的其他线程一般都是由主线程创建的。
线程中发生函数调用时,就会在线程栈中分配函数调用栈,而虚拟内存分配、文件操作、网络读写等很多功能都是由操作系统来实现,再向用户程序暴露接口,所以线程免不了要调用操作系统提供的系统服务,也就是少不了进行系统调用。CPU中会有一个特权级标志,用于记录当前程序执行在用户态还是内核态,只有标记为内核态时才可以访问内核空间,如果目前线程a1处在用户态,那就还不能访问内核空间,所以系统调用发生时就得切换到内核态,使用线程的内核栈执行内核空间的系统函数,这被称为从用户态切换到内核态。

2.3 系统调用
最初系统调用 是通过软中断 触发的,所谓软中断,就是指令模拟中断, 与软中断对应的就是硬件中断。操作系统会按照硬件要求,在内存里存一张中断向量表, 用来把各个中断编号映射到相应的处理程序, 例如Linux系统中,系统调用中断对应的编号为0x80,对应的处理程序就是用来派发系统调用的 ,操作系统提供了数百个系统调用,将这数百个系统调用放进系统调用表 ,系统调用表中系统调用编号 一一对应各自的函数入口 ,所以用户程序这里会把要调用的系统函数编号存入特定寄存器 ,通过寄存器或者用户栈来传递其他所需参数 ,然后用int 0x80来触发系统调用中断 ;而硬件层面,CPU有一个中断控制器,它负责接收中断信号 ,切换到内核态,保存用户态执行现场,一部分寄存器的值会通过硬件机制保存起来,还有一部分通用寄存器的值会被压入内核栈中,然后去中断向量表这里查询0x80对应的系统调用派发程序入口,而系统调用的派发程序会根据指定的系统调用编号,去系统调用表这里查询对应的系统调用入口并执行。
后来为了优化系统调用的性能 ,改为通过特殊指令触发系统调用 ,例如X86的sysenter和arm的64平台下的syscall。当CPU执行到这些指令时,就会陷入内核态,从专用寄存器拿到派发入口地址,省去了查询中断向量表的过程。
等系统调用结束后,再利用之前保存的信息恢复线程,在用户态的执行现场继续执行后面的指令,这样就完成了一次系统调用。



| 步骤 | 执行模式 | 关键操作 |
|---|---|---|
| 1. 发起调用 | 用户态 | 应用程序或库函数(如printf内部调用write)准备参数,并将系统调用编号存入特定寄存器(如eax)。 |
| 2. 陷入内核 | 用户态 → 内核态 | 执行特殊指令(如传统的int 0x80或现代的syscall),CPU自动切换到内核栈并保存用户态执行现场(如寄存器、程序计数器)。 |
| 3. 请求派发 | 内核态 | 内核根据系统调用编号,在系统调用表 中查找并执行对应的服务例程(如sys_write)。 |
| 4. 执行服务 | 内核态 | 内核例程执行具体的特权操作,如操作文件、进行网络通信等。 |
| 5. 返回结果 | 内核态 → 用户态 | 内核将执行结果和状态存入寄存器(如eax),通过iret或sysret等指令恢复用户态现场,返回到用户程序继续执行。 |
| 特性维度 | 软中断机制 (如 int 0x80) |
专用指令机制 (如 syscall/sysenter) |
|---|---|---|
| 触发方式 | 执行软件中断指令 int 0x80 |
执行特定指令(如syscall) |
| 核心流程 | 通过查询中断向量表找到系统调用派发程序 | 直接从专用寄存器获取派发程序入口 |
| 性能表现 | 上下文切换开销较大,需经历完整的中断处理流程 | 优化了流程,减少了状态保存和跳转步骤,开销更小 |
| 设计目标 | 提供一种稳定可靠的内核入口机制 | 针对系统调用的高频场景进行性能优化 |
2.4 线程切换
现代操作系统中CPU的执行权被划分为不同的时间片,只有获得CPU时间片的程序才能运行。由于时间片很短,所以用户感觉不到程序的切换过程,又因为CPU执行的很快,所以即使很短的时间片也足够它执行很多很多的指令。一个线程获得的时间片用完时,CPU硬件时钟会触发一次时钟中断,对应的中断处理程序会从已经就绪的线程中挑选一个来执行。(不展开调度问题)

线程切换:例如接下来要从线程A1切换到线程A2,而这两个线程同属于进程a,那么就只涉及到线程切换,只需要把线程A1的执行现场保存起来,后续再把指令指针、栈指针这些寄存器的值修改为线程A2的信息,修改一下内存中调度相关的数据结构,一次同进程间的线程切换就算完成了。

等到线程A1再次获得时间片时,会根据之前保存的信息恢复到切换前的执行现场,继续完成它的任务。

假如线程A1要切换到另一个进程b的线程B1,那么除了线程切换外,还要切换进程,CPU这里保存的页目录地址要切换到进程b。所以进程切换与线程切换的区别就是进程切换会导致地址空间等进程资源发生变化,会导致TLB缓存失效,代价相应会更大。

3、协程
3.1 协程认识
我们已经知道线程是进程中的执行体,拥有一个执行入口,以及从进程虚拟地址空间中分配的栈,包括用户栈和内核栈。操作系统会记录线程控制信息,而线程获得CPU时间片以后才可以执行。CPU这里的栈指针、指令指针等寄存器都要切换到对应的线程,如果线程自己又创建几个执行体给它们各自指定执行入口,申请一些内存分配给它们用作执行栈,那么线程就可以按需调度这几个执行体了。 为了实现这些执行体的切换,线程也需要记录它们的控制信息,包括ID标识符、栈的位置、执行入口地址、执行现场等等。线程可以选择一个执行体来执行 ,此时CPU中指令指针就会指向这个执行体的执行入口,栈基和栈指针寄存器也会指向线程给它分配的执行栈(需要注意单线程的情况下,协程的resume和yield一定是同步的,一个协程进行yield暂停,必然对应另一个协程的resume恢复,因为线程不能没用执行主体 )。
要切换执行体时,需要先保存当前执行体的执行现场,然后切换到另一个执行体,通过同样的方式可以恢复到之前的执行体,这样就可以从上次中断的地方继续执行。这些由线程创建的执行体就是所谓的协程 ,因为用户程序不能操作内核空间,所以只能给协程分配用户栈,而操作系统对协程一无所知,所以协程又被称为用户态线程。


协程的思想很早就被提出来了,最初是为了解决编译器实现中的问题,后来相继出现了很多种实现方式,例如Windows中的纤程,再例如Lua中的coroutine。可无论被赋予什么样的名字,有着怎样的用法,在创建协程时都要指定执行入口,底层都会分配协程执行栈和控制信息,否则又该如何实现用户态的调度?而让出执行权时,也都要保存执行现场,不然如何能够从中断处恢复执行?所以协程思想的关键在于控制流的主动让出和恢复,即yield和resume。
每个协程拥有执行栈,可以保存自己的执行现场,所以可以由用户程序按需创建协程。协程主动让出执行权时,会保存执行现场,然后切换到其他协程。协程恢复执行时,会根据之前保存的执行现场恢复到中断前的状态继续执行,这样就通过协程实现了既轻量又灵活的由用户态进行调度的多任务模型。

举一个例子:
cpp
void test1()
{
cout << 1 << ' ';
cout << 2 << ' ';
cout << 3 << ' ';
}
void test2()
{
cout << 'x' << ' ';
cout << 'y' << ' ';
cout << 'z' << ' ';
}
int main()
{
test1();
test2();
cout << endl;
return 0;
}
在单线程下,输出为:1 2 3 x y z
如果用libco库改造一下:
cpp
void test1()
{
cout << 1 << " ";
cout << 2 << " ";
co_yield_ct(); // 切出到主协程
cout << 3 << " ";
}
void test2()
{
cout << "x" << " ";
co_yield_ct(); // 切出到主协程
cout << "y" << " ";
cout << "z" << " ";
}
int main(void)
{
... // 主协程
co_resume(test1); // 启动协程 A
co_resume(test2); // 启动协程 B
co_resume(test1); // 从协程 A 切出处继续执行
co_resume(test2); // 从协程 B 切出处继续执行
}
在单线程下,输出为:1 2 x 3 y
可以看出切出操作是由 co_yield_ct() 函数实现的,而协程的启动和恢复是由 co_resume 实现的;test1()和test2()并不是一个执行完才执行另一个,而是产生了"交叉执行"的效果,这是通过协程实现的。
为什么用协程不用线程呢?
在执行某些操作(特别是IO操作)时,往往不希望通过"创建新线程"这种重量级方式来实现异步处理。更理想的做法是:让当前线程在运行过程中能够暂时切换去处理其他任务,待IO准备就绪后,再切换回来继续执行。
相比于多开一个线程来操作,使用协程的好处:
- 减少了线程的重复高频创建;
- 尽量避免线程的阻塞;
- 提升代码的可维护与可理解性(毕竟不需要考虑多线程那一套东西了);
协程的特点:
- 协程可以主动让出 CPU 时间片;(注意:不是当前线程让出 CPU 时间片,而是线程内的某个协程让出时间片供同线程内其他协程运行;)
- 协程可以恢复 CPU 上下文;当另一个协程继续执行时,其需要恢复 CPU 上下文环境;
- 协程有个管理者,管理者可以选择一个协程来运行,其他协程要么阻塞,要么ready,或者died;
- 运行中的协程将占有当前线程的所有计算资源;
3.2 协程的类别
对称协程与非对称协程
这是从调度控制权的角度进行的分类,决定了协程之间如何相互调用。
非对称协程:
- 这是最常见、最主流的协程类型(如 Python 的
async/await, Kotlin 的 coroutines, Go 的 goroutines)。- 它们之间存在明显的调用关系 ,类似于函数调用。一个协程(调用者)可以挂起 自身,并恢复 另一个特定的协程(被调用者)。被调用者执行完毕后,控制权会自动返回给调用者。
- 可以想象成老板和员工的关系:老板(主协程)将任务派发给员工(子协程),员工完成后必须向老板汇报,交还控制权。
对称协程:
- 在这种模型中,所有协程是平等的,不存在从属关系。
- 一个协程可以主动挂起 自身,并将控制权直接转移给任何一个其他协程,而不必是调用它的那个协程。调度器通常负责决定下一个执行的是哪个协程。
- 这更像是一个民主的圆桌会议,任何一个人发言完毕后,可以指定下一位发言人,而不需要先回到主持人那里。
核心区别: 非对称协程有明确的调用栈关系,而对称协程的控制流更像一个协作式的状态机。
有栈协程与无栈协程先说说什么是栈帧,什么是调用栈:
调用栈(Call Stack):管理函数调用顺序的"任务列表"
特性维度 调用栈 (Call Stack) 栈帧 (Stack Frame) 角色定位 管理函数调用顺序的"任务列表" 存储单次函数调用详细信息的"工作区" 数据结构 后进先出 (LIFO) 的栈结构 一个结构化的内存块,是调用栈的基本单元 核心内容 由一系列栈帧按调用顺序组成 返回地址、函数参数、局部变量、保存的寄存器等 生命周期 随线程创建而创建,随函数调用深入而增长 随函数调用开始而创建,随函数返回结束而销毁 核心功能 保证函数按正确顺序执行和返回,记录调用路径 为函数提供独立的执行环境,隔离不同调用的数据 有栈协程和无栈协程从实现机制 角度进行的分类,决定了协程是如何被"暂停"和"恢复"的。所谓的有栈,无栈并不是说这个协程运行的时候有没有栈,而是说协程之间是否存在调用栈。
无栈协程:
- 也称为"堆叠式协程"或"半协程"。它不需要独立的调用栈来保存协程的上下文信息,协程的上下文都放到公共内存中,当协程被挂起时,无栈协程会将协程的状态保存在堆上的数据结构中,并将控制权交还给调度器。当协程被恢复时,无栈协程会将之前保存的状态从堆中取出,并从上次挂起的地方继续执行。协程切换时,使用状态机来切换,就不用切换对应的上下文了,因为都在堆里的。比有栈协程都要轻量许多。。
- 其实现通常依赖于语言层面的特殊语法 (如
async/await)将一个函数"分割"成多个状态(状态机)。挂起时,只保存必要的局部变量和当前状态点(一个标签或指针),而不是整个调用栈。- 优点: 创建和切换开销极小,内存占用低,可以轻松创建数十万甚至上百万个协程。
- 缺点: 因为无栈不切换调用栈,所以无法做到嵌套多个函数还能像有栈一样切换。通常只能在顶级函数(指直接包含
await表达式的函数本身,它就是这个调用树的根节点)中被挂起,限制较多。Python、JavaScript、C# 的异步函数属于此类。有栈协程:
- 每个协程在创建时都会分配一块独立的内存作为其私有调用栈。这使得它可以像线程一样,在任意嵌套的函数调用深度中被挂起。
- 挂起时,整个栈帧(包括所有局部变量、返回地址等)都被完整保存;恢复时,整个栈被还原。
- 优点: 功能强大,使用起来更像线程,阻塞操作对调用栈深度无要求。
- 缺点: 每个协程的栈内存开销较大(通常为 KB 甚至 MB 级别),能同时存在的协程数量受内存限制。Go 的 goroutine 是典型的有栈协程。
核心区别: 有栈和无栈的区分是看能不能任意的保存并切换嵌套函数,因为无栈不切换调用栈,所以无法做到嵌套多个函数还能像有栈一样切换。
独立栈和共享栈这个分类主要针对有栈协程 ,是从内存使用策略角度进行的优化。
独立栈:
- 每个协程在创建时即分配一个固定大小的、独立的栈空间。
- 优点: 实现简单,运行效率高。
- 缺点: 如果栈空间分配过大,会造成内存浪费;如果分配过小,则有栈溢出的风险。需要在空间和安全之间做权衡。
共享栈:
- 多个协程按需分时复用同一块栈内存。当一个协程被挂起时,其栈内容会被复制(偷走)到一块独立的堆内存中,从而清空共享栈供其他协程使用。当该协程被恢复时,其栈内容再从堆内存复制回共享栈。
- 优点: 极大地节省了内存,因为只有在实际需要时(协程挂起)才会占用额外空间。
- 缺点: 栈内容的"偷取"和"恢复"涉及内存拷贝,会带来一定的性能开销。
核心区别: 独立栈是"空间换简单性",共享栈是"时间换空间"。
3.3 协程的优缺点
优点:
-
高效资源利用 轻量级特性:协程相比线程更加轻量。线程的创建和上下文切换开销较大,而协程复用线程的栈空间,显著降低了资源消耗。
-
简化异步编程 代码可读性:协程让异步代码具有同步代码的外观,极大提升了可读性和可维护性。例如Python的async/await语法使异步编程更加直观。
补充说明: 同步操作具有确定性(如发送消息必定会被接收),而异步操作则具有不确定性(类似操作系统信号的不可预知性)。
-
非阻塞操作 性能优势:协程支持非阻塞操作,在等待I/O或其他耗时任务时能继续执行其他工作,显著提升程序并发性和响应速度。
-
高并发适用性 大规模并发处理:得益于低开销特性,协程特别适合处理大量并发任务,如网络请求和高频I/O操作。
缺点:
-
调试复杂度 问题追踪:协程的异步和延迟执行特性使得调试比同步代码更困难,特别是在多协程交互场景中。
-
管理复杂性 调度需求:高级功能(如协程调度和优先级管理)需要额外框架支持,增加了系统复杂度。
-
状态管理挑战 共享状态难题:协程间共享状态需要精心设计,以避免数据竞争和一致性问题。
-
多核利用限制 核心限制:线程才是系统调度的基本单位。单线程下的多协程本质是串行执行,无法利用多核计算资源,通常需要结合多线程/多进程使用。
深入理解: 现代操作系统中,线程调度由OS直接管理,可分配到不同CPU核心实现并行。而协程由应用层控制,其执行依赖于所在线程:
- 操作系统负责线程的实际调度和核心分配
- 应用只能逻辑控制线程生命周期
- 协程依附于线程,其多核利用率取决于线程数量 单线程环境下,即使存在多个协程也无法利用多核资源。
3.4 协程的实现方式
协程实现的核心就是协程切换时的上下文保存与恢复,实现上下文切换的方法有多种,如使用汇编代码来切换上下文、C标准库提供的setjmp/longjmp也能实现上下文切换、类似Protothreads库使用switch-case的巧妙方式实现轻量级协程、还有利用glibc的ucontext组件来实现,我们的项目使用ucontext组件来实现。
ucontext组件
什么是ucontext?
ucontext是glibc提供的一组用于创建、保存和切换用户态执行上下文的API。它定义了一套完整的接口,让程序能够捕获当前执行状态(上下文),并在适当的时候恢复或切换到另一个执行状态。
在计算机科学中,"上下文"指的是当前CPU寄存器、栈指针、程序计数器、信号掩码等信息的总和。ucontext可以保存、切换和恢复这些上下文,从而实现协程的功能。
ucontext_t结构体是这套机制的核心,它至少包含以下字段:
cpptypedef struct ucontext { struct ucontext *uc_link; // 指向当前上下文结束时切换的上下文 sigset_t uc_sigmask; // 信号屏蔽字 stack_t uc_stack; // 栈信息 mcontext_t uc_mcontext; // 具体的CPU寄存器上下文 // ... } ucontext_t;每个字段都有特定用途:
uc_link定义了当前上下文结束后的后继上下文,uc_sigmask指定了上下文的信号掩码,uc_stack是该上下文使用的栈空间,而uc_mcontext则保存了具体的寄存器状态。ucontext的核心函数
ucontext组件提供了四个关键函数,它们共同协作完成上下文的管理工作:
- getcontext() - 获取当前上下文
int getcontext(ucontext_t *ucp)
此函数初始化ucp结构体,将当前的上下文信息保存到ucp中。如果执行成功,返回0;失败则返回-1。获取的上下文可以看作程序执行状态的一个快照,为后续的恢复或切换奠定基础。- setcontext() - 恢复指定上下文
int setcontext(const ucontext_t *ucp)
此函数设置当前上下文为ucp,从ucp保存的执行状态恢复运行。如果ucp执行完了,会恢复到uc_link指向的上下文;如果uc_link为NULL,则线程退出。成功时此函数不返回,而是跳转到ucp保存的上下文继续执行。- makecontext() - 创建新上下文
void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...)
此函数修改通过getcontext取得的上下文ucp,将其与指定的函数func绑定。在调用makecontext前,需要手动为ucp分配栈空间并设置uc_stack字段。还可以指定uc_link,即函数执行完毕后应恢复的上下文。
当该上下文被激活时,会执行func函数,argc指定参数个数,后面是具体的参数序列。- swapcontext() - 切换上下文
int swapcontext(ucontext_t *oucp, ucontext_t *ucp)
此函数保存当前上下文到oucp,同时切换到ucp对应的上下文。它是实现协程切换的关键,提供了原子性的保存--恢复操作,避免了先保存当前上下文再切换新上下文之间的竞争条件。ucontext的工作原理
要理解ucontext的工作原理,我们需要了解它如何保存和恢复执行状态。
当调用getcontext或swapcontext时,系统会将当前的寄存器状态(包括通用寄存器、栈指针、程序计数器等)保存到ucontext_t结构体的uc_mcontext字段中。同时,还会保存信号掩码和栈信息。
在x86-64架构下,getcontext的汇编实现大致如下:
cppENTRY(__getcontext) /* 保存保留寄存器和参数寄存器 */ movq %rbx, oRBX(%rdi) movq %rbp, oRBP(%rdi) movq %r12, oR12(%rdi) movq %r13, oR13(%rdi) movq %r14, oR14(%rdi) movq %r15, oR15(%rdi) movq %rdi, oRDI(%rdi) movq %rsi, oRSI(%rdi) /* 保存指令指针(返回地址) */ movq (%rsp), %rcx movq %rcx, oRIP(%rdi) /* 保存栈指针 */ leaq 8(%rsp), %rcx movq %rcx, oRSP(%rdi) /* 其他处理... */当调用setcontext或swapcontext恢复上下文时,过程正好相反:保存的寄存器值被重新加载到CPU中,程序执行点跳转回之前保存的位置。
ucontext实现简单协程例子
cpp#include <stdio.h> #include <ucontext.h> #include <unistd.h> int main(int argc, const char *argv[]) { ucontext_t context; // 获取当前上下文 getcontext(&context); puts("Hello world"); sleep(1); // 恢复到之前保存的上下文,形成循环 setcontext(&context); return 0; }上述代码会不断输出"Hello world",因为它每次执行到setcontext时都会跳转回getcontext后的位置。
cpp#include <stdio.h> #include <ucontext.h> ucontext_t main_context, context1, context2; void func1() { printf("协程1执行\n"); sleep(1); // 切换到协程2 swapcontext(&context1, &context2); printf("协程1恢复\n"); sleep(1); // 切换回主上下文 swapcontext(&context1, &main_context); } void func2() { printf("协程2执行\n"); sleep(1); // 切换回协程1 swapcontext(&context2, &context1); } int main() { char stack1[64 * 1024]; char stack2[64 * 1024]; // 初始化上下文1 getcontext(&context1); context1.uc_link = &main_context; context1.uc_stack.ss_sp = stack1; context1.uc_stack.ss_size = sizeof(stack1); makecontext(&context1, func1, 0); // 初始化上下文2 getcontext(&context2); context2.uc_link = &main_context; context2.uc_stack.ss_sp = stack2; context2.uc_stack.ss_size = sizeof(stack2); makecontext(&context2, func2, 0); // 切换到第一个协程 printf("主函数切换到协程1\n"); swapcontext(&main_context, &context1); printf("主函数恢复执行\n"); return 0; }这个例子展示了两个协程之间以及协程与主函数之间的切换。每个协程都有自己的栈空间,通过swapcontext实现主动让出CPU。
4、实现一个协程库
参考下一篇文章
【项目--协程库(C++)模块解析篇 - CSDN App】https://blog.csdn.net/cookies_s_/article/details/155192727?sharetype=blog\&shareId=155192727\&sharerefer=APP\&sharesource=cookies_s_\&sharefrom=link