学习 vfork 的时候,看到这篇文章中的一个例子,觉得很有趣,就拷贝下来自己跑了一下,其中的例子差不多是这样的:
1 | void fun1() { |
输出:
1 | ➜ ./vfork |
结合文章的讲解,我的理解如下:因为 vfork
后,子进程共享父进程的地址空间,父进程会等待子进程先执行,所以,子进程执行完 后 fun2
退出后,父进程的指令依旧停留在 fun1
的 printf
函数处。会执行 fun1
中的 printf
函数。但是为什么父进程没有打印出 goes 1 呢?因为子进程执行完 fun1
的时候,已经把 fun1
的返回地址弹出栈,返回到了 goes 1 处,而执行到 fun2
的时候,并没有返回,而是通过 _exit
直接退出了,所以,栈上的返回地址还保留着,而这个返回地址就是输出 goes 2 的 printf
语句的地址,因为父子进程共享栈,所以当父进程从 fun1
返回时,返回的就是这个语句的地址。
为了验证自己的理解是否到位,就把例子改成了下面这样,看看结果是否符合预期:
1 | void fun() { |
按照前面的理解,期望的输出应该是这样的:
1 | ➜ ./vfork |
我是这么分析的:首先,vfork
之后,子进程先执行,进入到 if
块中,打印了 goes 1,然后进入到 fun
中 _exit
了,这时候栈上 “残留” 着 fun
的返回地址。然后,父进程接着执行,进入到 else
块中, 打印出 goes 2,准备返回的时候发现栈上的地址是 fun
的返回地址,也就是打印 after fun 的那行代码,于是就接着输出 after fun。看起来一切都很合理对吧?但实际的输出却是:
1 | ➜ ./vfork |
after fun 没了!不知道你们想明白没,反正我是绞尽脑汁想了好久才豁然开朗。之前在分析函数返回的时候,分析得比较粗略,我们如果再细致一点,从汇编层面分析一下 return
的细节,真相就呼之欲出了。return
语句其实对应着两个汇编指令的调用,先是 leave
,然后才是 return
。leave
指令等价于让 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
调用处了。
总结:
vfork
出的父子进程共享地址空间,但是不会共享寄存器,寄存器在vfork
那个时刻,是相同的,后面随着子进程的执行开始分化。这期间父进程挂起,等到调度上 cpu 时,会用之前保存的值恢复所有寄存器。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,你的一个举手之劳,对这个小家伙而言说不定也是莫大的恩赐 :)。