持续创作,加速成长!这是我参与「掘金日新计划 · 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切换了就相当于换了执行单元的上下文环境。这一节课所有的讲解都可以归到这条线索上。
栈往往和执行单元是一对一的关系,栈的活跃就代表着它所对应的执行单元的活跃。栈上的数据非常敏感,一旦被攻击,往往会造成巨大的破坏。