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,然后再 retleave 指令做了两件事,将 rsp 恢复为 rbp 的值,然后 pop 出栈顶的 old rbp 给 rbp 寄存器。所以 leave 指令执行后,rsp 指向的就是栈顶的 rip 了,此时 ret 一下,就返回到 rip 指向的地址处了。

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

那么问题来了,为什么第一个例子中,父进程从 fun1 返回到了 fun2 的调用处?其实父进程恢复执行时,它的 rbp 确实应该指向 fun1 的栈帧底部。但子进程执行完 fun1 后,fun1 的栈帧就不在了,子进程执行 fun2 的时候,在原来 fun1 的栈帧所在处,建立了 fun2 的栈帧,导致父进程本来指向 fun1 栈帧底部的 rbp,此时指向的是 fun2 的栈帧底部,然后一 leave ,一 ret,自然就返回到 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。结合网上的一些讨论,我个人认为,如果 exec 执行前的准备工作比较多,会在各种函数间穿梭,怕影响父进程,那就别想太多,就用 fork;如果立马执行 exec,就使用 vfork,避免对 mm_struct 的无效拷贝。虽然很多声音都说不要用 vfork,已经被时代抛弃了,但我觉得反正就是多加一个字母的事,性能能省就省,苍蝇再小也是肉,而且 Linux 的宿主设备繁多,可能一个不起眼的小设备上,就跑着一个 Linux,你的一个举手之劳,对这个小家伙而言说不定也是莫大的恩赐 :)。