从根上理解进程和协程

492 阅读13分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第25天,点击查看活动详情

函数执行时,就会在栈创建栈帧,函数执行的上下文都将保存在栈帧。

进程和线程是如何切换的?os为避免频繁进入内核态,会把很多工作都尽量放在用户态。内核态、用户态到底意味着什么?

对执行单元的上下文环境进行切换,是由栈支撑。

  • 掌握协程基本

    在C++中使用各种协程库,或者在Lua、Go等语言中使用原生协程的时候,就能理解它们背后发生了什么,也可以帮你写出正确的IO程序

  • 深入理解操作系统用户态和内核态,架构时,就能正确评估os进入内核态的开销。

1 执行单元

即CPU调度、分派的基本单位,是一个CPU正常运行的基本单元。执行单元可以停下来,只要能把CPU状态(即寄存器的值)全部保存,等到这执行单元再被调度时,就把状态恢复。

保存状态,挂起,恢复执行,恢复状态的完整过程,即执行单元的调度(Scheduling)。

常见执行单元有进程,线程和协程。

2 进程和线程

当运行一个可执行程序时,os就会启动一个进程。进程会被os管理、调度,被调度到的进程就可独占CPU。

CPU是个可轮用的工作台,多个进程可在工作台工作,时间到了就会带着自己的工作离开工作台,换下一个进程上来。

进程有自己独立的内存空间和页表及文件表等私有资源,使用多进程系统,让多个任务并发执行,则它所占用资源就会较多。线程完美解决了这问题。

同一个进程中的线程共享该进程的内存空间,文件表,文件描述符等资源,它与同进程的其他线程共享资源分配。每个线程也有私有空间,即线程的栈。线程在执行函数调用时,会在自己的线程栈里创建函数栈帧。

进程看做资源分配的单位,线程是具体的执行实体。

由于线程切换和进程切换过程相似,只以进程切换讲解。

3 协程

比线程更轻量的执行单元。进程、线程调度由os负责,协程则由执行单元相互协商进行调度,其切换发生在用户态。只有前一个协程主动执行yield函数,让出CPU使用权,下一个协程才能得到调度。

因为程序自己负责协程调度,可让不忙的协程少参与调度,提升整个程序的吞吐量,而非像进程,没有繁重任务的进程,也可能被换进来执行。

协程切换、调度所耗资源最少,Go语言把协程和IO多路复用结合,提供便捷的IO接口,使协程深入人心。

os和Web Server演进历史,先是多进程系统,然后多线程系统,最后才是协程大规模使用,即执行单元越来越轻量,支持更大并发总数。

4 协程的调度和切换

4.1 案例

#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函数中继续执行。

5 协程是怎么实现这点

coroutine里发生了什么。创建这两个协程前,coroutine已申请一段1K内存作为协程栈,然后让栈底指针base指向栈的底部。因为栈是由上向下增长,又在协程栈放入base、起始地址(第23~27行),此时协程栈内数据:

准备好协程栈,就能调用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寄存器发生变化。

base地址正是为 "pop rbp"准备,start_b为retq准备。执行这次retq,CPU就会跳转到start_b函数运行。

经过这种切换,系统中会出现两个栈:

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

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

每个协程都拥有自己的:

  • 寄存器上下文

协程调度切换时,将寄存器上下文和栈保存到其他地方(如保存在coroutine对象),切回来时,恢复先前保存的寄存器上下文和栈。

所以,该程序不过是切换了程序运行的栈指针。

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

主要特点

  • 占用资源更少
  • 所有的切换和调度都发生在用户态
  • 调度是协商式,而非抢占式

语言基本选择多线程作为并发:

  • 线程相关概念是抢占式多任务(Preemptive multitasking)
  • 协程相关的是协作式多任务

不管是进程or线程,每次阻塞、切换都需陷入系统调用(system call),先让CPU执行os的调度程序,然后再由调度程序决定该哪个进程(线程)继续执行。

由于抢占式调度执行顺序无法确定,使用线程需小心处理同步问题,而协程完全不存在这问题。因为协作式任务调度,要用户自己负责任务让出。若一个任务不主动让出,其他任务就不会得到调度。这是协程的一个弱点,但若使用得当,这是个强大优点。

可尝试将编译优化等级设为O1,观察yield_to机器码变化,然后就可理解当栈基址寄存器的保存和恢复如果被优化掉以后,我们准备的那个数据就不再起作用了。也请你尝试对上述代码进行修改,以适应O1优化。

6 进程是怎么调度和切换的?

与协程切换原理类似,都将上下文保存在特定位置,切换到新进程执行。

不同在于,os提供进程的创建、销毁、信号通信等,使程序员很方便创建进程。若一个进程a创建另外一个进程b,则称a父进程,b子进程。

6.1 多进程案例

#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分支的代码都被运行了。fork是系统调用,创建进程:

  • 返回值为0,则代表当前进程是子进程
  • 返回值不为0,则代表当前进程是父进程

返回值就是子进程的进程ID。

子进程在打印完一行语句后就调用exit退出执行。父进程打印完后,并未立即退出,而是调用wait等待子进程退出。由于进程调度执行是os负责,具有很大随机性,所以父、子进程谁先退出,不能确定。为避免子进程变成孤儿进程,采用让父进程等待子进程退出,即对两个进程进行同步。

为何一次fork后,有两种不同返回值?fork本质在系统里创建两个栈:

  • 一个是父进程的
  • 一个是子进程的

创建时,子进程完全“继承”父进程的所有数据,包括栈上数据。父子进程栈如下:

在图3里,只要有一个进程对栈进行修改,栈就会复制一份,然后父子进程各自持有一份。图中的黄色部分也是进程共用的,如果有一个进程修改它,也会复制一份副本,这种机制叫做写时复制。

接着,操作系统就会接管两个进程的调度。当父进程得到调度时,父进程的栈上是fork函数的frame,当CPU执行fork的ret语句时,返回值就是子进程的ID。

而当子进程得到调度时,rsp这个栈指针就将会指向子进程的栈,子进程的栈上也同样是fork函数的frame,它也会执行一次fork的ret语句,其返回值是0。

所以第6行虽然是同一个变量pid,但实际上,它在子进程的main函数的栈帧里有一个副本,在父进程的栈帧里也有一个副本。从fork开始,父进程和子进程就已经分道扬镳了。你可以将进程栈的切换与协程栈的切换对比着进行学习。

我们通过一个例子展示了进程是如何创建的,并且分析了进程创建背后栈的变化过程。你可以看到,进程做为一种执行单元,它的切换还是要依赖于栈切换这个核心机制。

关于fork的更多的细节,我们将在第10课再加以分析。在这节课,将进程的栈类比于协程栈已经足够了。

7 用户态和内核态的切换

内核态、用户态切换依赖栈切换。

os内核运行时,也需要栈,即内核栈,与用户应用程序使用的用户态栈不同。只有高权限的内核代码才能访问。而内核态、用户态相互切换,最重要的就是两个栈的切换。

中断发生时,CPU根据需要跳转的特权级,去一个特定结构(不同CPU有所不同,如i386存在TSS,但不管啥CPU,定有个类似结构),取得目标特权级所对应的stack段选择子和栈顶指针,并分别送入ss寄存器和rsp寄存器,即完成一次栈切换。

然后,IP寄存器跳入中断服务程序开始执行,中断服务程序会把当前CPU中的所有寄存器,也就是程序的上下文都保存到栈上,这就意味着用户态的CPU状态其实是由中断服务程序在系统栈上进行维护:

程序因call指令或int指令跳转时,只需把下一条指令的地址放到栈,供被调用者执行ret指令使用,这可便于返回到调用函数中继续执行。但图4内核态栈有点特殊,CPU自动将用户态栈的段选择子ss3,和栈顶指针rsp3都放到内核态栈。这里的数字3代表了CPU特权级,内核态是0,用户态是3。

当中断结束时,中断服务程序会从内核栈里将CPU寄存器的值全部恢复,最后再执行"iret"指令(注意不是ret,而是iret,这表示是从中断服务程序中返回)。而iret指令就会将ss3/rsp3都弹出栈,并且将这个值分别送到ss和rsp寄存器中。这样就完成了从内核栈到用户栈的一次切换。同时,内核栈的ss0和rsp0也会被保存到前文所说的一个特定的结构中,以供下次切换时使用。

8 总结

栈切换的核心就是栈指针rsp寄存器的切换,只要我们想办法把rsp切换了就相当于换了执行单元的上下文环境。这一节课所有的讲解都可以归到这条线索上。

栈往往和执行单元是一对一的关系,栈的活跃就代表着它所对应的执行单元的活跃。栈上的数据非常敏感,一旦被攻击,往往会造成巨大的破坏。