【Linux 内核】进程管理

时间:2022-08-15 15:45:29

进程与程序的区别:程序是静态的一段代码,是一些保存在非易失性存储器的指令的有序集合,没有任何执行的概念;进程是一个动态的概念,它是程序执行的过程,包括动态创建、调度和消亡的整个过程,换句话说,进程是程序执行时的一个实例,即它是程序已经执行到何种程度的数据结构的汇集,也就是正在执行的程序代码的实时结果,从内核的观点看,进程的目的就是担当分配系统资源(CPU时间,内存等)的基本单位,是资源分配的最小单位。内核需要有效而又透明地管理所有细节。

从上面可知,进程是处于执行期的程序以及相关的资源的总称,实际上,完全可能存在两个或多个不同的进程执行的是同一个程序,并且两个或两个以上并存的进程还可以共享许多诸如打开的文件、地址空间之类的资源。

Linux - kernel - 2.6.32.61

进程描述符及任务结构

内核把进程的列表存放在叫做任务队列的双向循环链表中。链表中的每一项都是类型为 task_struct,称为进程描述符的结构,该结构定义在<linux/sched.h>文件中。进程描述符中包含一个具体进程的所有信息。其包含的数据能完整地描述一个正在执行的程序:它打开的文件,进程的地址空间,挂起的信号,进程的状态,还有其他更多信息。

struct task_struct {
	volatile long state;	/* -1 unrunnable, 0 runnable, >0 stopped */
	void *stack;
	unsigned int flags;	/* per process flags, defined below */

	struct list_head tasks; /*双向循环链表*/
	struct mm_struct *mm, *active_mm;
/* task state */
	int exit_state;
	int exit_code, exit_signal;

	pid_t pid;
	pid_t tgid;
	
	struct task_struct *real_parent; /* real parent process */
	struct task_struct *parent; /* recipient of SIGCHLD, wait4() reports */

	struct list_head children;	/* list of my children */
	struct list_head sibling;	/* linkage in my parent's children list */
	struct task_struct *group_leader;	/* threadgroup leader */

	struct fs_struct *fs;
/* open file information */
	struct files_struct *files;
	struct signal_struct *signal;
	struct sigpending pending;
	……
}
该结构中嵌套有双向循环链表,内核中的链表结构与众不同,它不是将数据结构塞入链表,而是将链表节点塞入数据结构。

分配进程描述符

Linux 通过 slab 分配器分配 task_struct 结构,这样能达到对象复用和缓存着色,当代码需要一个新的数据结构实例时,就可以从空闲链表中抓取一个,而不需要分配内存,再把数据放进去。以后,当不再需要这个数据结构的实例时,就把它放回空闲链表,而不是释放它。这里的空闲链表包含可供使用的、已经分配好的数据内存块。相当于一个内存池,这和STL中的内存管理类似。这样通过预先分配和重复使用 task_struct,可以避免动态分配和释放所带来的资源消耗

在内核中,访问任务通常需要获得指向其task_struct的指针。实际上,内核大部分处理进程的代码都是直接通过task_struct进行的。

进程创建

Unix 的进程创建将在新的地址空间里创建进程,读入可执行文件,最后开始执行这一步骤分解到两个单独的函数中去执行:fork()  和 exec() 函数族。首先,fork() 通过拷贝当前进程创建一个子进程。子进程与父进程的区别仅仅在于PID、PPID 和某些资源和统计量。exec() 函数负责读取可执行文件并将其载入地址空间开始运行。

Linux 的 fork() 使用写时拷贝页实现。写时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。写时拷贝简单地说就是,只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝,换句话说就是,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读方式共享。

那么这项技术有什么好处呢?如果不采用这项技术,fork() 时就发生资源的拷贝(父进程资源拷贝到子进程),而后立即执行 exec(),就会载入可执行文件,等于进程创建后就会马上运行一个可执行文件 ,那么之前大量拷贝的数据根本就不会使用,这就增大了进程创建的开销。有了写时拷贝,资源的复制只在需要时才拷贝,就有效地避免上述情况下的消耗,实际上,在一般情况下,进程创建后都会马上运行一个可执行文件。这样 fork() 的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。

fork()

Linux 通过 clone()系统调用实现fork()。实际上大部分的创建工作由do_fork()完成,该函数定义在 kernel/fock.c 文件中。我们可以跟踪源码查看其创建过程:

1. do_fork() 会调用copy_process() 函数

2. copy_process() 函数调用dup_task_struct(),从函数命我们就可大致的看出该函数的功能。我们大致的看下该函数:

static struct task_struct *dup_task_struct(struct task_struct *orig)//局部
{
	struct task_struct *tsk;
	struct thread_info *ti;
	unsigned long *stackend;

	int err;

	prepare_to_copy(orig);

	tsk = alloc_task_struct();//创建进程描述符,将描述符地址保存在tsk局部变量中

	ti = alloc_thread_info(tsk);//获取一块空闲内存区,用来存放新进程的thread_info结构和内核栈

 	err = arch_dup_task_struct(tsk, orig);//复制父进程描述符给子进程

	tsk->stack = ti;//在栈顶(栈底)创建struct thread_info,就是在内核栈的尾端指向thread_info

	setup_thread_stack(tsk, orig);//复制父进程的thread_info给子进程,但其task指向子进程的task_struct
	stackend = end_of_stack(tsk);//设置栈底

	return tsk;
}
上面的 setup_thread_stack(tsk, orig); 是通过偏移地址找到 task 的,下面为其中的一种实现

static inline void setup_thread_stack(struct task_struct *p, struct task_struct *org)
{
	*task_thread_info(p) = *task_thread_info(org);
	task_thread_info(p)->task = p;
}

static inline unsigned long *end_of_stack(struct task_struct *p)
{
	return (unsigned long *)(task_thread_info(p) + 1);
}
3.接下来就是子进程着手使自己与父进程区别开来。进程描述符内的许多成员都要被清零或设为初始值。这些操作均由 copy_process()  函数完成。那些不是继承而来的进程描述符成员,主要是统计信息。task_struct 中的大多数数据都依然未被修改。

4. 子进程的状态被设置为 TASK_UNINTERRUPTIBLE,以保证它不会投入运行。

5. 根据传递给 clone() 的参数标志,copy_process() 拷贝或共享打开的文件、文件系统信息、信号处理函数、进城地址空间和命名空间等。在一般情况下,这些资源会被给定进程的所有县城共享,否则,这些资源对每个进程是不同的,因此被拷贝在这里。

/* copy all the process information */
	if ((retval = copy_semundo(clone_flags, p)))
		goto bad_fork_cleanup_audit;
	if ((retval = copy_files(clone_flags, p)))
		goto bad_fork_cleanup_semundo;
	if ((retval = copy_fs(clone_flags, p)))
		goto bad_fork_cleanup_files;
	if ((retval = copy_sighand(clone_flags, p)))
		goto bad_fork_cleanup_fs;
	if ((retval = copy_signal(clone_flags, p)))
		goto bad_fork_cleanup_sighand;
	if ((retval = copy_mm(clone_flags, p)))
		goto bad_fork_cleanup_signal;
	if ((retval = copy_namespaces(clone_flags, p)))
		goto bad_fork_cleanup_mm;
	if ((retval = copy_io(clone_flags, p)))
		goto bad_fork_cleanup_namespaces;
	retval = copy_thread(clone_flags, stack_start, stack_size, p, regs);
6. 调用alloc_pid 为新进程分配i一个有效的 PID。

7. 最后,copy_process() 函数做好扫尾工作(中间出现分配错误,进行容错处理),然后返回指向子进程的指针。

再回到do_fork() 函数,如果copy_process() 函数成功返回,新创建的子进程被唤醒并让其投入运行。

进程终止

所有进程的终止都是由do_exit()函数来处理的,这个函数从内核数据结构中删除对终止进程的大部分引用(该函数定义于kernel/exit.c)

void do_exit(long code)
{
	//获取当前进程
	struct task_struct *tsk = current;

	exit_irq_thread();

  //sets PF_EXITING,表示进程正在被删除
	exit_signals(tsk);  
	
	//释放task_struct 的 mm_struct 内存
	//如果没有别的进程使用它们,就彻底释放它们
	exit_mm(tsk);

  //退出等候IPC信号队列
	exit_sem(tsk);
		
	//分别以递减文件描述符、文件系统数据的引用计数,如果某个引用计数的数值将为零,则彻底释放
	exit_files(tsk);
	exit_fs(tsk);

  //向父进程发出信号,给子进程重新找养父
	exit_notify(tsk, group_dead);

	/* causes final put_task_struct in finish_task_switch(). */
	//切换到新的进程
	tsk->state = TASK_DEAD;
	schedule();
  ……
}

经过上面的 do_exit() 操作,与进程相关联的所有资源都被释放掉了,该进程就成了僵死进程,进程占用的内存资源就是其本身信息,也就是内核栈、thread_info 结构和 task_struct 结构。此时进程存在的唯一目的就是向它的父进程提供信息。父进程检索到信息后,或者通知内核那是无关的信息后,由进程所占用的内存被释放,归还给系统使用。

在调用了do_exit() 之后,系统还保留了它的进程描述符,这样做可以让系统有办法在子进程终结后仍能获得它的信息。因此,进程终结时所需的清理工作和进程描述符的删除被分开执行。

进程删除

Unix 内核只允许在父进程发出了与终止的进程相关的wait() 类系统调用之后,才允许内核在进程一终止后就丢弃包含在进程描述符字段中的数据。这就是引入僵死状态的原因:尽管从技术上来说进程已死,但必须保存它的描述符,直到父进程得到通知。

当最终需要释放进程描述符时,release_task() 会被调用。该函数从僵死进程的描述符中分离出最后的数据结构,对僵死进程的处理有两种可能的方式:如果父进程不需要接收来自子进程的信号,就调用do_exit();如果已经给父进程发送了一个信号,就调用wait4() 或 waitpid() 系统调用。在后一种情况下,函数还将回收进程描述符所占用的内存空间,而在前一种情况下,内存的回收将由进程调度程序来完成。

后一种过程亦可表述为 sys_wait4()(SYSCALL_DEFINE4()) -> do_wait() -> wait_task_zombie() (do_wait_threaed() -> wait_consider_task() )-> release_task()

下面为release_task()函数:

void release_task(struct task_struct * p)
{
	struct task_struct *leader;
	int zap_leader;
repeat:
	tracehook_prepare_release_task(p);
	
	//减少该进程拥有者的进程使用计数,Linux用一个单用户高速缓存统计和记录每个用户占用的进程数目、文件数目
	atomic_dec(&__task_cred(p)->user->processes);
	proc_flush_task(p);

	write_lock_irq(&tasklist_lock);//加写锁
	tracehook_finish_release_task(p);
	//释放该进程的signal_struct
	__exit_signal(p);

  /*
	 * If we are the last non-leader member of the thread
	 * group, and the leader is zombie, then notify the
	 * group leader's parent process. (if it wants notification.)
	 */
	zap_leader = 0;
	leader = p->group_leader;
	if (leader != p && thread_group_empty(leader) && leader->exit_state == EXIT_ZOMBIE) {
		BUG_ON(task_detached(leader));
		do_notify_parent(leader, leader->exit_signal);
		/*
		 * If we were the last child thread and the leader has
		 * exited already, and the leader's parent ignores SIGCHLD,
		 * then we are the one who should release the leader.
		 *
		 * do_notify_parent() will have marked it self-reaping in
		 * that case.
		 */
		zap_leader = task_detached(leader);

		/*
		 * This maintains the invariant that release_task()
		 * only runs on a task in EXIT_DEAD, just for sanity.
		 */
		if (zap_leader)
			leader->exit_state = EXIT_DEAD;
	}

	write_unlock_irq(&tasklist_lock);//关写锁
	
	//释放线程所持有的所有资源
	release_thread(p);
	call_rcu(&p->rcu, delayed_put_task_struct);

	p = leader;
	if (unlikely(zap_leader))
		goto repeat;
}

如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则这些成为孤儿的进程就会在退出时永远处于僵死状态,白白地耗费内存。对于这个问题,解决方法是给子进程在当前线程组内找一个线程作为父亲,如果不行,就让 init 进程作为它们的父进程,前面说到的在 do_exit() 中调用 exit_notify() 函数,该函数会调用 forget_original_parent(),而后者会调用 find_new_reaper() 来执行寻父过程。
这样僵死状态的进程就有了父进程,父进程会例行的调用 wait() 类函数来检查其子进程,清楚所有与其相关的僵死进程。wait() 函数是阻塞型函数。


参考资料:

《Linux 内核设计与实现》

《深入理解 Linux 内核》