限于作者能力水平,本文可能存在谬误,因此而给读者带来的损失,作者不做任何承诺。
本文基于 Linux 4.14
内核源码进行分析。测试环境为 Ubuntu 16.04.4 LTS
桌面系统,其搭配的内核版本为 4.15.0-142-generic
,虽然和用来分析的内核版本不完全相同,但不影响我们对问题的分析。
最近看到一篇博文: VFORK 挂掉的一个问题,描述了 vfork()
调用场景下的一个问题。博文作者对问题产生的原因从宏观上进行了分析。本篇试着从代码细节层面,对问题原因进行分析。
本篇对博文的测试代码稍作了一下修改,修改后的测试代码如下:
#include
#include
#include int glob = 7890;int main(void)
{pid_t pid;int var;var = 88;//if ((pid = fork()) < 0) { // okayif ((pid = vfork()) < 0) {printf("vfork error");exit(-1);} else if (pid == 0) { /* 子进程 */var++;return 0; // 在 vfork() 调用下,会导致程序崩溃//exit(0); // okay}printf("pid=%d, glob=%d, var=%d\n", getpid(), glob, var);return 0;
}
在 Ubuntu 16.04.4 LTS
桌面系统下编译运行上述代码,程序会异常退出,系统报告如下信息:
$ ./vfork_test
pid=3485, glob=7890, var=4195584
vfork_test: cxa_atexit.c:100: __new_exitfn: Assertion `l != NULL' failed.
Aborted (core dumped)
我们看到,程序 abort 了。如果按如下两种方式之一修改前述代码:
. 将 vfork() 调用替换为 fork()
. 将子进程的代码 return 0; 替换为 exit(0)
程序将正常运行。为什么?接下来我们就试着分析下问题的原因。在正式分析开始之前,我们先来了解下 fork()
和 vfork()
的功能和历史。
fork()
和 vfork()
都可用来创建一个新的子进程。你一定会奇怪,既然功能相同,为什么还会有两个接口,只要一个不就好了吗?这是历史原因造成的。
fork()
先于 vfork()
出现,fork() 早期实现
,在创建子进程时,会完整的复制父进程的数据,而非现在的 写时拷贝(COW: Copy-On-Write)
;而通常对于 fork()
的使用场景,是用它来创建一个子进程,然后调用 exec*()
来启动一个新的程序,这意味着子进程的数据是全新的,此时拷贝父进程的数据变得没有必要,另外拷贝父进程的数据,也对新程序的启动速度造成了影响。为此,引入了 vfork()
。
vfork()
实现通过如下两点,解决上述 fork() 早期实现
导致的上述问题:
1. vfork() 创建的子进程和父进程共享数据;
2. vfork() 保证子进程先于父进程运行,而父进程在子进程退出后继续运行。
由此可见,fork()
和 vfork()
的不同,主要体现在 父子进程是否共享数据
和 父子进程运行的先后顺序
上。
前述的点 1.
,减少了不必要的数据拷贝,很大程度上加快了使用 exec*()
启动新程序的速度;而点 2.
保证子进程运行期间,父进程不参与系统资源竞争,进一步加速了子进程的启动和运行,顺带的系统中的其它进程也会受益。
而今的 fork()
实现,已然不再在子进程创建时就拷贝父进程数据,而是使用了 写时拷贝(COW: Copy-On-Write)
技术:仅在子进程对数据进行修改时,才建立自己独立的数据拷贝。当然,vfork()
对于优先运行子进程的场景,仍然存在微弱的性能优势,但除非对性能有极致要求,否则仍然不建议使用 vfork()
。
说完了 fork()
和 vfork()
的功能和历史,我们开始正式进入对问题代码细节层面地分析。
虽然已经可以从博文 VFORK 挂掉的一个问题 得到一个本篇所分析问题的一个答案,但不了解事情的来龙去脉,给人的感觉仍是隔靴搔痒。让我们顺着程序的执行流程,从代码细节层面,试着分析问题所在。
程序的启动,从 bash
的 fork() + exec*()
开始,我们的程序进程作为 bash
的子进程启动后,进入 c 运行时启动代码部分(glibc 源码):
/* 以 ARM32 平台启动代码为例 */
_start:.../* Pop argc off the stack and save a pointer to argv */pop { a2 }mov a3, sp/* Push stack limit */push { a3 }/* Push rtld_fini */push { a1 }/* Fetch address of __libc_csu_fini */ldr ip, =__libc_csu_fini/* Push __libc_csu_fini */push { ip }/* Set up the other arguments in registers */ldr a1, =main /* __libc_start_main() 的第1个参数: main() 的地址 */ldr a4, =__libc_csu_init/* __libc_start_main (main, argc, argv, init, fini, rtld_fini, stack_end) *//* Let the libc call main and exit with its return code. */bl __libc_start_main /* 进入 __libc_start_main() */
代码流程进入 __libc_start_main()
,继续看它的执行流程:
__libc_start_main()...__libc_csu_init().../* 进入程序的 main() 函数 */result = main (argc, argv, __environ MAIN_AUXVEC_PARAM);/* 子进程使用 return 退出,从 main() 退出到这里 */exit (result);
上节程序流程进入到 main()
后,到 vfork()
调用处,将创建新的子进程,然后父子进程并立。我们先看子进程的建立启动流程:
sys_vfork()/* * sys_fork() 创建进程时仅传递了 SIGCHLD 标记,* 所以后续创建进程过程中,我们重点关注 CLONE_VFORK 和* CLONE_VM 标记,因为这体现了 fork() 和 vfork() 的差异* 所在。*/_do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0, 0, NULL, NULL, 0)...p = copy_process(clone_flags, stack_start, stack_size,child_tidptr, NULL, trace, tls, NUMA_NO_NODE)...p = dup_task_struct(current, node); /* 新进程对象 */...p->vfork_done = NULL;...retval = copy_mm(clone_flags, p)struct mm_struct *mm, *oldmm;...oldmm = current->mm;.../* vfork(): 父子进程共享数据 */if (clone_flags & CLONE_VM) { mmget(oldmm); /* 增加父进程 mm_struct 对象引用计数:子进程也引用了它 */mm = oldmm;goto good_mm; /* 子进程共享父进程的 mm_struct */}.../* fork(): 子进程有自己独立的数据 */mm = dup_mm(tsk);if (!mm)goto fail_nomem;good_mm:tsk->mm = mm;tsk->active_mm = mm;return 0;.../** sigaltstack should be cleared when sharing the same VM*/if ((clone_flags & (CLONE_VM|CLONE_VFORK)) == CLONE_VM)sas_ss_reset(p);.../* 新进程创建的最后工作 */if (!IS_ERR(p)) {/** vfork() 用来同步父子进程: * 保证子进程先于父进程运行,父进程在子进程结束前不参与系统资源竞争。*/struct completion vfork;...if (clone_flags & CLONE_VFORK) {p->vfork_done = &vfork;init_completion(&vfork);get_task_struct(p);}wake_up_new_task(p); /* 唤醒新创建的子进程参与调度 */...if (clone_flags & CLONE_VFORK) {/* vfork():确保子进程先运行,父进程在子进程结束后继续运行 */if (!wait_for_vfork_done(p, &vfork)) ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);}}
从上面的代码分析,我们了解到:
1. fork() 的子进程通过独立的 mm_struct 来管理自己独立的数据;vfork() 通过和父进程共享 mm_struct 而共享数据。
2. vfork() 通过 task_struct::vfork_done 来同步父子进程:子进程先运行,父进程在子进程退出后继续运行。
我们重点关注 vfork()
调用下、父子进程共享 mm_struct
的情形,它意味着程序中的任意数据改变,会同时反映到父子进程。简单来说,就是父子进程使用相同的数据,其中包括 全局变量、堆、栈
等。了解到这一点,对我们后续地分析起着至关重要的作用。
从章节 5.1 程序的启动
中 __libc_start_main()
的流程分析看到,子进程将从 main()
回退到 __libc_start_main()
,然后调用 exit()
。
子进程从 main()
中直接调用 exit()
。
从上两小节不难看出,子进程退出的两种情形,仅仅在于 exit()
调用位置的不同:第1种情形 return
时是在 __libc_start_main()
调用 exit()
;第2种情形是在 main()
中调用 exit()
。看起来似乎没什么差别,就这样第1种情形程序就崩溃?第2种情形程序正常运行?
起先看到博文 VFORK 挂掉的一个问题 时,我没有直接看完全篇,先是试图从如下程序崩溃的信息寻找答案:
$ ./vfork_test
pid=3485, glob=7890, var=4195584
vfork_test: cxa_atexit.c:100: __new_exitfn: Assertion `l != NULL' failed.
Aborted (core dumped)
如果顺着去查 __new_exitfn()
函数去找问题,结果显然将是徒劳的。我一开始就是这么干的,结果走了弯路。其实变量 var
的输出值,已经给出了提示:明明赋值 88
,结果输出 4195584
。
从前面我们知道,父子进程共享栈空间
, 同时仅有一处父子进程共享代码语句 var = 88;
对 var
赋值,var
这个栈变量的值发生了改变,那只能是调用栈的变化。函数的非静态变量从栈上分配空间:进入时分配,退出时释放。更重要的是,链接寄存器(如 ARM32 下的 LR 寄存器)的值也发生了变化,这将导致调用链破坏,也就是博文 VFORK 挂掉的一个问题 说的 堆栈跪了
。vfork()
子进程退出时使用 return
,就是前面分析的情形,破坏了父进程的当前栈空间,最终提示的出错位置在 __new_exitfn()
中也变得不靠谱。
子进程直接在 main() 中调用 exit()
,不会破坏父进程的栈空间,因为 exit()
是对 sys_exit_group()
系统调用的直接封装。系统调用的更多细节,可以参考博文 Linux系统调用实现简析 。
从前面的分析知道,不管是 return
还是直接 exit()
,最终都会都调用了 exit()
,我们这里不讨论用户空间的细节,我们看一下子进程退出的内核空间的流程:
sys_exit_group()do_group_exit((error_code & 0xff) << 8).../* 给线程组中的其它进程发送 SIGKILL 信号 */sig->group_exit_code = exit_code;sig->flags = SIGNAL_GROUP_EXIT;zap_other_threads(current);/* 退出当前线程 */do_exit(exit_code)/* 我们只看 vfork 父子进程同步,以及 mm_struct 的相关部分 */...exit_mm()mm_release(current, mm)...if (tsk->vfork_done)complete_vfork_done(tsk)struct completion *vfork;task_lock(tsk);vfork = tsk->vfork_done;if (likely(vfork)) {tsk->vfork_done = NULL;/* 父进程还在 vfork() 里面等待,现在子进程退出了,唤醒父进程继续执行 */complete(vfork);}task_unlock(tsk);......
现在我们能够理解,为什么 vfork()
能够保证子进程先运行,子进程退出之前,父进程都不参与系统资源竞争,因为它一直在内核空间的 vfork()
调用中睡眠;子进程在 exit()
中发起唤醒动作,vfork()
终于得以继续运行。
如果不了解内核代码细节,我们就没有办法解决这个问题了吗?当然不是,我们只需要阅读 vfork()
的文档,就能解决这个问题,vfork()
文档明确标明了需要避开的坑。这就是博文 VFORK 挂掉的一个问题 作者所讲的 RTFM: Read The Fucking Manual
大法。如果仅仅是为了解决这个问题,阅读 API 手册仍然是最省时、也最为推荐的方法。
对于测试程序代码,假设我们只要求程序不崩溃,允许其它错误,应该怎样修改代码?
我们只需要将 main()
函数结尾处的 return 0;
修改为 exit(0)
。我们看一下修改代码后的程序输出:
$ ./vfork_test
pid=5033, glob=7890, var=4195584
虽然 var
变量的输出值仍然错误,但程序不再崩溃了。原因很简单,既然是破坏了调用链导致的程序崩溃(具体是破坏了调用链上的链接寄存器,如 ARM32 架构的 LR 寄存器),那父进程就调用 exit()
直接退出,不再返回 __libc_start_main()
,也就用不上调用链了,和子进程使用 exit()
是一样的道理。
VFORK 挂掉的一个问题
man fork()
man vfork()
上一篇:扑克玩法:9点半--数据分析
下一篇:C++基础——C++ 常量