该系列文章为《Linux/Unix系统编程手册》的学习笔记,由于该书太过冗长,属于工具书的类别,这里对书中的一些核心内容加以提炼和整理。 书中的编程练习这里不做展示和说明。
概念
进程是可执行程序的一个实例,由一系列用户控件和内核数据结构组成。
进程号: 由内核维护的进程标识,是一个正整数,init进程号永远为1,其余进程号与进程无绑定关系。
进程号有最大值,储存在
/proc/sys/kernel/pid_max
文件中,32位机最大值为32767。
进程内存布局
虚拟内存管理
计算机为每个进程维护了一片连续的、私有的地址空间,让进城有了独享内存的错觉。CPU获取内存时,通过内存管理单元(MMU)进行虚拟寻址,将虚拟地址翻译成物理地址。
虚拟内存将其地址空间划分为大小固定的虚拟页,每个虚拟页的大小为2的n次方字节。同样的,物理内存也被划分为大小相同的物理页。
页表
为了支持虚拟内存。内核为每个进程维护一张页表,页表结构如下图。
页表是一个页表条目(PTE)的数组,每个页表条目由一个有效位和一个n位地址字段组成。
- 如果有效位为1,表示虚拟页被缓存在物理页中,后面的地址字段指向物理页的地址。
- 如果有效位为0,若地址字段为null,则表明该虚拟页尚未被使用,否则,地址字段的地址指向磁盘中的地址,表示虚拟页未被缓存。
在CPU进行虚拟寻址时会发生下面两种情况:
- 页命中: 虚拟页的有效位为1,直接从地址字段指向的物理页中读取数据。
- 缺页: 虚拟页的有效位为0,这时将触发
缺页异常
,内核中的缺页异常处理程序将选择一个物理页为牺牲页,读出地址字段指向的磁盘空间读取数据,并缓存在牺牲页的地址中。
进程内存结构
进程分配的内存有很多部分,用段来表示虚拟内存的逻辑划分,包括文本段、数据段、栈和堆,布局如下图:
需要注意的是,栈的内存是从上向下增长的,而堆的内存是从下向上增长的。
进程行为
创建
pid_t fork(void);
调用fork
后会出现两个进程,都会从fork
的返回处开始执行。不同的是父进程返回子进程的进程号,而子进程返回0。
- 子进程共享父进程程序的文本段。
- 子进程将完全复制父进程的栈段、数据段及堆段,修改这些段中的变量不会影响父进程。
- 子进程将获取父进程的所有文件描述符副本,行为类似于
dup
,即父子进程对应的文件描述符均指向相同的打开文件句柄。
终止
void _exit(int status);
调用_exit(status)
系统调用终止当前进程,并以参数status
的值作为终止状态,可以供父进程wait
获取,0
为正常退出。
经常通过调用库函数exit(status)
来终止进程,它会执行以下动作:
- 调用退出处理程序
- 刷新stdio流缓冲区(fflush)
- 执行
_exit
系统调用
注意:stdio流缓冲区是维护在用户空间内存中的,因此创建子进程时也会复制缓冲区。如果不希望子进程获取父进程的缓存,需要在fork
调用前执行fflush
刷新缓冲区。
等待子进程终止
pid_t wait(int *status);
调用wait
来等待子进程终止,并以子进程pid
作为调用返回值。如果调用wait
前没有任何子进程终止,进程将被block直到任意子进程终止。
pid_t waitpid(pid_t pid, int *status, int options);
调用waitpid
等待指定子进程终止,options
参数设置为WNOHANG
,如果子进程状态为发生变化,父进程不会被block,而是直接返回0。waitpid(-1)
相当于wait
。
父子进程生命周期不同将产生两种进程:
- 孤儿进程: 父进程先于子进程终止,子进程变为孤儿进程,
init
将接管所有的孤儿进程成为其父进程。 - 僵尸进程: 子进程先于父进程终止,父进程未调用
wait
,内核将子进程转变为僵尸进程,父进程调用wait
后内核会将僵尸进程删除,如果父进程未调用wait
而直接终止,init
进程将接管子进程并直接调用wait
。
执行新程序
int execve(const char *pathname, char *const argv[], char *const envp[]);
调用execve
将制定路径的程序加载到当前进程的内存空间,进程的栈、数据、堆段将被新程序的相应部件替换,但进程中打开的文件描述符依然有效。
若想在调用exec
函数后关闭文件描述符,需对文件描述符设置close-on-exec
标识。
int system(const char *command);
调用system
函数来执行shell命令。system
调用后至少会再创建两个新进程,一个运行shell,另一个用于shell执行的命令。
SIGCHILD信号
子进程在终止时会向父进程发送SIGCHILD信号,父进程管理子进程的方式有两种:
- 在SIGCHILD信号处理函数中循环以
WNOHANG
标识符调用waitpid(-1)
,确保在此期间所有的已终止子进程被删除。 - 将SIGCHILD信号处置显式设置为
SIG_IGN
,虽然对SIGCHILD的默认处置就是忽略,但显式设置SIG_IGN
后,子进程终止后会直接被内核删除,不用转化为僵尸进程。
进程调度及优先级
进程调度策略
Linux调度进程使用CPU的默认模型是循环时间分享,每个进程轮流使用CPU一段时间,这段时间称为时间片或量子。在循环时间共享算法中,进程无法控制何时使用CPU以及使用CPU的时间,默认情况下,每个进程轮流使用CPU直到
- 时间片被用光。
- 自动放弃CPU。
nice值:是一种进程特性,间接影响内核的调度算法。取值范围为-20(高优先级)到19(低优先级)。使用fork创建的进程会集成nice值并在执行exec
后得到保持。
实时进程调度策略
实时进程相比于后台进程对调度器有更严格的要求,Linux提供了两个实时进程调度策略,循环策略(SCHED_RR)和先入先出策略(SCHED_FIFO),采用任意一种策略进行调度的进程优先级要高于标准循环时间分享模型(SCHED_OTHER)。
在实时调度策略中,拥有高优先级的进程在尝试访问CPU时总是优于低优先级进程,Linux提供了99个实时优先级,从1(最低)到99(最高),适用于两个实时调度策略。两种策略中当前进程都可能因为下面的原因之一而被抢占:
- 之前被阻塞的高优先级进程解除阻塞了(如等待的io操作完成了)。
- 另一个可运行进程的优先级被提高到了高于当前进程的优先级。
- 当前进程的优先级被降到低于其他可运行进程的优先级。
两种策略都维护着一个可运行进程队列,下一个可运行的进程是从优先级最高的队列头选出来的。
SCHED_RR
循环策略中,优先级相同的进程以循环时间分享的方式执行,进程每次使用CPU的时间为一个固定长度的时间片。进程被执行直到下面的条件之一满足:
- 达到时间片终点,进程被放入对应级别队列尾。
- 自愿放弃CPU,进程被放入对应界别队列尾。
- 进程终止。
- 被优先级更高的进程抢占,当高优先级进程执行结束后会继续执行直到剩余时间片耗尽。
SCHED_FIFO
与循环时间策略类似,不过先入先出策略中不存在时间片的概念,进程获得CPU控制权后,如果不存在其他原因导致CPU切换,它将一直执行。
自愿释放CPU
int sched_yield(void);
实时进程可通过两种方式自愿放弃CPU:
- 调用一个阻塞进程的系统调用。
- 调用
sched_yield()
。
CPU亲和力
在多处理器系统上进行进程调度时,进程可能会从一个CPU上调度到另外一个CPU上执行,这会产生性能下降:如果在之前的CPU缓冲器中存在进程数据,为了将进程的一行数据加载到新的CPU,需要将原CPU的这行数据实效(未修改则丢失数据,修改则写入内存)。
为了防止高速缓冲器数据不一致,多处理器架构同一时刻只允许数据被存放在一个CPU的高速缓冲中。
- 软CPU亲和力:Linux在条件允许的情况下总是调度进程到原CPU执行。
- 硬CPU亲和力:可以为进程设置,显式将进程限制在可用CPU的一个或一组上允许。
(End)