使用汇编实现函数调用
只需要维护好前面提到的两个比较重要的寄存器 rip
和 rsp
就可以了。
首先,要调用 hello
,就包含有控制权的转移,需要修改 rip
,这个比较简单,直接 call hello
就可以了。
其次控制权转移到 hello
后,函数开始执行,需要为 hello
分配好调用栈,直接使用 malloc
在堆上分配。需要注意 malloc
返回的地址是低地址,需要加上分配的内存大小获取得到高地址。这是由于我们要在动态分配的这块内存上模拟栈的行为,而在 x86-64 中栈底是高地址,栈从高地址往低地址进行增长。
这样我们的栈帧就不是连续的了,main
的栈帧在栈上,hello
的栈帧在堆上。但其实这是没有影响的,只需要能够维护好栈帧的链式关系就可以了。在进入 hello
时,和之前一样会保存好 main
的 rbp
,因此我们不需要额外关注链式结构。
我们只需要手动维护 rsp
就可以了。
用一段伪 (汇编) 代码描述一下,将 malloc
分配的内存,其高地址设置到 rsp
中,将参数设置到 rdi
中,call hello
进行调用。可以在前后做一些操作,将后续会用到的数据保存到栈中,后面 pop
出来继续使用。
# store sth, for later use --> 序言 (prologue)
# store old `%rsp`
movq hello_stack_sp, %rsp # 设置 `hello` 调用栈 !!!
movq $0x5, %rdi # 设置入参
call hello # 1) 保存返回地址
# 2) 跳转到 `hello` 执行
# restore and resume --> 后记 (epilogue)
# resume old `%rsp`
借助下面的图方便进行理解。记住,这里是理解协程的关键。
从这里我们也可以看出,其实函数调用栈就是一块内存,它不一定需要连续 ("the stack is a simple block of memory")。
按照这个思路,接下来我们看一下编码实现。
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
// 调用栈太小的话,在执行函数调用时会报错 (printf)
// fish: Job 1, './call-hello' terminated by signal SIGSEGV (Address boundary
// error)
#define STACK_SIZE (64 * 1024) // <-- caution !!!
uint64_t world(uint64_t num) {
printf("hello from world, %ld\n", num);
return 42;
}
uint64_t hello(uint64_t num) { return world(num); }
int main() {
uint64_t num = 5, res = 0;
char *stack = malloc(STACK_SIZE); // 分配调用栈 !!!
char *sp = stack + STACK_SIZE;
asm volatile("movq %%rcx, 0(%1)\n\t"
"movq %1, %%rsp\n\t"
"movq %3, %%rdi\n\t"
"call *%2\n\t"
: "=r"(res) /* output */
: "b"((uintptr_t)sp - 16), /* input */
"d"((uintptr_t)hello),
"a"((uintptr_t)num));
asm volatile("movq 0(%%rsp), %%rcx" : :); // 这里的 restore 可以删除掉
printf("num = %ld, res = %ld\n", num, res);
return 0;
}
// hello from world, 5
// num = 5, res = 42
主要关注 main
函数中调用 hello
的这段内联汇编,查看其对应的汇编代码 (代码片段 (跳转过去看看汇编,同时本地用 gdb 调试一下)),与直接调用 hello(5)
的汇编代码基本一致。这里我们未实现 restore old rsp
,需要注意。
本质就是,实现控制权的转移,同时在程序运行时一直满足 ABI 的要求。
- GCC 内联汇编,特别注意 ❗️:x86-64 要求调用栈按照 16 字节对齐 (当然这就是 ABI 的要求了,我们需要查询手册)
讲了这么多关于函数和函数调用的知识点,基本上都是我们熟知的。大家可能会纳闷,函数调用和我们今天要讲的协程有什么关系呢?
前面我们提到「协程是泛化的函数」,其实这话是高德纳说的:
Subroutines are special cases of more general program components, called coroutines. In contrast to the unsymmetric relationship between a main routine and a subroutine, there is complete symmetry between coroutines, which call on each other. -- Donald E. Knuth, Art of Computer Programming - Volume 1 (Fundamental Algorithms), 1.4.2. Coroutines
与函数相比,协程要更通用一些,即函数是协程的一种特殊情况。
还是来看看函数调用 (左上角的这张图),main
函数调用 hello
,hello
再调用 world
,会形成一个函数调用栈。当 world
执行完毕后,返回 hello
,hello
继续执行,hello
执行完毕后,返回 main
,main
继续执行,直至执行完成。
这都是站在 main
函数的角度在理解。
我们可以试着换一个视角,站在 hello
函数的角度来看看 (左下角的这张图)。
就可以这么理解 —— main
yield
主动让出执行权,resume
hello
,hello
获得执行权,开始执行,执行完成后 hello
yield
让出执行权 (return
语句),resume
main
函数继续执行。这样来看,main
总是从上一次 yield
的地方继续向下执行。
但协程又与函数调用有些不同,协程能够多次被暂停和恢复 (右上角的这张图)。hello
可以 yield
主动让出控制权 (这就是与普通函数调用不一样的地方,普通函数只能在 return
时 yield
让出控制权,不会再次恢复执行),再次唤醒时,hello
从最近一次 yield
的地方继续往下执行。
我们只需要在 hello
yield
时保存执行的上下文,后面重新获取 CPU 控制权时,恢复保存的上下文。如何实现控制流的主动让出和返回 —— 这个比较容易实现,类比 main
通过汇编调用 hello
,维护好 rsp
和 rip
等寄存器就可以了。会在第三部分实现一个简单的协程时,详细进行说明。
回到函数与协程,此时,我们可以说,函数是协程的一种特例,协程切换和函数调用,二者的操作大体相同。
接下来,我们继续来看一下 coroutine
的第二个关键点,多任务处理。
多任务处理:并发地执行多个任务的能力
多任务是操作系统提供的特性,指能够 并发 地执行多个任务。比如现在我使用 chrome 在进行投影,同时后台还运行着微信、打开着终端。这样看上去好像多个任务是在并行运行着,实际上,一个 CPU 核心在同一时间只能执行一条指令。图示中一个矩形框表示一个 CPU 核心。
那如何在单核 CPU 上执行多任务呢?这依赖于分时系统,它将 CPU 时间切分成一段一段的时间片,当前时间片执行任务 1,下一个时间片执行任务 2,操作系统在多个任务之间快速切换,推进多个任务向前运行。由于时间片很短,在绝大多数情况下我们都感觉不到这些切换。这样就营造了一种“并行”的错觉。
那真实的并行是怎么样的呢?需要有多个 CPU 核心,每一个核心负责处理一个任务,这样在同一个时间片下就会同时有多条指令在并行运行着 (每个核心对应一条指令),不需要在任务之间进行上下文切换。
Rob Pike,也就是 Golang 语言的创始人之一,在 2012 年的一个分享 (Concurrency is not Parallelism, Rob Pike, 2012, slide) 中就专门讨论了并发和并行的区别,很直观地解释了二者的区别:
Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once. -- Rob Pike
并发是一种 同时处理 很多事情的能力,并行是一种 同时执行 很多事情的手段。
我们把要做的事情拆分成多个独立的任务进行处理,这是并发的能力。在多核多 CPU 的机器上同时运行这些任务,是并行的手段。可以说,并发是为并行赋能。当我们具备了并发的能力,并行就是水到渠成的事情。
所以我们平时都谈论高并发处理,而不会说高并行处理(这是高性能计算中的内容)。
今天,在这里,我们主要讨论的是单核 CPU 上的多任务处理,涉及到几个问题:
- 任务是什么,怎样抽象任务这样一个概念?
- 多个任务之间需要进行切换,把当前任务的上下文先保存起来,把另一个任务的上下文恢复,那么任务的上下文都包含哪些东西呢,如何进行上下文的保存和恢复呢?
- 什么情况下进行任务切换?
下面我们来看一下任务包含哪几个层次的抽象。
任务抽象:进程、线程、协程
从今天的实现看,任务的抽象并不唯一。
我们熟悉的进程和线程,以及今天讨论的协程,都可以作为这里任务的抽象。这三类对象都可被 CPU 核心赋予执行权,因此每个抽象本身至少需要包含下一条将要执行的指令地址,以及运行时的上下文。
任务抽象 | 上下文 |
进程 | PCB |
线程 | TCB |
协程 | use-defined |
从任务的抽象层级来看:对于进程,其上下文保存在进程控制块中;对于线程,其上下文保存在线程控制块中;而对于协程,上下文信息由程序员自己进行维护。
但如果我们换一个角度,从 CPU 的角度来看,这里所说的任务的上下文表示什么呢?我们都知道,冯诺依曼体系结构计算机,执行程序主要依赖的是内置存储:寄存器和内存,这就构成了程序运行的上下文 (context)。
寄存器的数量很少且可以枚举,我们直接通过寄存器名进行数据的存取。在将 CPU 的执行权从任务 1 切换到任务 2 时,要把任务 1 所使用到的寄存器都保存起来 (以便后续轮到任务 1 继续执行时进行恢复),并且寄存器的值恢复到任务 2 上一次执行时的值,然后才将执行权交给任务 2。
再看内存,不同的任务可以有不同的地址空间,通过不同的地址映射表来体现。如何切换地址映射表,也是修改寄存器。
所以,任务的上下文就是一堆寄存器的值。要切换任务,只需要保存和恢复一堆寄存器的值就可以了。针对进程、线程、协程,都是如此。
这样,我们就回答了上一页中,什么是任务以及任务的上下文是什么,如何进行保存和恢复。
接下来我们来看上一页中的第三个问题,任务在什么时候进行切换?一个任务占用着 CPU 核心正在运行着,有两种方式让它放弃对 CPU 的控制,一个是主动,一个是被动。
主动和被动,在计算机中有它的专有用词,抢占式和协作式。
抢占式 (preemptive) 是被动的,由操作系统来控制任务切换的时机。
在每次中断后,操作系统都能够重新获得 CPU 的控制权。上图展示了当一个硬件中断到达时,操作系统进行任务切换。
我们可以看到,抢占式多任务中操作系统就是“老大”,操作系统能够完全控制每个任务的执行时间,从而保证每个任务能够获得一个相对公平的 CPU 时间片,使得运行不可靠的用户态程序成为可能,例如 while (true) { }
这样的死循环。
抢占式的问题也很明显,因为有操作系统的参与,每次进行任务切换,都会从用户态切换到内核态,还需要保存任务的上下文信息,因此上下文切换开销比较大。同时由于每个任务都有单独的栈空间,在启动过多任务时,内存占用大,会限制系统支持运行的任务数量。
与抢占式多任务强制性地暂停任务的执行不同,协作式 (cooperative) 多任务允许任务一直运行,直至该任务主动放弃 CPU 的执行权