Linux: vfork() 程序异常退出问题分析

1. 前言

限于作者能力水平,本文可能存在谬误,因此而给读者带来的损失,作者不做任何承诺。

2. 分析环境

本文基于 Linux 4.14 内核源码进行分析。测试环境为 Ubuntu 16.04.4 LTS 桌面系统,其搭配的内核版本为 4.15.0-142-generic ,虽然和用来分析的内核版本不完全相同,但不影响我们对问题的分析。

3. 问题场景

最近看到一篇博文: VFORK 挂掉的一个问题,描述了 vfork() 调用场景下的一个问题。博文作者对问题产生的原因从宏观上进行了分析。本篇试着从代码细节层面,对问题原因进行分析。
本篇对博文的测试代码稍作了一下修改,修改后的测试代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int glob = 7890;

int main(void)
{
	pid_t pid;
	int var;

	var = 88;
	//if ((pid = fork()) < 0) { // okay
	if ((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() 的功能和历史。

4. 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() 的功能和历史,我们开始正式进入对问题代码细节层面地分析。

5. 问题分析

虽然已经可以从博文 VFORK 挂掉的一个问题 得到一个本篇所分析问题的一个答案,但不了解事情的来龙去脉,给人的感觉仍是隔靴搔痒。让我们顺着程序的执行流程,从代码细节层面,试着分析问题所在。

5.1 程序的启动

程序的启动,从 bashfork() + 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);

5.2 子进程的启动

上节程序流程进入到 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.3 子进程的退出

5.3.1 用 return 语句退出的情形

从章节 5.1 程序的启动__libc_start_main() 的流程分析看到,子进程将从 main() 回退到 __libc_start_main() ,然后调用 exit()

5.3.2 用 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系统调用实现简析

5.3.3 子进程退出的细节

从前面的分析知道,不管是 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() 终于得以继续运行。

6. 另辟蹊径

如果不了解内核代码细节,我们就没有办法解决这个问题了吗?当然不是,我们只需要阅读 vfork() 的文档,就能解决这个问题,vfork() 文档明确标明了需要避开的坑。这就是博文 VFORK 挂掉的一个问题 作者所讲的 RTFM: Read The Fucking Manual 大法。如果仅仅是为了解决这个问题,阅读 API 手册仍然是最省时、也最为推荐的方法。

7. 彩蛋

对于测试程序代码,假设我们只要求程序不崩溃,允许其它错误,应该怎样修改代码?
我们只需要将 main() 函数结尾处的 return 0; 修改为 exit(0) 。我们看一下修改代码后的程序输出:

$ ./vfork_test 
pid=5033, glob=7890, var=4195584

虽然 var 变量的输出值仍然错误,但程序不再崩溃了。原因很简单,既然是破坏了调用链导致的程序崩溃(具体是破坏了调用链上的链接寄存器,如 ARM32 架构的 LR 寄存器),那父进程就调用 exit() 直接退出,不再返回 __libc_start_main() ,也就用不上调用链了,和子进程使用 exit() 是一样的道理。

8. 参考资料

VFORK 挂掉的一个问题
man fork()
man vfork()

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值