Linux进程管理:(一)进程的创建和终止

文章说明:

在最新版本的POSIX标准中,定义了进程创建和终止的操作系统层面的原语。进程创建包括fork()和execve()函数族,进程终止包括wait()、waitpid()、kill(),以及exit()函数族。Linux操作系统在实现过程中为了提高效率,把POSIX标准的fork()原语扩展为vfork()和clone()两个原语。

在POSIX标准中还规定了posix_spawn()函数,也就是把fork()和exec()的功能结合起来,形成单个spawn操作——创建一个新进程并且执行程序。glibc函数库实现了posix_spawn()函数。

1. 写时复制技术

在传统的UNIX操作系统中,创建新进程时会复制父进程所拥有的所有资源,这样进程的创建变得很低效。每次创建子进程时都要把父进程的进程地址空间中的内容复制到子进程,但是子进程还不一定全盘接收,甚至完全不用父进程的资源。子进程调用execve()系统调用之后,可能和父进程分道扬镰。

现代的操作系统都采用写时复制(Copy On Write,COW)的技术进行优化。写时复制技术就是父进程在创建子进程时不需要复制进程地址空间的内容到子进程,只需要复制父进程的进程地址空间的页表到子进程,这样父、子进程就共享了相同的物理内存。当父、子进程中有一方需要修改某个物理页面的内容时,触发写保护的缺页异常,然后才复制共享页面的内容,从而让父、子进程拥有各自的副本。也就是说,进程地址空间以只读的方式共享,当需要写入时才发生复制,如下图所示。写时复制是一种可以推迟甚至避免复制数据的技术,它在现代操作系统中有广泛的应用。

在这里插入图片描述

在采用了写时复制技术的Linux内核中,用fork()函数创建一个新进程的开销变得很小,免去了复制父进程整个进程地址空间中的内容的巨大开销,现在只需要复制父进程页表的一点开销。

2. 进程的创建

在Linux 5.0内核中,fork()、vfork()、clone()以及创建内核线程的接口函数都是通过调用_do_fork()函数来完成的,只是调用的参数不一样。如下图所示:

在这里插入图片描述

// fork()实现
	_do_fork(SIGCHLD,0,0,NULL,NULL,0);

// vfork()实现
	_do_fork(CLONE_VFORK|CLONE_VM|SIGCHLD,0,0,NULL,NULL,0);

// clone()实现
	_do_fork(clone_flags,newsp,0,parent_tidptr,child_tidptr,tls);

// 内核线程
	_do_fork(flags|CLONE_VM|CLONE_UNTRACE,(unsigned long)fn,(unsigned long)arg,NULL,NULL,0);

2.1 fork()函数

使用fork()函数来创建子进程时,子进程和父进程有各自独立的进程地址空间,但是共享物理内存资源,包括进程上下文、进程堆栈、内存信息、打开的文件描述符、进程优先级、根目录、资源限制、控制终端等。在fork()创建期间,子进程和父进程共享物理内存空间,当它们开始执行各自程序时,它们的进程地址空间开始分道扬镰,这得益于写时复制技术。

子进程和父进程有如下区别:

  • 子进程和父进程的ID不一样
  • 进程不会继承父进程的内存方面的锁,如mlock()
  • 进程不会继承父进程的一些定时器,如setitimer()、alarm()、timer_create()
  • 进程不会继承父进程的信号量,如semop()

fork()函数在用户空间C库中的定义如下:

#include <unistd.h>
#include <sys/types.h>

pid_t fork(void);

fork()函数会有两次返回,一次在父进程中,另—次在子进程中。如果返回值为0,说明是这是子进程;如果返回值为正数,说明这是父进程,父进程会返回子进程的ID;如果返回-1,表示创建失败。

fork()函数通过系统调用进入Linux内核,然后通过_do_fork()函数来实现:

SYSCALL_DEFINE0(fork)
{
	return _do_fork(SIGCHLD,0,0,NULL,NULL,0);
}

fork()函数只使用SIGCHLD标志位,在子进程终止后发送SIGCHLD信号通知父进程。

fork()函数也有一些缺点,尽管使用了写时复制机制技术,但是它还需要复制父进程的页表,在某些场景下会比较慢,所以有了后来的vfork()原语和clone()原语。

2.2 vfork()函数

vfork()函数和fork()函数类似,但是vfork()的父进程会—直阻塞,直到子进程调用exit()或者execve()为止。vfork()函数在用户空间C库中的定义如下:

#include <unistd.h>
#include <sys/types.h>

pid_t vfork(void);

vfork()函数通过系统调用进入Linux内核,然后通过_do_fork()函数来实现:

SYSCALL_DEFINE0(vfork)
{
	return _do_fork(CLONE_VFORK|CLONE_VM|SIGCHLD,0,0,NULL,NULL,0);
}
  • CLONE_VFORK 表示父进程会被挂起,直至子进程释放虚拟内存资源
  • CLONE_VM 表示父、子进程执行在相同的进程地址空间中
  • 通过vfork可以避免复制父进程的页表项

2.3 clone()函数

clone()函数通常用于创建用户线程。在Linux内核中没有专门的线程,而是把线程当成普通进程来看待,因此在内核中还以task_struct数据结构来描述线程,并没有使用特殊的数据结构或者调度算法来描述线程。

clone()函数功能强大,可以传递众多参数,可以有选择地继承父进程的资源,如可以和vfork()一样,与父进程共享一个进程地址空间,从而创建线程;也可以不和父进程共享进程地址空间,甚至可以创建兄弟关系进程。

/* glibc 库的封装 */
#include <sched.h>

// fn是子进程执行时的函数指针
// child_stack为子进程分配栈
// flags设置clone标志位,表示需要从父进程继承哪些资源
// arg是传递给子进程的参数
int clone(int (*fn)(void *),void *child_stack,int flags,void *arg,...);

clone()函数通过系统调用进入Linux内核,然后通过_do_fork()函数来实现:

SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
		 int __user *, parent_tidptr,
		 int __user *, child_tidptr,
		 unsigned long, tls)
{
	return _do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr, tls);
}

2.4 内核线程的创建

**内核线程(kemel thread)**其实就是运行在内核地址空间的进程,它和普通用户进程的区别在于内核线程没有独立的进程地址空间,即tasks_struct数据结构中mm指针设置为NULL,它只能运行在内核地址空间,和普通进程一样参与系统的调度中。所有的内核线程都共享内核地址空间。

Linux内核提供多个接口函数来创建内核线程:

  • kthread_create(threadfn,data,namefmt,arg...)
    • threadfn:新创建内核线程的运行函数
    • namefmt:新创建内核线程的名字
    • 新建的内核线程处于不可运行态,需要调用wake_up_process()函数来将其唤醒并添加到就绪队列中
  • kthread_run(threadfn,data,namefmt,...)可以创建一个马上运行的内核线程

内核线程最终还通过_do_fork()函数来实现:

pid_t kernel_thread(int (*fn)(void *), void *arg, unsigned long flags)
{
	return _do_fork(flags|CLONE_VM|CLONE_UNTRACED, (unsigned long)fn,
		(unsigned long)arg, NULL, NULL, 0);
}

2.5 _do_fork()函数分析

经过上面的介绍,我们已经知道在Linux 5.0内核中,fork()、vfork()、clone()以及创建内核线程的接口函数都是通过调用_do_fork()函数来完成的,那么我们接下来,介绍下_do_fork()函数的实现,该函数的流程图如下图所示:

在这里插入图片描述

为了使读者有更真切的理解,下文将根据流程图围绕源代码进行讲解这个过程:

_do_fork()

// fork()、vfork()、clone() 以及创建内核线程的函数接口都是通过调用 _do_fork() 函数来完成的
// clone_flags:创建进程的标志位集合
// stack_start:用户态栈的起始地址
// stack_size:用户态栈的大小,通常设置为0
// parent_tidptr:指向用户态空间中地址的指针,指向父进程的ID
// child_tidptr:指向用户态空间中地址的指针,指向子进程的ID
// tls:表示线程本地存储(Thread-Local Storage),子进程会继承父进程的内存映像,包括TLS数据,tls 参数允许你在创建子进程时指定新进程的TLS值。
long _do_fork(unsigned long clone_flags,
	      unsigned long stack_start,
	      unsigned long stack_size,
	      int __user *parent_tidptr,
	      int __user *child_tidptr,
	      unsigned long tls)
{
	...
	// 检查子进程是否允许被跟踪
	if (!(clone_flags & CLONE_UNTRACED)) {
		if (clone_flags & CLONE_VFORK)
			trace = PTRACE_EVENT_VFORK;
		else if ((clone_flags & CSIGNAL) != SIGCHLD)
			trace = PTRACE_EVENT_CLONE;
		else
			trace = PTRACE_EVENT_FORK;

		if (likely(!ptrace_event_enabled(current, trace)))
			trace = 0;
	}

	// 创建一个新的子进程,如果创建成功,返回子进程的 task_struct
	p = copy_process(clone_flags, stack_start, stack_size,
			 child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
	...
	// 获取虚拟的 PID,即从当前命名空间内部看到的 PID
	nr = pid_vnr(pid);

	...

	// 对于 vfork() 创建的子进程
	if (clone_flags & CLONE_VFORK) {
		// 使用 vfork_done 完成量来达到扣留父进程的目的
		p->vfork_done = &vfork;
		// init_completion() 用于初始化这个完成量
		init_completion(&vfork);
		get_task_struct(p);
	}

	// 唤醒新创建的进程,也就是把进程加入就绪队列里并接受调度、运行
	wake_up_new_task(p);

	...

	// 对于 vfork,调用 wait_for_vfork_done() 函数等待子进程调用 exec() 或者 exit()
	if (clone_flags & CLONE_VFORK) {
		if (!wait_for_vfork_done(p, &vfork))
			ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
	}

	...
	// 对于父进程,返回新创建子进程的 pid
	// 对于子进程,返回 0
	return nr;
}

_do_fork()->copy_process()

// fork() 的核心实现函数,它会创建新进程的描述符以及新进程执行所需要的其他数据结构,主要做了三件事:
//	1.为新进程分配一个 task_struct 数据结构
//	2.对于新进程,复制和继承父进程的资源
//	3.为新进程分配一个ID
static __latent_entropy struct task_struct *copy_process(
					unsigned long clone_flags,
					unsigned long stack_start,
					unsigned long stack_size,
					int __user *child_tidptr,
					struct pid *pid,
					int trace,
					unsigned long tls,
					int node)
{
	...
	// 做标志位的检查
	...
	// 为新进程分配一个 task_struct 数据结构和内核栈
	p = dup_task_struct(current, node);
	if (!p)
		goto fork_out;

	...

    // 检查进程数是否超过了进程的资源限制 RLIMIT_NPROC
	// RLIMIT_NPROC 规定了每个实际用户可拥有的最大子进程数
	if (atomic_read(&p->real_cred->user->processes) >=
			task_rlimit(p, RLIMIT_NPROC)) {
		if (p->real_cred->user != INIT_USER &&
		    !capable(CAP_SYS_RESOURCE) && !capable(CAP_SYS_ADMIN))
			goto bad_fork_free;
	}
	current->flags &= ~PF_NPROC_EXCEEDED;

	// 复制父进程的证书
	retval = copy_creds(p, clone_flags);
	if (retval < 0)
		goto bad_fork_free;

	...
	// max_threads 表示当前系统最多可以拥有的进程数量,这个值由系统内存大小来决定
	// nr_threads 是系统的一个全局变量,如果系统已经分配的进程数量到达或者超过系统最大进程数目,那么内存资源不足
	// 会导致新进程的创建失败
	if (nr_threads >= max_threads)
		goto bad_fork_cleanup_count;

	delayacct_tsk_init(p);	/* Must remain after dup_task_struct() */
	// flags 用于存放进程重要的标志位
	// 取消使用超级用户权限(PF_SUPERPRIV)并告诉系统这不是一个 worker 线程(PF_WQ_WORKER),因为 worker 线程
	// 由工作队列机制创建,PF_IDLE 表示新创建的进程处于空闲状态,这个标识位是为了解决空闲注入驱动的一些问题
	p->flags &= ~(PF_SUPERPRIV | PF_WQ_WORKER | PF_IDLE);
	// 设置 PF_FORKNOEXEC 标志位,因此这个进程暂时还不能执行
	p->flags |= PF_FORKNOEXEC;
	// 初始化两个链表
	// p->children 链表是新进程的子进程链表
	// p->sibling 链表是新进程的兄弟进程链表
	INIT_LIST_HEAD(&p->children);
	INIT_LIST_HEAD(&p->sibling);
	...
	// 初始化与进程调度相关的数据结构
	retval = sched_fork(clone_flags, p);
	if (retval)
		goto bad_fork_cleanup_policy;

	...
	// 复制父进程打开的文件等信息
	retval = copy_files(clone_flags, p);
	if (retval)
		goto bad_fork_cleanup_semundo;
	// 复制父进程的 fs_struct 数据结构等信息
	retval = copy_fs(clone_flags, p);
	if (retval)
		goto bad_fork_cleanup_files;
	...
	// 复制父进程的信号系统
	retval = copy_signal(clone_flags, p);
	if (retval)
		goto bad_fork_cleanup_sighand;
	// 复制父进程的进程地址空间的页表信息
	retval = copy_mm(clone_flags, p);
	if (retval)
		goto bad_fork_cleanup_signal;
	// 复制父进程的命名空间
	retval = copy_namespaces(clone_flags, p);
	if (retval)
		goto bad_fork_cleanup_mm;
	// 复制父进程中与 I/O 相关的内容
	retval = copy_io(clone_flags, p);
	if (retval)
		goto bad_fork_cleanup_namespaces;
	// 复制父进程的内核堆栈信息
	retval = copy_thread_tls(clone_flags, stack_start, stack_size, p, tls);
	if (retval)
		goto bad_fork_cleanup_io;

	stackleak_task_init(p);

	if (pid != &init_struct_pid) {
		// 为新进程分配一个 pid 数据结构和 PID
		pid = alloc_pid(p->nsproxy->pid_ns_for_children);
		if (IS_ERR(pid)) {
			retval = PTR_ERR(pid);
			goto bad_fork_cleanup_thread;
		}
	}

...
	// pid_nr() 分配一个全局的 PID,这个全局的 PID 是从 init 进程的命名空间的角度来看的
	// pid_vnr() 分配一个虚拟的 PID,它是从当前进程的命名空间的角度来看的
	p->pid = pid_nr(pid);
	// 设置 group_leader 和 TGID
	// 若子进程归属于父进程线程组
	if (clone_flags & CLONE_THREAD) {
		p->exit_signal = -1;
		p->group_leader = current->group_leader;
		p->tgid = current->tgid;
	// 若子进程是线程组的领头进程,也就是 CLONE_THREAD 标志被清零
	} else {
		if (clone_flags & CLONE_PARENT)
			p->exit_signal = current->group_leader->exit_signal;
		else
			p->exit_signal = (clone_flags & CSIGNAL);
		p->group_leader = p;
		p->tgid = p->pid;
	}

	...
	// 把新进程添加到进程管理的流程里
	if (likely(p->pid)) {
		ptrace_init_task(p, (clone_flags & CLONE_PTRACE) || trace);

		init_task_pid(p, PIDTYPE_PID, pid);
		// 判断新进程是否为领头进程
		if (thread_group_leader(p)) {
			...
		} else {
			...
		}
		...
	}
	...

	return p;

...
	return ERR_PTR(retval);
}

3. 进程的终止

进程主动终止主要有如下两个途径:

  • 从main()函数返回,链接程序会自动添加exit()系统调用
  • 主动调用exit()系统调用

进程被动终止主要有如下3个途径:

  • 进程收到一个自己不能处理的信号
  • 进程在内核态执行时产生了一个异常
  • 进程收到SIGKILL等终止信号

当一个进程终止时,Linux内核会释放它所占有的资源,并把这个消息告知父进程,而一个进程终止时可能有两种情况:

  • 若它先于父进程终止,那么子进程会变成—个僵尸进程,直到父进程调用wait()才算最终消亡
  • 若它在父进程之后终止,那么init进程将成为子进程的新父进程

exit()函数在用户空间C库中的定义如下:

// status 表示进程结束时的状态
void exit(int status);

exit()函数通过系统调用进入Linux内核,然后通过do_exit()函数来实现:

SYSCALL_DEFINE1(exit, int, error_code)
{
	do_exit((error_code&0xff)<<8);
}
  • 32
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值