编程高手必学的内存知识

05 | 栈的魔法:从栈切换的角度理解进程和协程

海纳

华为编译器高级专家,原Huawei JDK团队负责人

你好,我是海纳。

上一节课,我们了解到函数在执行的时候,就会在栈上创建栈帧,那么函数执行的上下文都将保存在栈帧里。今天,我们就再来进一步分析,栈切换在计算机系统设计中所发挥的重要作用。

几乎所有的程序员都会遇到并发程序。因为多进程或者多线程程序可以并发执行,充分利用多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_b,co_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                      retq

yield_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准备的。执行这次retq,CPU就会跳转到start_b函数中去运行了。

经过这种切换,系统中会出现两个栈,如图2所示:

当程序继续执行时,在start_b中调用了yield_to,CPU又会转移回协程a的栈上,这样在执行retq时,就会返回到main函数里继续运行了。

在这个过程中,我们并没有使用任何操作系统的系统调用,就实现了控制流的转移。也就是说,在同一个线程中,我们真正实现了两个执行单元。这两个执行单元并不像线程那样是抢占式地运行,而是相互主动协作式执行,所以,这样的执行单元就是协程。我们可以看到,协程的切换全靠本执行单元主动调用yield_to来把执行权让渡给其他协程。

每个协程都拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方(上述例子中,保存在coroutine对象中),在切回来的时候,恢复先前保存的寄存器上下文和栈。

分析到这里,这个程序对我们而言,已经没有太多秘密了,它所有看上去神奇的地方,不过就是切换了程序运行的栈指针而已。

分析到这里,我们就可以准确地定义协程了。协程是一种轻量级的,用户态的执行单元。相比线程,它占用的内存非常少,在很多实现中(比如Go语言)甚至可以做到按需分配栈空间。

它主要有三个特点:

  1. 占用的资源更少;
  2. 所有的切换和调度都发生在用户态。
  3. 它的调度是协商式的,而不是抢占式的。

前两个特点容易理解,我来给你重点解释一下第三个特点。

目前主流语言基本上都选择了多线程作为并发设施,与线程相关的概念是抢占式多任务(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切换了就相当于换了执行单元的上下文环境。这一节课所有的讲解都可以归到这条线索上。

我们又用了协程切换,进程栈的写时复制和切换,以及用户态和内核态的切换这三个例子来说明举例说明栈的切换所发挥的重要作用。

通过两节课的学习,我们对进程中的栈空间相关的知识进行一次比较深入的梳理。从中我们可以得到一个结论:栈往往和执行单元是一对一的关系,栈的活跃就代表着它所对应的执行单元的活跃。栈上的数据非常敏感,一旦被攻击,往往会造成巨大的破坏。

在第三节课里,我们学习了堆空间的管理方式,这两节课又学习了栈空间的运行机制,这两部分内容都是程序运行时所要操作的内存。在这之后,我们将目光转移到程序的汇编代码,研究一下程序的静态数据是如何组织和划分的。

思考题

我们这节课讲到了协程和进程的栈切换,但没有讲线程的栈切换。请你思考,线程的栈切换是更类似协程那种提前创建好的方式,还是更类似于进程那种按需写时复制?为什么?欢迎你在留言区和我交流你的想法,我在留言区等你。

欢迎你在留言区分享你的想法和收获,我在留言区等你。如果这节课帮到了你,也欢迎你把这节课分享给自己的朋友。我们下一讲再见!

教程推荐

Flutter在线教程

F#在线教程

Redis在线教程

Django在线教程

C++在线教程

SEO教程在线教程

随机推荐

轻上西梅饮膳食纤维植物果蔬汁益生菌元风味饮料点评怎么样?详细...

狮王小狮王儿童氟防蛀牙膏 20g评测好不好用?来看看图文评测!

卡诗元气姜粉瓶生姜洗发水250ml是否值得入手?买家评测分享?

滋源 无患子控油洗头水 200ml评测结果好吗?最新口碑评测反馈?

LAIMANUS洗衣粉莱曼斯香味皂粉洗衣粉5kg适合入手吗?用户使用感...

LAIMANUS洗衣粉莱曼斯香味皂粉洗衣粉5kg适合入手吗?网友评测点...