Linux 学习笔记——第二章 进程管理和调度(4)
《深入 Linux 内核架构》阅读笔记。书籍参考的内核版本较老,文章参考的 Linux 内核版本为 5.4.103,并根据新版内核调整了一些代码片段
进程复制
Linux 中用于复制进程的系统调用有 3 个:
-
fork
是重量级调用,因为它建立了父进程的一个完整副本,然后作为子进程执行。为减少与该调用相关的工作量,Linux 使用了写时复制(copy-on-write)技术 -
vfork
类似于fork
,但并不创建父进程数据的副本。相反,父子进程之间共享数据。这节省了大量 CPU 时间。vfork
设计用于子进程形成后立即执行execve
系统调用加载新程序的情形。由于
fork
使用了写时复制技术,vfork
速度方面不再有优势,因此应该避免使用它。 -
clone
产生线程,可以对父子进程之间的共享、复制进行精确控制。
写时复制
内核使用了写时复制(Copy-On-Write,COW)技术,以防止在 fork
执行时将父进程的所有数据复制到子进程。该技术利用了下述事实:进程通常只使用了其内存页的一小部分。因此创建进程时并不复制进程的整个地址空间,而是只复制其页表,fork
之后父子进程的地址空间指向同样的物理内存页。父子进程不能修改彼此的页,这也是两个进程的页表对页标记了只读访问的原因。
只要一个进程试图向复制的内存页写入,处理器会向内核报告访问错误(此类错误被称作缺页异常)。内核然后查看额外的内存管理数据结构,检查该页是否可以用读写模式访问,还是只能以只读模式访问。如果是后者,则必须向进程报告段错误。如果页表项将一页标记为“只读”,但通常情况下该页应该是可写的,内核可根据此条件来判断该页实际上是 COW 页。因此内核会创建该页专用于当前进程的副本,当然也可以用于写操作。
COW 机制使得内核可以尽可能延迟内存页的复制,更重要的是,在很多情况下不需要复制。
执行系统调用
早期 Linux 中 fork
、vfork
和 clone
系统调用的入口点分别是 sys_fork
、sys_vfork
和 sys_clone
函数,其定义依赖于具体的体系结构。上述函数的任务是从处理器寄存器中提取由用户空间提供的信息,调用体系结构无关的 do_fork
函数,后者负责进程复制。该函数的原型如下:
// kernel/fork.c
long do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr)
{
struct kernel_clone_args args = {
.flags = (lower_32_bits(clone_flags) & ~CSIGNAL),
.pidfd = parent_tidptr,
.child_tid = child_tidptr,
.parent_tid = parent_tidptr,
.exit_signal = (lower_32_bits(clone_flags) & CSIGNAL),
.stack = stack_start,
.stack_size = stack_size,
};
if (!legacy_clone_args_valid(&args))
return -EINVAL;
return _do_fork(&args);
}
- clone_flags 是一个标志集合,用来指定控制复制过程的一些属性。
- stack_start 是用户状态下栈的起始地址。
- stack_size 是用户状态下栈的大小。
- parent_tidptr 和 child_tidptr 是指向用户空间中地址的两个指针,分别指向父子进程的 PID。
不同的 fork
变体,主要是通过标志集合区分。
而在 Linux 5.4.103 中,大多数架构通过宏定义实现这几个系统调用。以 x86 架构为例:
// arch/x86/include/asm/unistd.h
#ifndef _ASM_X86_UNISTD_H
#define _ASM_X86_UNISTD_H 1
// ...
# define __ARCH_WANT_SYS_FORK
# define __ARCH_WANT_SYS_VFORK
# define __ARCH_WANT_SYS_CLONE
# define __ARCH_WANT_SYS_CLONE3
#endif /* _ASM_X86_UNISTD_H */
在 kernel/fork.c 会通过这些宏定义实现具体的函数,以 fork
和 vfork
为例:
// kernel/fork.c
#ifdef __ARCH_WANT_SYS_FORK
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
struct kernel_clone_args args = {
.exit_signal = SIGCHLD,
};
return _do_fork(&args);
#else
/* can not support in nommu mode */
return -EINVAL;
#endif
}
#endif
#ifdef __ARCH_WANT_SYS_VFORK
SYSCALL_DEFINE0(vfork)
{
struct kernel_clone_args args = {
.flags = CLONE_VFORK | CLONE_VM,
.exit_signal = SIGCHLD,
};
return _do_fork(&args);
}
#endif
可以看到这几个函数都会调用 _do_fork
,它负责进程的创建和复制。
_do_fork
的实现
_do_fork
完成的工作主要有:
- 根据调用标志初始化 ptrace 标志
copy_process
- 根据其他标志完成处理程序
wake_up_new_task
。将其 task_struct 添加到调度器队列。调度器也有机会对新启动的进程给予特别处理,这使得可以实现一种策略以便新进程有较高的几率尽快开始运行,另外也可以防止一再地调用fork
浪费 CPU 时间。- 如果设置了 CLONE_VFORK 标志,调用
wait_for_vfork_done
。如果使用vfork
机制,必须启用子进程的完成机制,task_struct 的vfork_done
成员即用于该目的。父进程在该变量上进入睡眠状态,直至子进程退出。在进程终止(或用execve
启动新应用程序)时,内核自动调用complete(vfork_done)
。这会唤醒所有因该变量睡眠的进程。
copy_process
的实现
-
检查标志。复制进程的行为受到相当多标志的控制,内核必须判断哪些标志组合没有意义。例如创建新命名空间的同时要求共享根目录就是不被允许的。
// kernel/fork.c if ((clone_flags & (CLONE_NEWNS|CLONE_FS)) == (CLONE_NEWNS|CLONE_FS)) return ERR_PTR(-EINVAL);
-
在内核建立了自洽的标志集之后,则用
dup_task_struct
来建立父进程 task_struct 的副本。用于子进程的新的 task_struct 实例可以在任何空闲的内核内存位置分配。父子进程的 task_struct 实例只有一个成员不同:新进程分配了一个新的核心态栈,即 task_struct->stack。 -
在
dup_task_struct
成功之后,内核会检查当前的特定用户在创建新进程之后,是否超出了允许的最大进程数目:// kernel/fork.c 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; }
-
如果资源限制无法防止进程建立,则调用接口函数
sched_fork
,以便使调度器有机会对新进程进行设置。该例程会初始化一些统计字段,在多处理器系统上,如果有必要可能还会在各个 CPU 之间对可用的进程重新均衡一下。此外进程状态设置为 TASK_RUNNING,由于新进程事实上还没运行,这个状态实际上不是真实的。但这可以防止内核的任何其他部分试图将进程状态从非运行改为运行,并在进程的设置彻底完成之前调度进程。 -
接下来会调用许多形如
copy_xyz
的例程,以便复制或共享特定的内核子系统的资源。由于子进程的 task_struct 是从父进程的 task_struct 精确复制而来,因此相关的指针最初都指向同样的资源,或者说同样的具体资源实例
如果 CLONE_ABC 置位,则两个进程会共享 res_abc。此外,为防止与资源实例关联的内存空间释放过快,还需要对实例的引用计数器加 1,只有进程不再使用内存时,才能释放。如果父进程或子进程修改了共享资源,则变化在两个进程中都可以看到。
如果 CLONE_ABC 没有置位,接下来会为子进程创建 res_abc 的一份副本,新副本的资源计数器初
始化为1。 因此在这种情况下,如果父进程或子进程修改了资源,变化不会传播到另一个进程。
以 copy_files
为例:
// kernel/fork.c
static int copy_files(unsigned long clone_flags, struct task_struct *tsk)
{
struct files_struct *oldf, *newf;
int error = 0;
/*
* A background process may not have any files ...
*/
oldf = current->files;
if (!oldf)
goto out;
if (clone_flags & CLONE_FILES) {
atomic_inc(&oldf->count);
goto out;
}
newf = dup_fd(oldf, &error);
if (!newf)
goto out;
tsk->files = newf;
error = 0;
out:
return error;
}
-
copy_process
还要负责对进程关系的处理。对普通进程,父进程是分支进程。对于线程来说有些不同:由于线程被视为分支进程内部的第二(或第三、第四,等等)个执行序列,其父进程应是分支进程的父进程。// kernel/fork.c /* CLONE_PARENT re-uses the old parent */ if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) { p->real_parent = current->real_parent; p->parent_exec_id = current->parent_exec_id; if (clone_flags & CLONE_THREAD) p->exit_signal = -1; else p->exit_signal = current->group_leader->exit_signal; } else { p->real_parent = current; p->parent_exec_id = current->self_exec_id; p->exit_signal = args->exit_signal; }
对线程来说还需要另一个校正,即普通进程的线程组组长是进程本身。对线程来说,其组长是当前进程的组长:
// kernel/fork.c if (clone_flags & CLONE_THREAD) { p->group_leader = current->group_leader; p->tgid = current->tgid; } else { p->group_leader = p; p->tgid = p->pid; }
新进程必须被归入上一篇文章描述的 ID 数据结构体系中。
thread_group_leader
只检查新进程的 pid 和 tgid 是否相同。倘若如此,则该进程是线程组的组长。在这种情况下,还需要完成更多必要的工作。// kernel/fork.c init_task_pid_links(p); if (likely(p->pid)) { init_task_pid(p, PIDTYPE_PID, pid); if (thread_group_leader(p)) { init_task_pid(p, PIDTYPE_TGID, pid); init_task_pid(p, PIDTYPE_PGID, task_pgrp(current)); init_task_pid(p, PIDTYPE_SID, task_session(current)); // ... attach_pid(p, PIDTYPE_TGID); attach_pid(p, PIDTYPE_PGID); attach_pid(p, PIDTYPE_SID); } else { // ... } attach_pid(p, PIDTYPE_PID); nr_threads++; }
最后,PID本身被加到ID数据结构的体系中。创建新进程的工作就此完成。
内核线程
内核线程是直接由内核本身启动的进程。内核线程实际上是将内核函数委托给独立的进程,与系统中其他进程“并行”执行(实际上,也并行于内核自身的执行)。内核线程经常称之为(内核)守护进程。基本上,有两种类型的内核线程:
- 类型1:线程启动后一直等待,直至内核请求线程执行某一特定操作。
- 类型2:线程启动后按周期性间隔运行,检测特定资源的使用,在用量超出或低于预置的限制值时采取行动。内核使用这类线程用于连续监测任务。
调用 kernel_thread
函数可启动一个内核线程。
// kernel/fork.c
pid_t kernel_thread(int (*fn)(void *), void *arg, unsigned long flags)
{
struct kernel_clone_args args = {
.flags = ((lower_32_bits(flags) | CLONE_VM |
CLONE_UNTRACED) & ~CSIGNAL),
.exit_signal = (lower_32_bits(flags) & CSIGNAL),
.stack = (unsigned long)fn,
.stack_size = (unsigned long)arg,
};
return _do_fork(&args);
}
产生的线程将执行用 fn 指针传递的函数,而用 arg 指定的参数将自动传递给该函数。flags 中可指定 CLONE 标志。
因为内核线程是由内核自身生成的,应该注意下面两个特别之处。
- 它们在 CPU 的管态(supervisor mode)执行,而不是用户状态
- 它们只可以访问虚拟地址空间的内核部分(高于 TASK_SIZE 的所有地址),但不能访问用户空间。
task_struct 中包含了指向 mm_structs 的两个指针:mm
和 active_mm
。虚拟地址空间分成两个部分:底部可以由用户层程序访问,上部则专供内核使用。在内核代表用户层程序运行时(例如,执行系统调用),虚拟地址空间的用户空间部分由 mm
指向的 mm_struct 实例描述。每当内核执行上下文切换时,虚拟地址空间的用户层部分都会切换,以便与当前运行的进程匹配。
这为优化提供了一些余地,可遵循所谓的惰性 TLB 处理(lazy TLB handling)。由于内核线程不与任何特定的用户层进程相关,内核并不需要切换虚拟地址空间的用户层部分,保留旧设置即可。假如内核线程之后运行的进程与之前是同一个。这种情况下,内核并不需要修改用户空间地址表,地址转换后备缓冲器(即 TLB)中的信息仍然有效。
可以使用 kernel/kthread.c 中的辅助函数 kthread_create_on_node
和 kthread_create_on_cpu
来创建内核线程。
内核线程会出现在系统进程列表中,但在 ps 的输出中由方括号包围,以便与普通进程区分。
~$ ps fax
PID TTY STAT TIME COMMAND
2 ? S 0:00 [kthreadd]
3 ? I< 0:00 \_ [rcu_gp]
4 ? I< 0:00 \_ [rcu_par_gp]
6 ? I< 0:00 \_ [kworker/0:0H-kb]
7 ? I 0:00 \_ [kworker/0:1-rcu]
9 ? I< 0:00 \_ [mm_percpu_wq]
10 ? S 0:00 \_ [ksoftirqd/0]
11 ? I 0:01 \_ [rcu_sched]
12 ? S 0:00 \_ [migration/0]