vfork 的诡异输出

学习 vfork 的时候,看到这篇文章中的一个例子,觉得很有趣,就拷贝下来自己跑了一下,其中的例子差不多是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void fun1() {
vfork();
printf("%d\n", getpid());
}

void fun2() {
_exit(0);
}

int main() {

fun1();

printf("%d goes 1\n", getpid());

fun2();

printf("%d goes 2\n", getpid());

return 0;
}

输出:

1
2
3
4
5
➜ ./vfork
2846438
2846438 goes 1
2846437
2846437 goes 2

结合文章的讲解,我的理解如下:因为 vfork 后,子进程共享父进程的地址空间,父进程会等待子进程先执行,所以,子进程执行完 后 fun2 退出后,父进程的指令依旧停留在 fun1printf 函数处。会执行 fun1 中的 printf 函数。但是为什么父进程没有打印出 goes 1 呢?因为子进程执行完 fun1 的时候,已经把 fun1 的返回地址弹出栈,返回到了 goes 1 处,而执行到 fun2 的时候,并没有返回,而是通过 _exit 直接退出了,所以,栈上的返回地址还保留着,而这个返回地址就是输出 goes 2 的 printf 语句的地址,因为父子进程共享栈,所以当父进程从 fun1 返回时,返回的就是这个语句的地址。

为了验证自己的理解是否到位,就把例子改成了下面这样,看看结果是否符合预期:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void fun() {
_exit(0);
}

int main() {

int pid = vfork();

if (pid == 0) {
printf("%d goes 1\n", getpid());
fun();
printf("after fun");
return 0;
} else {
printf("%d goes 2\n", getpid());
return 0;
}
}

按照前面的理解,期望的输出应该是这样的:

1
2
3
4
➜ ./vfork             
2846933 goes 1
2846932 goes 2
after fun

我是这么分析的:首先,vfork 之后,子进程先执行,进入到 if 块中,打印了 goes 1,然后进入到 fun_exit 了,这时候栈上 “残留” 着 fun 的返回地址。然后,父进程接着执行,进入到 else 块中, 打印出 goes 2,准备返回的时候发现栈上的地址是 fun 的返回地址,也就是打印 after fun 的那行代码,于是就接着输出 after fun。看起来一切都很合理对吧?但实际的输出却是:

1
2
3
➜ ./vfork             
2846933 goes 1
2846932 goes 2

after fun 没了!不知道你们想明白没,反正我是绞尽脑汁想了好久才豁然开朗。之前在分析函数返回的时候,分析得比较粗略,我们如果再细致一点,从汇编层面分析一下 return 的细节,真相就呼之欲出了。return 语句其实对应着两个汇编指令的调用,先是 leave,然后才是 returnleave 指令等价于让 rsp 恢复为 rbp 的值,并且 pop 出栈顶的 old rbp 给 rbp 寄存器。所以 leave 指令执行后,rsp 指向的就是栈顶的 rip 了,此时 return 一下,就返回到 rip 对应的地址处了。

了解 return 的细节后,我们再来看看子进程将调用 _exit 退出时的情况,此时子进程 rbp 指向的是 fun 函数的栈帧底部,也就是 after fun 的 printf 语句处,但随着 _exit 的调用,子进程烟消云散。vfork 虽然会让父子进程共享地址空间,但是并不会共享寄存器,子进程 “生前” 对寄存器的改变并不会被父进程看见。子进程结束时虽然它的 rbp 指向的是 fun 的栈底,但父进程恢复执行后,父进程的 rbp 指向的是却依然是 vfork 调用时 main 函数的栈帧底部,所以父进程执行到 return 语句时,在执行了 leave 汇编指令后,栈顶的 rip 就是 main 函数的返回地址(这个地址在 libc 中),这之后再执行 return 指令就直接返回到 libc 里去了。

那么问题来了,为什么第一个例子中,父进程从 fun1 返回的却是 fun2 的调用处呢?其实父进程恢复执行时,它的 rbp 确实本应该指向的 fun1 的栈帧底部。但是,子进程执行完 fun1 后,fun1 的栈帧就不在了,子进程执行 fun2 的时候,在原来 fun1 的栈帧所在处,建立了 fun2 的栈帧。导致父进程本来指向 fun1 栈帧底部的 rbp 此时指向的是 fun2 的栈帧底部,然后一 leave ,一 return,自然就返回到 fun2 调用处了。

总结:

  1. vfork 出的父子进程共享地址空间,但是不会共享寄存器,寄存器在 vfork 那个时刻,是相同的,后面随着子进程的执行开始分化。这期间父进程挂起,等到调度上 cpu 时,会用之前保存的值恢复所有寄存器。
  2. return 返回到哪里,关键看 leave 指令执行前的那一刻,rbp 指向的是哪个函数的栈帧底部,如果是 a ,那就返回到 a 的调用处,如果是 b,那就返回到 b 的调用处,至于是从哪个函数进来的,那不重要,只不过通常情况下,进入和返回的都是同一个调用点。

当然,如果遵循推荐的 vfork 使用指南,通常我们并不会遇到这种诡异现象。vfork 的推荐玩法是,子进程分裂出来后,赶紧执行 exec 函数和父进程分家,抛弃掉父进程的地址空间 mm_struct(主要就是抛弃掉父进程的页表和 vma),另起炉灶,建立自己的 mm_struct,然后各自安好,互不打扰。在 fork 引入了写时复制技术后,使用 fork + exec 性能其实也还 ok,然而,虽然不用急着拷贝内存了,依然需要立即拷贝 mm_struct。结合网上的一些讨论,我个人认为,究竟使用 fork + exec 还是 vfork + exec 看情况而定,如果 exec 执行前的准备工作比较多,会在各种函数间穿梭,怕影响父进程,那就别想太多,就用 fork;如果立马执行 exec,就使用 vfork,避免对 mm_struct 的无效拷贝。虽然很多声音都说不要用 vfork,已经被时代抛弃了,但我觉得反正就是多加一个字母的事,性能能省就省,苍蝇再小也是肉,而且 Linux 的宿主设备繁多,可能一个不起眼的小设备上,就跑着一个 Linux,你的一个举手之劳,对这个小家伙而言说不定也是莫大的恩赐 :)。