什么是执行单元

执行单元是指操作系统调度和分派的基本单位,它是一个 CPU 能正常运行的基本单元。执行单元是可以停下来的,只要能把 CPU 状态(其实就是寄存器的值)全部保存起来,等到这个执行单元再被调度的时候,就把状态恢复过来就行了。把这种保存状态,挂起,恢复执行,恢复状态的完整过程,称为执行单元的调度(Scheduling)。

常见的执行单元有进程、线程和协程

理解进程和协程

进程有自己独立的内存空间和页表、打开的文件等等各种私有资源。同一个进程中的线程则共享该进程的内存空间、文件描述符等资源,它与同一个进程的其他线程共享资源分配。除了共享的资源,每个线程也有自己的私有空间,这就是线程的栈。线程在执行函数调用的时候,会在自己的线程栈里创建函数栈帧。
因此,进程可以看作资源分配的基本单位,而线程可以看作具体的执行实体。

理解协程

协程是比线程更轻量的执行单元。进程和线程的调度是由操作系统负责的,而协程是由执行单元相互协商进行调度的(是程序自己负责协程的调度),所以它的切换发生在用户态。只有前一个协程主动地执行 yield 函数,让出 CPU 的使用权,下一个协程才能得到调度。

协程是如何调度和切换的

内联汇编入门

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #define STACK_SIZE 1024
  4. typedef void(*coro_start)();
  5. class coroutine {
  6. public:
  7. long* stack_pointer; // rsp
  8. char* stack; // 从堆上分配内存来模拟栈帧
  9. coroutine(coro_start entry) {
  10. if (entry == NULL) {
  11. stack = NULL;
  12. stack_pointer = NULL;
  13. return;
  14. }
  15. stack = (char*)malloc(STACK_SIZE);
  16. char* base = stack + STACK_SIZE; // 栈基址
  17. stack_pointer = (long*) base;
  18. stack_pointer -= 1;
  19. *stack_pointer = (long) entry; // rip
  20. stack_pointer -= 1; // 若采用 O1 级别优化,需要注释 25-26 行
  21. *stack_pointer = (long) base; // rbp
  22. }
  23. ~coroutine() {
  24. if (!stack)
  25. return;
  26. free(stack);
  27. stack = NULL;
  28. }
  29. };
  30. coroutine* co_a, * co_b;
  31. void yield_to(coroutine* old_co, coroutine* co) {
  32. __asm__ (
  33. "movq %%rsp, %0\n\t"
  34. "movq %%rax, %%rsp\n\t"
  35. :"=m"(old_co->stack_pointer):"a"(co->stack_pointer):);
  36. }
  37. void start_b() {
  38. printf("B");
  39. yield_to(co_b, co_a);
  40. printf("D");
  41. yield_to(co_b, co_a);
  42. }
  43. int main() {
  44. printf("A");
  45. co_b = new coroutine(start_b);
  46. co_a = new coroutine(NULL);
  47. yield_to(co_a, co_b);
  48. printf("C");
  49. yield_to(co_a, co_b);
  50. printf("E\n");
  51. delete co_a;
  52. delete co_b;
  53. return 0;
  54. }
  1. $ g++ -g -o co -O0 coroutine.cpp
  2. $ ./co
  3. ABCDE

上述代码如何实现的协程

通过调用构造函数 coroutine 创建了两个协程 co_a 和 co_b。其中,co_b 的入口地址是函数 start_b,co_a 没有入口地址。co_b 申请了一段大小为 1K 的堆内存作为协程栈,然后让栈底指针 base 指向栈的底部,然后依次在协程栈上放入了 start_b 的函数地址以及 base 地址,如图 1 所示:
image.png
在准备好协程栈以后,就可以调用 yield_to 方法进行协程的切换。使用”objdump -d”命令查看 yield_to 方法经过编译以后的机器码:

  1. 000000000040076d <_Z8yield_toP9coroutineS0_>:
  2. 40076d: 55 push %rbp
  3. 40076e: 48 89 e5 mov %rsp,%rbp
  4. 400771: 48 89 7d f8 mov %rdi,-0x8(%rbp) // 保存 old_co 的指针在栈上
  5. 400775: 48 89 75 f0 mov %rsi,-0x10(%rbp) // 保存 co 的指针在栈上
  6. 400779: 48 8b 45 f0 mov -0x10(%rbp),%rax // co
  7. 40077d: 48 8b 00 mov (%rax),%rax // co->stack_pointer
  8. 400780: 48 8b 55 f8 mov -0x8(%rbp),%rdx // old_co
  9. 400784: 48 89 22 mov %rsp,(%rdx) // 保存 rsp 到 old_co->stack_pointer
  10. 400787: 48 89 c4 mov %rax,%rsp // 改变 rsp 为 co->stack_pointer
  11. 40078a: 5d pop %rbp
  12. 40078b: c3 retq // retq 指令将会从栈上取出 rip,并跳转执行

在代码的 57 行,当调用这一次 yield_to 时,rsp 寄存器刚好就会指向新的协程 co 的栈,接着就会执行”pop rbp”和”retq”这两条指令。栈的切换(rsp 的改变),并没有直接改变指令的执行顺序,因为栈指针存储在 rsp 寄存器中,当前执行到的指令存储在 IP 寄存器中,rsp 的切换并不会导致 IP 寄存器发生变化,只有在”retq”这条指令执行后指令的执行顺序才发生了改变。
经过这样的切换,会出现两个栈,如图二所示:
image.png
当程序继续执行时,在 start_b 中调用了 yield_to,CPU 又会切换回协程 a 的栈上,这样在执行 retq 时,就会返回到 main 函数里继续运行了。在这个过程中,我们并没有使用任何操作系统的系统调用,就实现了控制流的转移,而这种控制流的转换全靠本执行单元主动调用 yield_to 来把执行权让渡给其他协程(协作而非抢占)。
每个协程都拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方(上述例子中,保存在 coroutine 对象中),在切回来的时候,再恢复先前保存的寄存器上下文和栈。
目前主流语言基本上都选择了多线程作为并发设施,与线程相关的概念是抢占式多任务(Preemptive multitasking),而与协程相关的是协作式多任务。不管是进程还是线程,每次阻塞、切换都需要陷入系统调用 (system call),先让 CPU 执行操作系统的调度程序,然后再由调度程序决定该哪一个进程 (线程) 继续执行。由于抢占式调度执行顺序无法确定,在使用线程时需要非常小心地处理同步问题,而协程完全不存在这个问题。因为协作式的任务调度,是要用户自己来负责任务的让出的。

进程是怎样调度和切换的

进程切换的原理其实与协程切换的原理大致相同,都是将上下文保存在特定的位置,切换到新的进程去执行。但进程的调度执行是操作系统负责的,具有很大的随机性,所以父进程和子进程谁先退是不能确定的。
操作系统提供 fork 方法用于创建一个子进程,一次 fork 之后,会有两个返回值,原因在于 fork 在系统里创建了两个栈,一个是父进程的,一个是子进程的。创建的时候,子进程完全“继承”了父进程的所有数据,包括栈上的数据。父子进程栈的情况如图 3 所示:image.png

用户态和内核态是如何切换的

内核态和用户态的转换也依赖于栈的切换。操作系统内核在运行的时候,也是需要栈的,这个栈称为内核栈,它与用户应用程序使用的用户态栈是不同的。只有高权限的内核代码才能访问它。而内核态与用户态的相互切换,其中最重要的一个步骤就是内核栈和用户栈的切换。
中断发生时,CPU 根据需要跳转的特权级,去一个特定的结构中(不同的 CPU 会有所不同,比如 i386 就存在 TSS 中,但不管是什么 CPU,一定会有一个类似的结构),取得目标特权级所对应的 stack 段选择子和栈顶指针,并分别送入 ss 寄存器和 rsp 寄存器,这就完成了一次栈的切换。
然后,IP 寄存器跳入中断服务程序开始执行,中断服务程序会把当前 CPU 中的所有寄存器,也就是程序的上下文都保存到栈上,这就意味着用户态的 CPU 状态其实是由中断服务程序在系统栈上进行维护的。如图 4 所示:
image.png
一般来说,当程序因为 call 指令或者 int 指令进行跳转的时候,只需要把下一条指令的地址放到栈上,供被调用者执行 ret 指令使用,这样可以便于返回到调用函数中继续执行。但图 4 中的内核态栈里有一点特殊之处,就是 CPU 自动地将用户态栈的段选择子 ss3,和栈顶指针 rsp3 都放到内核态栈里了。这里的数字 3 代表了 CPU 特权级,内核态是 0,用户态是 3。
当中断结束时,中断服务程序会从内核栈里将 CPU 寄存器的值全部恢复,最后再执行”iret”指令(注意不是 ret,而是 iret,这表示是从中断服务程序中返回)。而 iret 指令就会将 ss3/rsp3 都弹出栈,并且将这个值分别送到 ss 和 rsp 寄存器中。这样就完成了从内核栈到用户栈的一次切换。同时,内核栈的 ss0 和 rsp0 也会被保存到前文所说的一个特定的结构中,以供下次切换时使用。
总的来说,栈切换的核心就是栈指针 rsp 寄存器的切换。