内存学习:深入理解进程和协程

📅 2026/7/5 10:39:58
内存学习:深入理解进程和协程
引言之前我们了解到函数在执行的时候就会在栈上创建栈帧那么函数执行的上下文都将保存在栈帧里。今天我们就再来进一步分析栈切换在计算机系统设计中所发挥的重要作用。几乎所有的程序员都会遇到并发程序。因为多进程或者多线程程序可以并发执行充分利用多 CPU 多核的计算资源来完成任务会大大提升应用程序的性能。所以我相信你在工作中也遇到过多线程程序但不知道你是否考虑过进程和线程是如何切换的呢很多文章都介绍了操作系统为了避免频繁进入内核态会把很多工作都尽量放在用户态。那么你有没有仔细思考过内核态、用户态到底意味着什么呢要回答上面的问题我们就要理解这些概念背后最重要的一个步骤对执行单元的上下文环境进行切换。它就是由栈这个核心数据结构支撑的这也是我们今天学习的重点内容。通过今天的学习你将掌握协程的基本知识这样你在 C 中使用各种协程库或者在 Lua、Go 等语言中使用原生协程的时候就能理解它们背后发生了什么也可以帮你写出正确的 IO 程序。你还将深入理解操作系统用户态和内核态这样你在做架构的时候就能正确评估操作系统进入内核态的开销是多少。在讲解执行单元的切换与栈的关系之前我们先来给出它的准确定义。什么是执行单元执行单元是指 CPU 调度和分派的基本单位它是一个 CPU 能正常运行的基本单元。执行单元是可以停下来的只要能把 CPU 状态其实就是寄存器的值全部保存起来等到这个执行单元再被调度的时候就把状态恢复过来就行了。我们把这种保存状态挂起恢复执行恢复状态的完整过程称为执行单元的调度 (Scheduling)。具体来说常见的执行单元有进程线程和协程三种接下来我们详细说明这三种执行单元的区别和联系。我们先来比较进程和线程。理解进程和线程当运行一个可执行程序的时候操作系统就会启动一个进程。进程会被操作系统管理和调度被调度到的进程就可以独占 CPU 了。CPU 就像是一个可以轮流使用的工作台多个进程可以在工作台上工作时间到了就会带着自己的工作离开工作台换下一个进程上来工作。进程有自己独立的内存空间和页表以及文件表等等各种私有资源如果使用多进程系统让多个任务并发执行那么它所占用的资源就会比较多。线程的出现解决了这个问题。同一个进程中的线程则共享该进程的内存空间文件表文件描述符等资源它与同一个进程的其他线程共享资源分配。除了共享的资源每个线程也有自己的私有空间这就是线程的栈。线程在执行函数调用的时候会在自己的线程栈里创建函数栈帧。根据上面所说的特点人们常把进程看做是资源分配的单位把线程才看成一个具体的执行实体。由于线程的切换过程和进程的切换过程十分相似我们这节课就只以进程的切换为重点进行讲解请你一定要自己查找相关资料对照进程切换的过程去理解线程的切换过程。理解协程协程是比线程更轻量的执行单元。进程和线程的调度是由操作系统负责的而协程则是由执行单元相互协商进行调度的所以它的切换发生在用户态。只有前一个协程主动地执行 yield 函数让出 CPU 的使用权下一个协程才能得到调度。因为程序自己负责协程的调度所以大多数时候我们可以让不那么忙的协程少参与调度从而提升整个程序的吞吐量而不是像进程那样没有繁重任务的进程也有可能被换进来执行。协程的切换和调度所耗费的资源是最少的Go 语言把协程和 IO 多路复用结合在一起提供了非常便捷的 IO 接口使得协程的概念深入人心。从操作系统和 Web Server 演进的历史来看先是多进程系统的出现然后出现了多线程系统最后才是协程被大规模使用这个演进历程背后的逻辑就是执行单元需要越来越轻量以支持更大的并发总数。但我们这节课却要先讲协程这是因为从实现层面来说协程是最简单的当你理解了协程的实现原理再回头学习进程就比较容易了所以我们先来学习协程的原理。协程是怎么调度和切换的在讲解协程的理论之前我们先通过一个最简单的协程的例子来观察协程的运作机制#include stdio.h #include stdlib.h #define STACK_SIZE 1024 typedef void(*coro_start)(); class coroutine { public: long* stack_pointer; char* stack; coroutine(coro_start entry) { if (entry NULL) { stack NULL; stack_pointer NULL; return; } stack (char*)malloc(STACK_SIZE); char* base stack STACK_SIZE; stack_pointer (long*) base; stack_pointer - 1; *stack_pointer (long) entry; stack_pointer - 1; *stack_pointer (long) base; } ~coroutine() { if (!stack) return; free(stack); stack NULL; } }; coroutine* co_a, * co_b; void yield_to(coroutine* old_co, coroutine* co) { __asm__ ( movq %%rsp, %0\n\t movq %%rax, %%rsp\n\t :m(old_co-stack_pointer):a(co-stack_pointer):); } void start_b() { printf(B); yield_to(co_b, co_a); printf(D); yield_to(co_b, co_a); } int main() { printf(A); co_b new coroutine(start_b); co_a new coroutine(NULL); yield_to(co_a, co_b); printf(C); yield_to(co_a, co_b); printf(E\n); delete co_a; delete co_b; return 0; }我们使用 g 对这段代码进行编译注意要使用 O0 进行编译不能使用更高的优化级别这是因为更高级别的优化会内联 yield_to 方法这就使得栈的布局和程序中期望的不相符了。我们先来看看这段代码的运行的结果如下所示# g -g -o co -O0 coroutine.cpp # ./co ABCDE这段代码的神奇之处在于main 函数在执行到一半的时候可以停下来去执行 start_b 函数这和我们通常遇到的函数调用是很不一样的。而这种效果是通过协程达到的。你可以看到在 main 函数的执行过程中即代码的 57 行CPU 通过执行 yield_to 方法转到另外一个协程。新的协程的入口函数是 start_b所以CPU 就转而去执行 start_b在 start_b 执行到 48 行的时候还能再通过 yield_to再回到 main 函数中继续执行。下面我们来看协程是怎么实现这一点的。我们调用构造函数 coroutine 创建了两个协程 co_a 和 co_b即代码的 55、56 行。其中co_b 的入口地址是函数 start_bco_a 没有入口地址。我们具体来看在 coroutine 里发生了什么。其实在创建这两个协程之前coroutine 已经申请了一段大小为 1K 的内存作为协程栈然后让栈底指针 base 指向栈的底部第 21 行。因为栈是由上向下增长的所以我们又在协程栈上放入了 base 地址和起始地址第 23~27 行此时协程栈内的数据是这样的如图 1 所示在准备好协程栈以后就可以调用 yield_to 方法进行协程的切换。在上一节中我们提到过协程要主动调用 yield 方法将 CPU 的占有权让出来后面的协程才能执行。所以协程切换的关键机制就肯定隐藏在 yield_to 方法里。yield_to 方法具体做了什么事情呢我们需要通过机器码来进行说明。这里我们使用objdump -d命令查看 yield_to 方法经过编译以后的机器码000000000040076d _Z8yield_toP9coroutineS0_: 40076d: 55 push %rbp 40076e: 48 89 e5 mov %rsp,%rbp 400771: 48 89 7d f8 mov %rdi,-0x8(%rbp) 400775: 48 89 75 f0 mov %rsi,-0x10(%rbp) 400779: 48 8b 45 f0 mov -0x10(%rbp),%rax 40077d: 48 8b 00 mov (%rax),%rax 400780: 48 8b 55 f8 mov -0x8(%rbp),%rdx 400784: 48 89 22 mov %rsp,(%rdx) 400787: 48 89 c4 mov %rax,%rsp 40078a: 5d pop %rbp 40078b: c3 retqyield_to 中参数 old_co 指向老协程co 则指向新的协程也就是我们要切换过去执行的目标协程。这段代码的作用是首先把当前 rsp 寄存器的值存储到 old_co 的 stack_pointer 属性第 9 行并且把新的协程的 stack_pointer 属性更新到 rsp 寄存器第 10 行然后retq 指令将会从栈上取出调用者的地址并跳转回调用者继续执行第 12 行这是上一节课的内容如果你不熟悉可以再自行复习一下。结合以上分析我们可以想象在协程示例代码的第 57 行当调用这一次 yield_to 时rsp 寄存器刚好就会指向新的协程 co 的栈接着就会执行pop rbp和retq这两条指令。这里你需要注意一下栈的切换并没有改变指令的执行顺序因为栈指针存储在 rsp 寄存器中当前执行到的指令存储在 IP 寄存器中rsp 的切换并不会导致 IP 寄存器发生变化。而显然如图 1 所示我们刚才精心准备的 base 地址正好就是为了pop rbp准备的而 start_b 则是为了 retq 准备的。执行这次 retqCPU 就会跳转到 start_b 函数中去运行了。经过这种切换系统中会出现两个栈如图 2 所示当程序继续执行时在 start_b 中调用了 yield_toCPU 又会转移回协程 a 的栈上这样在执行 retq 时就会返回到 main 函数里继续运行了。在这个过程中我们并没有使用任何操作系统的系统调用就实现了控制流的转移。也就是说在同一个线程中我们真正实现了两个执行单元。这两个执行单元并不像线程那样是抢占式地运行而是相互主动协作式执行所以这样的执行单元就是协程。我们可以看到协程的切换全靠本执行单元主动调用 yield_to 来把执行权让渡给其他协程。每个协程都拥有自己的寄存器上下文和栈。协程调度切换时将寄存器上下文和栈保存到其他地方上述例子中保存在 coroutine 对象中在切回来的时候恢复先前保存的寄存器上下文和栈。分析到这里这个程序对我们而言已经没有太多秘密了它所有看上去神奇的地方不过就是切换了程序运行的栈指针而已。分析到这里我们就可以准确地定义协程了。协程是一种轻量级的用户态的执行单元。相比线程它占用的内存非常少在很多实现中比如 Go 语言甚至可以做到按需分配栈空间。它主要有三个特点占用的资源更少 ;所有的切换和调度都发生在用户态。它的调度是协商式的而不是抢占式的。前两个特点容易理解我来给你重点解释一下第三个特点。目前主流语言基本上都选择了多线程作为并发设施与线程相关的概念是抢占式多任务Preemptive multitasking而与协程相关的是协作式多任务。不管是进程还是线程每次阻塞、切换都需要陷入系统调用 (system call)先让 CPU 执行操作系统的调度程序然后再由调度程序决定该哪一个进程 (线程) 继续执行。由于抢占式调度执行顺序无法确定我们使用线程时需要非常小心地处理同步问题而协程完全不存在这个问题。因为协作式的任务调度是要用户自己来负责任务的让出的。如果一个任务不主动让出其他任务就不会得到调度。这是协程的一个弱点但是如果使用得当这其实是一个可以变得很强大的优点。你可以尝试将编译优化等级设为 O1观察 yield_to 函数的机器码的变化然后就可以理解当栈基址寄存器的保存和恢复如果被优化掉以后我们准备的那个数据就不再起作用了。也请你尝试对上述代码进行修改以适应 O1 优化。在理解了协程以后我们再回过头来看进程。进程是怎么调度和切换的进程切换的原理其实与协程切换的原理大致相同都是将上下文保存在特定的位置切换到新的进程去执行。所不同的是操作系统为我们提供了进程的创建、销毁、信号通信等基础设施这使得程序员可以很方便地创建进程。如果一个进程 a 创建了另外一个进程 b则称 a 为父进程b 为子进程。我先带你通过下面这个例子直观地感受多进程运行的情况#include unistd.h #include stdio.h int main() { pid_t pid; if (!(pid fork())) { printf(I am child process\n); exit(0); } else { printf(I am father process\n); wait(pid); } return 0; }编译执行这段代码的结果如下所示# gcc -o p process.c # ./p I am father process I am child process在这个结果里我们可以看到在 if 分支和 else 分支中的代码都被运行了。曾经有个笑话说这个世界上最远的距离不是你在天涯我在海角而是你在 if 里我在 else 里。由此可见这个笑话也并不正确还是要看 if 条件里填的是什么。在上面的代码中fork 是一个系统调用用于创建进程如果其返回值为 0则代表当前进程是子进程如果其返回值不为 0则代表当前进程是父进程而这个返回值就是子进程的进程 ID。我们看到子进程在打印完一行语句后就调用 exit 退出执行了。父进程在打印完以后并没有立即退出而是调用 wait 函数等待子进程退出。由于进程的调度执行是操作系统负责的具有很大的随机性所以父进程和子进程谁先退出我们并不能确定。为了避免子进程变成孤儿进程我们采用了让父进程等待子进程退出的办法就是对两个进程进行同步。其实这段程序最难理解的是第 6 行为什么一次 fork 后会有两种不同的返回值这是因为 fork 方法本质上在系统里创建了两个栈这两个栈一个是父进程的一个是子进程的。创建的时候子进程完全“继承”了父进程的所有数据包括栈上的数据。父子进程栈的情况如图 3 所示在图 3 里只要有一个进程对栈进行修改栈就会复制一份然后父子进程各自持有一份。图中的黄色部分也是进程共用的如果有一个进程修改它也会复制一份副本这种机制叫做写时复制。接着操作系统就会接管两个进程的调度。当父进程得到调度时父进程的栈上是 fork 函数的 frame当 CPU 执行 fork 的 ret 语句时返回值就是子进程的 ID。而当子进程得到调度时rsp 这个栈指针就将会指向子进程的栈子进程的栈上也同样是 fork 函数的 frame它也会执行一次 fork 的 ret 语句其返回值是 0。所以第 6 行虽然是同一个变量 pid但实际上它在子进程的 main 函数的栈帧里有一个副本在父进程的栈帧里也有一个副本。从 fork 开始父进程和子进程就已经分道扬镳了。你可以将进程栈的切换与协程栈的切换对比着进行学习。我们通过一个例子展示了进程是如何创建的并且分析了进程创建背后栈的变化过程。你可以看到进程做为一种执行单元它的切换还是要依赖于栈切换这个核心机制。关于 fork 的更多的细节我们将在第 10 课再加以分析。在这节课将进程的栈类比于协程栈已经足够了。用户态和内核态是怎么切换的在第二节课里我们讲解了中断描述符表并且用系统调用 write 这个例子来展示如何通过软中断进入内核态。实际上内核态和用户态的切换也依赖栈的切换。因为在第二节课里我们还没有讲到栈所以在讲到用户态切换内核态的时候并没有涉及到栈的切换现在我们补上用户态和内核态切换的最后一块拼图。操作系统内核在运行的时候肯定也是需要栈的这个栈称为内核栈它与用户应用程序使用的用户态栈是不同的。只有高权限的内核代码才能访问它。而内核态与用户态的相互切换其中最重要的一个步骤就是两个栈的切换。中断发生时CPU 根据需要跳转的特权级去一个特定的结构中不同的 CPU 会有所不同比如 i386 就存在 TSS 中但不管是什么 CPU一定会有一个类似的结构取得目标特权级所对应的 stack 段选择子和栈顶指针并分别送入 ss 寄存器和 rsp 寄存器这就完成了一次栈的切换。然后IP 寄存器跳入中断服务程序开始执行中断服务程序会把当前 CPU 中的所有寄存器也就是程序的上下文都保存到栈上这就意味着用户态的 CPU 状态其实是由中断服务程序在系统栈上进行维护的。如图 4 所示一般来说当程序因为 call 指令或者 int 指令进行跳转的时候只需要把下一条指令的地址放到栈上供被调用者执行 ret 指令使用这样可以便于返回到调用函数中继续执行。但图 4 中的内核态栈里有一点特殊之处就是 CPU 自动地将用户态栈的段选择子 ss3和栈顶指针 rsp3 都放到内核态栈里了。这里的数字 3 代表了 CPU 特权级内核态是 0用户态是 3。当中断结束时中断服务程序会从内核栈里将 CPU 寄存器的值全部恢复最后再执行iret指令注意不是 ret而是 iret这表示是从中断服务程序中返回。而 iret 指令就会将 ss3/rsp3 都弹出栈并且将这个值分别送到 ss 和 rsp 寄存器中。这样就完成了从内核栈到用户栈的一次切换。同时内核栈的 ss0 和 rsp0 也会被保存到前文所说的一个特定的结构中以供下次切换时使用。总结这节课我们举例说明了进程线程和协程的基本概念并对它们的调度做了简单的说明。然后介绍了服务端编程模型从多进程向协程演进的历程。接着我们重点介绍了栈切换的整个过程。栈切换的核心就是栈指针 rsp 寄存器的切换只要我们想办法把 rsp 切换了就相当于换了执行单元的上下文环境。这一节课所有的讲解都可以归到这条线索上。我们又用了协程切换进程栈的写时复制和切换以及用户态和内核态的切换这三个例子来说明举例说明栈的切换所发挥的重要作用。通过两节课的学习我们对进程中的栈空间相关的知识进行一次比较深入的梳理。从中我们可以得到一个结论栈往往和执行单元是一对一的关系栈的活跃就代表着它所对应的执行单元的活跃。栈上的数据非常敏感一旦被攻击往往会造成巨大的破坏。在第三节课里我们学习了堆空间的管理方式这两节课又学习了栈空间的运行机制这两部分内容都是程序运行时所要操作的内存。在这之后我们将目光转移到程序的汇编代码研究一下程序的静态数据是如何组织和划分的。