现代操作系统都可以同时运行若干进程,对于单核CPU,给定时刻只会有一个进程运行,CPU快速的在各个进程中切换,给用户以多个进程同时运行的错觉,对于多核CPU,可以真正并发运行多个进程,取决于CPU的数目。
内核在各个进程切换过程中,必须做到进程之间不能互相干扰,而且需要CPU时间必须在各种应用程序中尽可能公平的共享,进程管理和调度有两个主要任务:
- 内核决定各个进程分配多长时间,何时切换到下一个进程;
- 进程从A切换到B时,需要确保进程B的执行环境与上一次撤销其处理资源时完全相同;
这两个任务称之为调度器的内核子系统的职责。
进程生命周期
进程不是总是可以立即运行,有时必须等待外部信号,在信号发生时进程无法运行。当调度器在进程之间切换时,必须知道系统中每个进程的状态。进程可能有以下几种状态:
- 运行:进程正在运行
- 等待:进程能够运行,但没有分到时间片,调度器下一次可以选择此进程
- 睡眠:进程正在睡眠无法运行,正在等待外部事件
- 僵尸:进程资源已经释放,但还保留进程表中的项
- 终止:进程退出
Linux进程管理还需另外两种状态选项:用户状态和核心态。用户态受到各种限制,内核态却有无上的权利。进程通常处于用户状态,只能访问自身数据,无法干扰其他进程。如果进程想要访问系统数据或者功能,必须切换到核心态,用户态切换到核心态有两种方法,第一种是系统调用(系统调用)[/reading/linux-kernel-system-call.html],另一种是通过中断。内核的抢占调度模型建立了一个层次结构,用户判断哪些进程状态可以由其他状态抢占。
- 普通进程总是可能被抢占
- 如果进程处于核心态并在处理系统调用,那么其他进程是无法抢占,但中断可以终止系统调用
- 中断可以暂停出于用户状态和核心态的进程,中断有最高优先级
内核有一个内核抢占的选项,支持紧急情况下切换到另一个进程,甚至当前处于核心态执行系统调用。
进程表示
Linux内核涉及进程和进程所有算法都围绕一个名为 task_struct
的数据结构建立,该结构定义在 include/sched.h
中,这是系统中重要的一个结构。
task_struct
结构复杂,要想都搞清楚着实不易,但该结构可以分为各个部分,每个部分表示进程的不同方面:
- 状态和执行信息:如待定信号,进程ID(pid),父进程及其他有关进程的指针,优先级和程序执行有关的信息
- 有关分配的虚拟内存信息
- 进程身份凭据,如用户ID,组ID及权限
- 使用的文件包含程序代码的二进制文件,以及进程所处理的所有文件的文件系统信息
- 线程信息记录该进程特定于CPU的运行时间数据
- 在与其他应用程序协作时所需的进程间通信有关的信息
- 该进程所用的信号处理程序,用于响应到来的信号
task_struct
的许多成员并非简单类型变量,而是其他数据结构的指针。下面介绍一些结构的重要数据成员。
当前状态 state
state
指定了当前状态,可以使用
TASK_RUNNING
:进程处于可运行状态,这并不意味着实际分配了CPU,进程可能会一直等到调度器选中它TASK_INTERRUPTIBLE
:针对等待某事件或其他资源的睡眠进程设置的。在内核发送信号给进程表明事件已经发生,进程状态变为TASK_RUNNING
TASK_UNINTERRUPTIBLE
:用于因内核指示而停用的睡眠进程,他们不能由外部信号唤醒,只能由内核亲自唤醒TASK_STOPPED
:表示进程特意停止运行,例如由调度器暂停TASK_TRACED
:本来不是进程状态,用于从停止的进程中,将当前被调试的那些与常规的进程区分开
下面常量可以用于struct task_struct
进程状态字段,也可以用于exit_state
字段,后者明确地用于退出进程
EXIT_ZOMBIE
:僵尸进程EXIT_DEAD
:状态则是指wait
系统调用已经发出,而进程完全从系统移除之前的状态,只有多个线程对同一个进程发出wait
调用时,才有意义
资源限制 rlimit
Linux提供资源限制功能,该机制利用了task_struct
中的 rlim 数组,数组项类型为 struct rlimit
:
struct rlimit {
unsigned long rlim_cur;
unsigned long rlim_max;
};
上述定义可以用于多种不同的资源类型:
rlim_cur
:是进程当前资源限制,也称之为软限制rlim_max
:是该限制的最大容许值,称之为硬限制
系统调用setrlimit
来增减当前限制,但不能超过rlim_max
指定的值。rlim
数组中的位置标志了受限资源的类型,这也是内核需要定义的处理器常数,将资源与位置关联起来的原因。如果某一类资源没有限制,则将rlim_max
设置为RLIM_INFINITY
。以下是几种宏定义:
常数 | 语义 |
---|---|
RLIMIT_CPU | 按毫秒计算的最大CPU时间 |
RLIMIT_FSIZE | 允许的最大文件长度 |
RLIMIT_DATA | 数据段的最大长度 |
RLIMIT_STACK | (用户态)栈最大长度 |
RLIMIT_CORE | 内存转储文件最大长度 |
RLIMIT_NOFILE | 打开文件最大数目 |
RLIMIT_SIGPENDING | 待决信号的最大数目 |
RLIMIT_MSGQUEUE | 信息队列最大数目 |
在proc
文件系统中,可以从文件/proc/self/limits
查看当前rlimit
限制。
进程类型
典型的UNIX进程包括:由二进制代码组成的应用程序、单线程、分配给应用程序的一组资源。新进程是由fork
和exec
系统调用完成的。
- fork 生成当前进程的一个相同副本,成为子进程。
- exec 从一个可执行文件加载另一个应用程序,来代替当前运行的进程。
- clone 工作原理基本与fork相同,但新进程不是独立于父进程的,可以与其共享某些资源,如父进程的内存数据等。
clone
用于实现线程,但仅仅系统调用还不能做到这点,还需要用户空间库才能提供完整实现,如Linuxthreads和Next Generation Posix Threads等。
命名空间
命名空间提供了虚拟化的一种轻量级形式,传统上,在Linux以及其他衍生的UINX扁蹄中,许多资源是全局管理的,例如系统中的所有进程按照惯例都是通过PID标识的,这意味着内核必须管理一个全局的PID列表,而且,所有调用者通过uname系统调用返回的系统相关信息都是相同的。用户的管理方式相同,即各个用户是通过一个全局唯一的UID号标识。
有些情况下,如提供Web主机的提供商给每个用户提供Linux计算机全部访问权限,包括root权限在内,传统上,要为每个用户准备一台计算机,代价很高,使用KVM虚拟机是另一种办法,但资源分配做的不是很好,计算机各个用户都需要一个独立的内核,以及一份完全安装好的用户层应用。
命名空间提供了一种不同的解决方案,所需资源较少,命令空间只使用一个内核,前述的所有全局资源都通过命令空间抽象起来,这使得可以将一组进程放置到容器中,各个容器彼此隔离,隔离可以使得容器的成员与其他容器毫无关系,但也可以通过允许容器进程一定的共享,来降低容器之间的间隔。例如,容器可以设置为使用自身的PID集合,但仍然与其他容器共享部分文件系统。
Linux 系统对简单形式的命令空间的支持已经有很长时间了,主要是chroot系统调用,该方法可以将进程限制到文件系统的某一部分。因而是一种简单的命名空间机制,但真正的命名空间能够控制的功能远超文件系统视图。
新命令空间可以用两种方法创建:
- fork 或 clone 系统调用创建新进程时,有特定选项可以控制是与父进程共享命名空间,还是建立新的命名空间
- unshare 系统调用将进程的某些部分从父进程分离,其中也包括命名空间
在通过上述两种方式从父进程命名空间分离后,从该进程角度看,改变全局属性不会传播到父进程命名空间,而父进程修改也不会传播到子进程。
命名空间的实现需要两个部分:每个子系统的命名空间结构,将此前所有的全局组建包装的命名空间中;将给定进程关联到所属各个命名空间的机制。
子系统此前的全局属性现在封装到命名空间中,每个进程关联到一个选定的命名空间,每个可以感知命名空间的内核子系统都必须提供一个数据结构,将所有通过命名空间形式提供的对象集中起来。struct nsproxy
用户汇集指向特定于子系统的命名空间包装器的指针:
struct nsproxy {
atomic_t count;
struct uts_namespace *uts_ns;
struct ipc_namespace *ipc_ns;
struct mnt_namespace *mnt_ns;
struct pid_namespace *pid_ns;
struct net *net_ns;
};
当前内核的以下范围可以感知到命名空间:
- UTS(UNIX Timesharing System)命名空间包含了运行内核的名称、版本、底层体系结构类型等信息
- 保存在
struct ipc_namespace
中的所有与进程间通信IPC有关信息 - 已经装载的文件系统的视图,在
struct mnt_namespace
中给出 - 有关进程ID的信息,由
struct pid_namespace
提供 struct net_ns
包含所有网络相关的命名空间参数
task_struct
结构成员 struct nsproxy *nsproxy
保存关联到自身的命名空间视图。因为使用指针,多个进程可以共享一组名字空间,这样,修改给定的命名空间,对所有属于该命名空间的进程都是可见的。
init_nsproxy
定义了初始的全局命名空间,其中维护了指向各子系统初始的命名空间对象的指针:
struct nsproxy init_nsproxy = {
.count = ATOMIC_INIT(1),
.uts_ns = &init_uts_ns,
#if defined(CONFIG_POSIX_MQUEUE) || defined(CONFIG_SYSVIPC)
.ipc_ns = &init_ipc_ns,
#endif
.mnt_ns = NULL,
.pid_ns = &init_pid_ns,
#ifdef CONFIG_NET
.net_ns = &init_net,
#endif
};
进程ID号
UNIX进程总会分配一个号码用于在命令空间中唯一的标志它们,该号码被称作PID。用fork和clone产生的每个进程都会由内核自动地分配唯一的新的PID值。
进程除了PID这个值还有其他的ID,有下列几种可能的类型:
- 处于某个线程组中的所有统一的线程组ID(TGID),如果进程没有使用线程,则PID和TGID相同,线程组中的主进程被称作组长。通过clone组建的所有线程
task_struct
的group_leader
成员,会指向组长的task_struct
。 - 独立进程可以组成进程组(
setpgrp
系统调用),进程组成员的task_struct
成员pgrp
属性都相同,即组长PID。 - 几个进程组可以合并成一个会话。会话中所有进程都有相同的会话ID,保存在
task_struct
的session
成员中。SID可以使用setsid
系统调用设置。
进程关系
除了源于ID连接的关系外,内核还负责管理建立在UNIX进程创建模型之上的家族关系:
- 如果进程A分支形成进程B,进程A称之为父进程二进程B是子进程
- 如果进程B再次分支建立进程C,进程A和进程C之间称之为祖孙关系
- 如果进程A分支若干次形成B1,B2… 各个B进程之间成为兄弟进程
task_struct
数据结构提供了两个链表表头,用于实现这些关系:
struct task_struct {
...
struct list_head children;
struct list_head sibling;
...
};
进程管理相关的系统调用
进程复制
Linux内核提供三个函数复制进程:
fork
:这是重量级调用,因为它提供了一个完整副本,然后作为子进程执行,为减少调用相关的工作量,Linux使用了写时复制(copy-on-write)技术vfork
:类似fork,但并不创建父进程数据副本,相反,父子进程之间共享数据,这节省了大量CPU时间,vfork设计用于子进程执行execve系统调用加载新程序的情况,但由于fork函数实现了写时复制功能,所以vfork在性能方面不再有优势,应避免使用clone
产生线程,可以对父子进程之间的共享、复制进行精确控制
写时复制
内核使用写时复制技术,以防止在fork
执行时将父进程的所有数据复制到子进程,该技术利用了下述事实,进程通常只使用了其内存页的一小部分,在调用fork
时,内核通常会复制父进程的每个内存页,这样有两个不好的负面效应:
- 使用了大量的内存
- 复制操作耗费很长时间
如果应用程序在进程复制之后使用exec
立即加载程序,那么负面效应会更严重。这意味着,此前复制操作是完全多余的。内核可以使用技巧来规避这个问题,并不复制整个地址空间,而是复制其页表,这样建立了虚拟地址和物理内存页之间的联系。fork
之后父子进程地址空间指向相同的物理内存页。
这时父子进程不能修改彼此的页,这也是两个进程页表对其标志了只读的原因。如果两个进程只读内存页,二者共享空间不会有问题,只要有一个进程试图向复制的内存页写入,处理器会向内核报告缺页异常。内核查看额外的内存管理数据结构,如果是COW页,内核会创建当前进程的副本。这里的实现需要了解内存管理方面的知识。
COW机制使得内核尽可能延迟内存页复制,当然大多数情况下,可能都不需要复制,节省了大量的时间。
执行系统调用
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)
参数如下:
clone_flags
是一个标志集合,用来指定控制复制过程中的一些属性start_stack
是用户状态下栈的起始地址regs
是一个指向寄存器集合的指针,其中以原始形式保存了调用参数。该参数使用的数据类型是特定于体系结构的struct pt_regs
stack_size
是用户状态下栈的大小,通常没有必要,设置为0parent_tidptr
和child_tidptr
是指向用户空间中地址的两个指针,分别指向父子进程的TID,NPTL(Native Posix Threads Library)库的线程实现需要这两个参数。
不同的fork变体,主要是通过标志集合区分,多数体系结构上,典型的fork调用实现方式于IA-32处理器相同:
// arch/x86/kernel/process.c
int sys_fork(struct pt_regs *regs)
{
return do_fork(SIGCHLD, regs->sp, regs, 0, NULL, NULL);
}
使用的唯一标志是 SIGCHLD,这意味着在子进程终止后发送 SIGCHLD 信号通知父进程。如果do_fork
成功,则新建进程的PID作为系统调用的结果返回,否则返回负值错误码。
sys_vfork
的实现与sys_fork
只是略微不同,前者使用了额外的标志CLONE_VFORK
和CLONE_VM
。
sys_clone
的实现方式与上述调用相似,差别在于do_fork
调用如下:
// arch/x86/kernel/process_32.c
long sys_clone(unsigned long clone_flags, unsigned long newsp,
void __user *parent_tid, void __user *child_tid, struct pt_regs *regs)
{
if (!newsp)
newsp = regs->sp;
return do_fork(clone_flags, newsp, regs, 0, parent_tid, child_tid);
}
标志不再是硬编码,可以通过各个寄存器参数传递到系统调用。而且,也不再复制父进程的栈,可以指定新的栈地址。
do_fork 的实现
所有三个fork机制最终都调用了do_fork
,其代码流程如下:
do_fork
|
|----- copy_process
|----- 确定PID
|----- 初始化vfork的完成处理程序
|----- wake_up_new_task
|----- 是否设置了CLONE_VFORK 标志 -> wait_for_completion
do_fork
以调用copy_process
开始,后者执行生成新进程的实际工作,根据指定标志重用父进程的数据,子进程生成之后,内核必须执行以下收尾操作:
进程复制do_fork
实现如下:
copy_process
|
|----- 检查标志
|----- dup_task_struct
|----- 检查资源限制
|----- 初始化 task_struct
|----- sched_fork
|----- 复制进程各个部分
|----- copy_semundo
|----- copy_files
|----- copy_fs
|----- copy_sighand
|----- copy_signal
|----- copy_mm
|----- copy_namespaces
|----- copy_thread
|----- 设置各个 ID、进程关系
复制进程的行为受到相当多的标志的控制,clone(2) 的手册详细讲述了这些标志,标志可以参考手册。该函数也需要做一些错误判断,Linux函数有时候会在成功的时候返回结构体指针,失败的情况下返回错误码,但函数只能返回一个值,所以Linux做了一个特殊处理,虽然一般而言指针可以指向内存任何位置,而Linux支持的每个体系结构的虚拟地址从0到4Kib的区域,该区域没有任何有意义的信息,因此内核可以重用此地址范围来编码错误。
当检查完标志后,使用dup_task_struct
来建立父进程task_struct
的副本,父进程的task_struct
实例只有一个成员不同,新进程分配了一个新的核心态,即task_struct->stack
。通常栈和thread_info
一同保存在一个联合之中,thread_info
保存了线程所需的所有特定于处理器的底层信息。
内核线程
内核线程是直接由内核本身启动的进程,内核线程实际上是将内核函数委托给独立的进程,内核线程经常称之为守护线程,用于执行下列任务:
- 周期性的修改内存页与页来源块设备同步
- 如果内存页很少使用,写入交换区
- 管理延迟动作
- 实现文件系统的事务日志
基本上,有两种类型的内核线程:
- 线程启动后一直等待,直至内核请求线程执行某一特定操作
- 线程启动后按周期性间隔运行,检测特定资源的使用,在用量超出或低于预期的限制时采取行动。
调用kernel_thread
函数可启动一个内核线程,定义是特定于体系结构的,但原型总是相同的。
int kernel_thread(int (*fn)(void *), void * arg, unsigned long flags)
产生的线程将执行用fn指针传递的函数,用arg指定的参数自动传个fn,flags可指定CLONE标志。kernel_thread
第一个任务是构建一个pt_regs
实例,对其中的寄存器指定适当的值,这与普通的fork
类似,接下来调用do_fork
函数。
p = do_fork(flags | CLONE_VM | CLONE_UNTRACED, 0 ®s, 0, NULL, NULL);
内核线程可以用两种方法实现,古老的方法内核中一些地方仍然在使用该方法,将一个函数直接传递给kernel_thread
,该函数接下来负责帮助内核调用daemonize
以转换为守护进程:
- 该函数从内核线程释放其父进程的所有资源,不然这些资源会一直锁定到线程结束。这是不可取的,因为守护线程通常运行到系统关机,因为守护进程只操作内核地址区域。
daemonize
阻塞信号的接收。- 将init用作守护进程的父进程。
内核线程的现代方法是辅助函数kthread_create
。
struct task_struct * kthread_create(int (*threadfn)(void *data),
void *data,
const char namefmt[],
...);
改函数创建一个新的内核线程,其名称由namefmt
给出,最初该线程是停止的,需要使用wake_up_process
启动它,此后,会调用threadfn给出的线程函数,而data则作为参数。
另一个备选方案是宏kthread_run
,它会调用kthread_create
创建新线程,但立即唤醒它,还可使用kthread_create_cpu
代替kthread_create
创建内核线程,使之绑定到特定的CPU。
内核线程会出现在系统进程列表中,在ps的输出中由放括号包围,以便与普通进程区分。
cppbreak@ThinkPad:~$ ps fax
PID TTY STAT TIME COMMAND
2 ? S 0:00 [kthreadd]
3 ? S 0:00 \_ [ksoftirqd/0]
6 ? S 0:02 \_ [migration/0]
7 ? S 0:00 \_ [watchdog/0]
8 ? S 0:00 \_ [migration/1]
9 ? S 0:00 \_ [kworker/1:0]
10 ? S 0:00 \_ [ksoftirqd/1]
12 ? S 0:00 \_ [watchdog/1]
13 ? S 0:01 \_ [migration/2]
启动新程序 execve
Linux 通过用新代码替换现存程序,即可启动新程序。Linux提供的 execve
系统调用可实现此功能。该函数的入口点是体系相关的sys_execve
函数,该函数把具体的工作委托给do_execve
实现具体功能。
int do_execve(const char *filename,
const char __user *const __user *__argv,
const char __user *const __user *__envp,
struct pt_regs *regs)
这里不仅用参数传递了寄存器集合和可执行文件的名称filename,而且还传递了指向程序的参数和环境指针。这里argv和envp都是指针数组,而且指向用户空间,__user
注释允许自动化工具检测是否所有处理都正确。
do_execve
代码执行流程如下:
do_execv
|----- 打开可执行文件
|----- bprm_init
|----- mm_alloc
|----- init_new_context
|----- __bprm_mm_init
|----- prepare_binprm
|----- 复制环境和参数数组内容
|----- search_binary_handler
首先要打开可执行文件,换言之,内核找到相关的inode并生成一个文件描述符,用于寻址文件。
bprm_init
接下来处理若干管理性任务:mm_alloc
生成一个新的mm_struct
实例来管理进程地址空间。init_new_context
是一个特定于体系结构的函数,用于初始化该实例,而__bprm_mm_init
则建立初始化的栈。
新进程的各个参数,例如euid,egid,参数列表,环境等等,这里会合并成一个类型为linux_binprm
的结构,prepare_binprm
用于提供一些父进程相关的值,特别是 UID 和 GID。
Linux 支持可执行文件的各种不同组织格式,标准格式是ELF。Linux还支持其他不同的二进制格式,通过函数search_binary_handler
在do_execve
结束时查找一种适当的二进制格式。各种格式根据不同特点(文件开始时的“魔数”)识别。二进制格式处理程序执行以下操作。
- 释放原进程所使用的所有资源
- 将应用程序映射到虚拟地址空间中。
- text段包含程序的可执行代码,
start_code
和end_code
指定该段在地址空间中驻留的区域 - 预先初始化的数据,位于
start_data
和end_data
之间,映射自可执行文件对应段 - 堆用户动态内存分配,置于虚拟地址空间中,
start_brk
和brk
指定了其边界 - 栈的位置由
start_stack
定义,大多数计算机的栈都是自动向下增长 - 程序的参数和环境也映射到虚拟地址空间中,分别位于
arg_start
和arg_end
之间,以及env_start
和env_end
之间
- text段包含程序的可执行代码,
- 设置进程的指令指针和其他特定于体系结构的寄存器,以便在调度器选择该进程时开始执行程序的main函数
Linux内核中,每种二进制格式都表示为下列数据结构的一个实例:
struct linux_binfmt {
struct list_head lh;
struct module *module;
int (*load_binary)(struct linux_binprm *, struct pt_regs * regs);
int (*load_shlib)(struct file *);
int (*core_dump)(struct coredump_params *cprm);
unsigned long min_coredump; /* minimal dump size */
};
linux支持的部分二进制格式有flat_format
, script_format
, misc_format
, elf_format
, elf_fdpic_format
, irix_format
, som_format
, aout_format
。每种二进制格式必须提供下面三个函数:
load_binary
用于加载普通程序load_shlib
用于加载共享库,即动态库core_dump
用于程序错误的情况下输出内存转储
每种格式首先必须用register_binfmt
向内核注册。该函数的目的是向一个链表增加一个新的二进制格式,该链表的表头是fs/exec.c
中的全局变量formats
,linux_binfmt
实例通过其next
成员彼此连接起来。
退出进程
进程必须用exit系统调用终止,这使得内核有机会将该进程使用的资源放回系统。该调用的入口点是 sys_exit
函数,需要一个错误码作为其参数,以便退出进程,最终调用do_exit
函数实现,该函数实现就是将各个引用计数器减1。如果引用计数器归0就将相应的内存区域返还给内存管理模块。
调度器的实现
内存中保存了对每个进程的唯一描述,通过若干结构与其他进程连接起来,调度器任务便是程序之间共享CPU时间,创造并执行的错觉,该任务分为两个不同部分:一个涉及调度策略,另一个涉及上下文切换。
概观
内核必须提供一种方法,在进程之间尽可能公平地共享CPU时间,而同时又要考虑不同的任务优先级。schedule
函数是理解调度操作的起点,该函数定义在kernel/sched.c
中,是内核代码最常用的函数之一。