「Learning Linux Kernel」- 进程管理(学习笔记)

进程 与 线程

进程,是处于执行期的程序,及其他相关资源(比如 打开的文件、挂起的信号、内核内部数据、一个或多个内存地址空间、一个或多个执行线程、存放全局变量的数据段 等等)的总称。进程不仅指正在执行的程序代码。

线程,是执行线程的简称,是在进程中活动的对象。每个线程具有独立的程序计数器(Program Counter)、线程栈、一组进程寄存器。内核调度的对象是线程,而不是进程,但是 Linux 并不特别区分线程与进程,对于 Linux 而言,线程只是一种特殊进程。

现代操作系统为进程提供两种虚拟机制:虚拟处理器与虚拟内存。虚拟处理器,然进程认为自身独占整个处理器。虚拟内存,让进程认为自己独占全部内存。但是,在 Linux 中,线程是共享虚拟内存的,但是每个线程都拥有各自的虚拟处理器。

进程从创建时开始存活。在 Linux 中,进程使用 fork() 系统调用,通过复制现有进程(父进程),来创建新进程(子进程)。在现代 Linux 内核中,fork() 使用 clone() 系统调用来实现。当 fork() 调用结束时,返回调用 fork() 函数的位置,继续执行后续代码,但是会“返回两次”。但实际上并“不是返回两次”,只是看起来“像返回两次”,因为 fork() 通过复制创建子进程,当 fork() 调用结束后,此时系统中会存在两个相同的进程(至少可执行代码是相同的)。在父进程中,fork() 返回子进程的进程 ID。在子进程中,fork 返回 0,表示创建成功。参考 fork() in C 文章,获取说明示例。

在通常情况下,创建进程是为了执行新的程序,而不是单纯的复制自身。因此,在 fork() 调用之后,我们会接着调用 exec() 函数,用于创建新的地址空间,并将新的程序载入其中以执行。

程序使用 exit() 系统调用退出执行,该函数会终结进程并释放资源。父进程可以通过 wait4() 查询子进程是否终结,这使父进程可以等待子进程执行结束。在进程退出后,被设置为僵死状态,直到父进程调用 wait() 或者 waitpid() 为止。

进程描述符 与 任务结构

在内核中,进程列表保存在名为任务队列(Task List)的双向循环链表中。链表的每一项都是 task_struct 结构(在 /include/linux/sched.h 中),包含具体进程的全部信息,被称为进程描述符(Process Descriptor)

task_struct(进程描述符、Process Descriptor)

进程描述符相对较大,因为其中包含的数据能够完整的描述一个正在执行的程序:打开的文件,进程地址空间,挂起的信号,进程的状态,以及其他信息。

分配进程描述符

在 Linux 2.6 以前,各进程的 task_struct 保存在各自内核栈的尾端,这样可以避免使用额外的寄存器(有些体系结构,比如 x86,寄存器较少),只要通过栈指针就可以计算出位置。

在 Linux 2.6 以后,由于使用 slab allocator 动态生成 task_struct 结构,以达到对象复用与缓存着色的目的。所以只需要在内核栈的尾端(对于向下增长的栈,栈底;对于向上增长的栈,栈顶)创建一个新的 thread_info 结构(在 /arch/<platform>/include/asm/thread_info.h 中),而 thread_info -> task 则是指向该进程的 task_struct 的指针。

进程描述符的存放

内核通过唯一的进程标识值(Process Identification Value)或 PID 来标识每个进程。PID 是 pid_t 隐含,实际上就是 int 类型。最大值默认设置为 32768(为与老版本的 Unix 和 Linux 兼容),尽管该值可以增加到 400 万(受 /include/linux/threads.h 定义的最大 PID 限制),系统管理员可以修改 /proc/sys/kernel/pid_max 来修改最大值。内核将 PID 包含在各自的进程描述符中(task_struct)。

在内核中,访问进程需要获取进程的 task_struct 指针(内核大部分处理进程的代码都是直接通过 task_struct 进行)。获取 task_struct 是通过 current 宏来查找,因此 current 宏的速度就尤为重要。硬件体系结构不同,current 宏的实现也不同。

在 x86 中,寄存器较少,因此 current 宏的实现是使用内核栈底部的 thread_info 结构来查找 task_struct 结构。通过 current_thread_info() 函数计算 thread_info 的地址,汇编代码如下:

movl $-8192, %eax # 这里假设栈空间大小为 8KB,8192=10000000000000
andl %esp, %eax

在 PowerPC 中,寄存器较多,task_struct 结构保存在 r2 寄存器中,以此 current 宏的实现是直接返回 r2 寄存器的值。

进程状态

task_struct -> state 描述进程的当前状态。进程处于五种状态之一:
1)TASK_RUNNING:正在运行(处于运行中 或 处于运行队列中)。在用户空间中正在执行的进程,只有这一种状态,其他状态都不是在执行的进程。
2)TASK_INTERRUPTIBLE:可中断,进程正在睡眠,等待某些某些条件。当条件满足后,内核将进程状态设置为运行。该状态的进程,也会因为接受到信号而提前被唤醒并随时准备投入运行。
3)TASK_UNINTERRUPTIBLE:不可终中断,类似于可中断状态,但是在该状态下,即使接受到信号,也不会被唤醒或投入运行。通常进程在等待关键资源。
4)TASK_TRACED:进程被其他进程跟踪。例如,使用 ptrace 进行调试。
5)TASK_STOPED:停止执行。进程没有投入运行,也不能投入运行。比如,收到 STOP TSTP TTIN TTOU 等等信号时。此外调试期间收到该信号也会进入这种状态。

设置当前进程状态

内核使用 set_task_state(task, state) 函数来调整进程状态。在必要时,它会设置内存屏障来强制其他处理器作重新排序(一般只在 SMP 系统中有此必要)。否则与 task->state = state; 等价。

函数 set_current_state(state) 与 set_task_state(current, state) 含义是等同的,参见 /include/linux/sched.h 文件。

进程上下文

程序通常在用户空间执行,当执行系统调用(或触发异常)时,会陷入内核空间,此时称内核“代表进程执行”并“处于进程上下文中”。在此上下文中,current 是有效的。除非此间隙有更高优先级的进程需要执行,并由调度器做出了相应调整,否则内核退出时,程序恢复在用户空间会继续执行。

程序只有通过系统调用和异常处理程序才能够陷入内核,即应用程序只能通过这些接口访问内核。

进程家族树

进程存在明显的继承关系,所有的进程都是 PID=1 的 init 进程的后台。系统启动的最后阶段将启动 init 进程,该进程读取初始化脚本,并执行其他相关程序,完成系统启动的整个过程。

系统的每个进程必有一个父进程,也可以拥有零个或多个子进程。进程的关系保存在 task_struct 结构中:task_struct->parent 指向父进程的进程描述符(task_struct);task_struct->children 指向子进程的链表。

init 进程的 task_struct 是作为 init_task 静态分配的。如下代码可以演示进程与父进程之间的关系:

struct task_struct *task;
for (task = current; task != &init_task; task = task->parent)
    ;

通过这种继承体系,可以任何一个进程出发,通过继承关系,查找到任意指定的其他进程。但是大多数时候,直接使用任务队列(Task List)就可以遍历系统中的所有进程,任务队列本身就是一个双向的循环列表。

获取下一个进程与获取上一个进程的代码:

list_entry(task->tasks.next, struct task_struct, tasks)
list_entry(task->tasks.prev, struct task_struct, tasks)

这两个例程通过 next_task(task) 与 prev_task(task) 宏提供。

而 for_each_process(task) 宏可以遍历整个任务队列,每次遍历都将指向下一个任务:

struct task_struct *task;
for_each_process(task) {
    /* this pointlessly prints the name and PID of each task */
    printk(“%s[%d]\n”, task->comm, task->pid);
}

进程创建

传统的进程创建,一般为,首先在新的地址空间中创建进程,读入可以执行文件,最后开始执行。但是,在 Unix 中,这些步骤分为两个函数 fork() 与 exec()。

首先,fork() 通过复制当前进程来创建子进程,与父进程的唯一区别是 PID、PPID、某些资源的统计量(统计量是进程相关的,所以没有必要复制)。然后,exec() 读取可执行文件,并将其载入地址空间开始执行。

写时复制

传统 fork() 系统调用直接将资源复制给新进程,fork() 的实际开销就是 复制父进程的页表 以及 为子进程创建进程描述符(task_struct)。但是,这种方法效率低下,因为拷贝的数据不是共享的,如果子进程立即执行新的程序,那么之前的拷贝将全部作废。

因此,在 Linux 中,fork() 使用写时复制(Copy on Write)的方法。此时,内核并不复制整个父进程的地址空间,子进程与父进程共享同一个拷贝,只有子进程需要写入数据时,数据才会复制,以使子进程写入自己的数据中。

在这种情况下,如果没有写入,就根本不会发生复制。比如,fork() 之后,立即调用 exec() 来启动其他程序,这是一种非常常见的场景。

fork()

fork() vfork() __clone() 库函数都使用 clone() 系统调用,并传入系列参数来指定需要共享的资源,然后由 clone() 调用 do_fork() 来完成。

do_fork() 定义在 /include/kernel/forc.c 中,该函数调用 copy_process() 函数,然后让进程开始运行。

copy_process() 函数的工作内容如下:
1)调用 dup_task_struct() 为新进程创建内核栈、tread_info、task_struct 结构,此时,子进程与父进程的描述符完全相同。
2)检查并确保,在新创建子进程后,当前用户所拥有的的进程数没有超过给它分配的资源限制。
3)处理子进程数据,子进程 task_struct 的很多数据都要清零或者设为初始值,比如统计量。
4)子进程被设置为 task_uninterruptible 状态,保证它不会投入运行。
5)调用 copy_flags() 以更新 task_struct->flags 成员。PF_SUPERPRIV 标志(进程是否有超级用户权限)被清零;PF_FORKNOEXEC 标志(进程还未调用 exec() 函数)被设置;
6)调用 alloc_pid() 为进程分配 PID
7)根据传入 clone() 标志,copy_process() 拷贝和共享打开的文件、文件系统信息、信号处理函数、进程地址空间、命令空间 等等。在一般情况下,这些资源会由给定进程的所有线程共享,否则这些资源对于每个进程是不同的,因此会被复制。
8)进行一些结尾工作,并返回指向子进程的指针。

回到 do_fork() 函数,此时 do_fork() 函数会立即唤醒子进程,让子进程先执行。这是有意的,因为多数场景下子进程会立即调用 exec() 函数,这样可以避免写时拷贝的额外开销。如果父进程先执行,可能会向地址空间写入数据。

vfork()

除了不复制父进程的页表项,vfork() 与 fork() 的功能相同。子进程作为父进程的一个单独的线程在它的地址空间中运行,父进程被阻塞,直到子进程退出或执行 exec() 调用。

子进程不能向地址空间写入数据。在以前这个优化很有意义,那时候的 fork() 没有 COW 特性。现在有 Linux fork() 支持 COW 特性,所以 vfork() 的优势仅在于不复制父进程的页表项。如果 Linux fork() 支持 COW 页表项,那么 vfork() 就完全没有用处了。另外由于 vfork() 的语义非常微妙(比如,如果 exec() 失败了呢?),所以最好不要使用 vfork() 调用,内核也无需实现该系统调用,或者当成 fork() 来实现(在 Linux 2.2 以前既是如此)。

vfork() 的实现是通过向 clone() 传递一个特殊标志来实现的:
1)在调用 copy_process() 时,task_struct 的 vfork_done 成员被设置为 NULL
2)在执行 do_fork() 函数时,如果给定特别标志,则 vfork_done 会指向一个特定地址;
3)子进程开始执行,父进程不是马上恢复执行,而是等到子进程通过 vfork_done 指针向其发送信号;
4)在调用 mm_release() 时,该函数用于进程退出内存地址空间,并检查 vfork_done 是否为空。不为空,则向父进程发送信号;
5)回到 do_fork() 函数,父进程恢复执行并返回;
6)如果一切顺利,子进程在新的地址空间运行,父进程也恢复在原地址运行。开销确实低,但是它的实现并不好。

线程的实现(Linux)

线程,在同一程序内共享地址空间,还共享打开的文件及其他资源。线程机制,支持并发曾需设计技术,在多处理器系统上,也能保证真正的并行处理。

Linux 实现线程的机制很独特,它将所有的 线程 当作 进程 来实现:1)线程仅被视为一个与其他进程共享某些资源的进程,即具有属于自己的 task_struct 结构,2)只是与其他进程共享某些资源(比如地址空间),3)没有与线程相关的数据结构与特别的调度算法。

在其他操作系统中,线程的实现比较复杂,比如会有一个进程描述符,该描述符中包含指向不同线程的指针。而在 Linux 中,则只是创建普通的 task_struct 结构,并在建立时,指定它们共享的资源。

创建线程

线程的创建与普通进程类似:只不过是调用 clone() 时的传递的标志参数不同,以指明需要共享的资源:
1)线程:clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0):这些标志指明要共享的资源;
2)对比 fork() 实现:clone(CLONE_SIGHAND, 0)
3)对比 vfork() 实现:clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0)

这些标志决定了子进程的行为方式,与父进程共享的资源类型。如下进行简单列举,这些都定义在 /include/linux/sched.h 文件中:

Flag 			Meaning
CLONE_FILES 	Parent and child share open files.
CLONE_FS 		Parent and child share filesystem information.
CLONE_IDLETASK 	Set PID to zero (used only by the idle tasks).
CLONE_NEWNS 	Create a new namespace for the child.
CLONE_PARENT 	Child is to have same parent as its parent.
CLONE_PTRACE 	Continue tracing child.
CLONE_SETTID 	Write the TID back to user-space.
CLONE_SETTLS 	Create a new TLS for the child.
CLONE_SIGHAND 	Parent and child share signal handlers and blocked signals.
CLONE_SYSVSEM 	Parent and child share System V SEM_UNDO semantics.
CLONE_THREAD 	Parent and child are in the same thread group.
CLONE_VFORK 	vfork() was used and the parent will sleep until the child wakes it.
CLONE_UNTRACED 	Do not let the tracing process force CLONE_PTRACE on thechild.
CLONE_STOP 		Start process in the TASK_STOPPED state.
CLONE_SETTLS 	Create a new TLS (thread-local storage) for the child.
CLONE_CHILD_CLEARTID 	Clear the TID in the child.
CLONE_CHILD_SETTID 		Set the TID in the child.
CLONE_PARENT_SETTID 	Set the TID in the parent.
CLONE_VM 				Parent and child share address space.

内核线程

内核也需要执行后台操作,这是通过内核线程(Kernel Thread)完成的。比如 flush、ksoftirqd 这些就是内核线程,使用 pe -ef 可以看到很多内核线程。

内核线程,是运行在内核空间的标准进程,只在内核空间运行。与普通进程的区别在于,内核线程没有独立的地址空间(指向地址空间的 mm 指针被设置为 NULL)。内核进程与普通进程一样,可以被调度,也可以被抢占。

在系统启动时,内核线程由其他内核线程创建。通过从 kthreadd 内核进程中衍生出所有新的内核线程,来实现内核线程的创建。

在 /include/linux/kthread.h 中,从现有内核线程产生新的内核线程的方法如下。新进程由 kthreadd 内核进程通过 clone() 系统调用而创建,新进程将运行 threadfn 函数,data 为其参数,namefmt 为进程名(采用类似 printf() 形式的可变参数)。在调用 wake_up_process() 来唤醒,否则不会运行。:

struct task_struct *kthread_create(int (*threadfn)(void *data),
									void *data,
									const char namefmt[],
									...)

可以调用 kthread_run() 来实现创建并运行,它是个调用 kthread_create() 与 wake_up_process() 的宏:

#define kthread_run(threadfn, data, namefmt, ...) 	\
({													\
	struct task_struct *k;							\
	k = kthread_create(threadfn, data, namefmt, ## __VA_ARGS__);\
	if (!IS_ERR(k))									\
		wake_up_process(k);							\
	k;												\
})

直到调用 do_exit() 时退出,或者内核的其他部分调用 kthread_stop() 退出,传递给 kthread_stop() 的参数为 kthread_create() 函数返回的 task_struct 结构的地址:

int kthread_stop(struct task_struct *k)

进程结束

进程结束时,内核将释放其占用的所有资源,并通知父进程。

进程退出一般是自发的:1)调用 exit() 系统调用;2)或者隐式退出(比如编译器在 main() 函数返回处放置的 exit() 系统调用);3)或者收到不能忽略的信号或异常时,被动终结。

不管进程是怎样终结的,大部分都要靠 do_exit() 来完成,他要完成下面这些繁琐的工作:
1)设置 task_struct 的 flags 成员的 PF_EXITING 标志;
2)调用 del_timer_sync() 删除任何内核定时器。在返回时,它保证没有计时器在队里,并且没有定时器处理器在运行。
3)如果 BSD 进程记账功能是开启的,do_exit() 调用 acct_update_integrals() 来输出记账信息;
4)然后,调用 exit_mm() 函数释放进程占用的 mm_sturct,如果没有别的进程使用它们(即这个地址空间没有被共享),就彻底释放。
5)接下来,调用 exit_sem() 函数,如果进程在排队,以等待 IPC 信号,进程将从这里离开队列;
6)调用 exit_files() 与 exit_fs() 来递减文件描述符、文件系统的数据的引用计数。如果其中某个引用计数的数值降为零,那就表示进程未使用该资源,可以释放。
7)接着,修改存放在 task_struct 的 exit_code 成员中的任务码,将其设置为 exit() 提供的退出码,或者完成任何其他由内核机制规定的退出动作。退出码保存在这里供父进程检索。
8)调用 exit_notify() 来向父进程发送信号,并给子进程寻找新的父进程(父进程为线程组其他线程,或 init 进程),并将进程状态设置为 EXIT_ZOMBIE 状态。
9)do_exit() 调用 schedule() 切换到新进程。因为处于 EXIT_ZONMBIE 状态的进程不会再被调度,所以这是最后执行的代码。do_exit() 不再返回。

至此,与进程相关联的资源都被释放(如果这些资源没有与他人共享)。进程处于 EXIT_COMBIE 退出状态。它所占用的资源就是 内核栈、thread_info、task_struct 结构,此时存在的唯一目的就是向父进程提供信息(比如退出信息)。父进程取回信息之后,或者通知内核那是无用信息后,进程支持有的资源将被释放,以归还给系统。

删除进程描述符

在调用 do_exit() 之后,进程不再执行,但是系统依旧保留该进程的进程描述符,这是为了在子进程结束后依旧能够获取其信息,因此才将 进程的退出 与 进程描述符的删除 分开处理。父进程获取已终结的子进程的信息后,或者通知内核它不再关注那些信息之后,子进程的 task_struct 结构才会被释放。

wait() 系列的函数都是通过 wait4() 系统调用实现的。wait4() 的标准动作是挂起调用它的进程,直到其中某个子进程退出,此时会返回子进程的 PID 值。此外,调用 wait() 时传入的指针会包含子进程的退出码。

当最终需要释放 task_struct 结构时,会调用 release_task() 以完成如下工作:
1)=> __exit_signal() => __unhash_process() => detach_pid(),detach_pid() 从 pidhash 上删除该进程,也要从任务队列中删除该进程。
2)__exit_signal() 释放已结束进程使用的所有剩余资源,并进行最终统计和记录;
3)如果该进程是线程组最后一个成员,并且领头进程是僵尸状态,那么 release_task() 就要通知僵尸领头的父进程;
4)release_task() 调用 put_task_struct() 释放进程内核栈 和 thread_info 结构所占的页,并释放 task_struct 所占的 slab 告诉缓存;
5)至此,进程描述符和所有进程独享的资源就全部释放。(进程也不会成为僵尸进程存在于系统中)

孤儿进程的困境

如果父进程先于子进程退出,必须想法为子进程寻找新的父进程,否则子进程退出时,没有父进程调用 wait() 函数,就不能回收子进程的 task_struct 结构,子进程就会一直处于僵尸状态存在于系统中。解决方法是为子进程寻找新的父进程,或者使用 init 进程。do_exit() 会调用 exit_notify(),而 exit_notify() 又会调用 forget_original_parent(),而后会调用 find_new_reaper() 来寻找父进程:

static struct task_struct* find_new_reaper(struct task_struct *father) {
	struct pid_namespace *pid_ns = task_active_pid_ns(father);
	struct task_struct *thread;
	thread = father;
	while_each_thread(father, thread)
	{
		if (thread->flags & PF_EXITING)
			continue;
		if (unlikely(pid_ns->child_reaper == father))
			pid_ns->child_reaper = thread;
		return thread;
	}
	if (unlikely(pid_ns->child_reaper == father)) {
		write_unlock_irq(&tasklist_lock);
		if (unlikely(pid_ns == &init_pid_ns))
			panic(“Attempted to kill init!”);
		zap_pid_ns_processes(pid_ns);
		write_lock_irq(&tasklist_lock);
		/*
		 * We can not clear ->child_reaper or leave it alone.
		 * There may by stealth EXIT_DEAD tasks on ->children,
		 * forget_original_parent() must move them somewhere.
		 */
		pid_ns->child_reaper = init_pid_ns.child_reaper;
	}
	return pid_ns->child_reaper;
}

上面的这段代码,试图寻找进程所在线程组内的其他进程。如果线程组内没有其他进程,则返回 init 进程。

当找到合适的父进程,将遍历子进程,为其设置新的父进程:

reaper = find_new_reaper(father);
list_for_each_entry_safe(p, n, &father->children, sibling) {
	p->real_parent = reaper;
	if (p->parent == father) {
		BUG_ON(p->ptrace);
		p->parent = p->real_parent;
	}
	reparent_thread(p, father);
}

然后,调用 ptract_exit_finish() 同样寻找父进程,不过这次是给 ptraced 的子进程寻找父进程:

void exit_ptrace(struct task_struct *tracer) {
	struct task_struct *p, *n;
	LIST_HEAD(ptrace_dead);
	write_lock_irq(&tasklist_lock);
	list_for_each_entry_safe(p, n, &tracer->ptraced, ptrace_entry)
	{
		if (__ptrace_detach(tracer, p))
			list_add(&p->ptrace_entry, &ptrace_dead);
	}
	write_unlock_irq(&tasklist_lock);
	BUG_ON(!list_empty(&tracer->ptraced));
	list_for_each_entry_safe(p, n, &ptrace_dead, ptrace_entry)
	{
		list_del_init(&p->ptrace_entry);
		release_task(p);
	}
}

这段代码遍历了两个链表:子进程链表和 ptrace 子进程链表,给每个子进程设置新的父进程。和两个链表是 Kernel 2.6 的新特性。当进程被追踪时,它的临时父进程设定为调试进程。此时,如果它的父进程退出,系统会为它和它的兄弟进程重新寻找父进程。在以前的内核中,系统需要遍历所有进程才能找到这些子进程。而现在是在一个单独的被 ptrace 追踪的子进程链表中搜索兄弟进程,减轻了遍历带来的消耗。

一旦有了新的父进程,当子进程退出时就不会有成为僵死进程的危险。init 进程会例行调用 wait() 来检查其子进程,清除所有与其关联的僵尸进程。

参考文献

fork() in C – GeeksforGeeks