1 进程的创建
进程是程序执行的实例,在Linux中,进程的创建通常由系统调用fork()
完成。fork()
创建一个新的进程(子进程),这个子进程是调用fork()
的进程(父进程)的副本。子进程获得父进程的所有资源,包括文件描述符、环境变量等。linux系统中,所有用户进程的父进程是init进程,其PID为1,所有用户进程都是通过init进程直接创建或者派生出来。
pid_t pid = fork();
if (pid == 0) {
// 子进程的代码
} else if (pid > 0) {
// 父进程的代码
} else {
// fork() 失败
}
2 可执行程序的加载
当用户在命令行输入一个可执行文件名时,shell 通过 fork
创建一个子进程,在子进程创建后,通过系统调用 exec()
系列函数将一个新的可执行程序加载到进程的地址空间。这些函数包括execl()
, execp()
, execv()
等。exec()
不创建新进程,而是用指定的可执行程序替换当前进程的内容。
2.1 exec系统调用的工作流程
这里以 execv
系统调用为例子。
execve
系统调用用于执行一个可执行文件。其原型如下:
int execve(const char *pathname, char *const argv[], char *const envp[]);
pathname
: 可执行文件的路径。argv[]
: 参数列表。envp[]
: 环境变量列表。
-
进入内核态:当用户调用
execve
时,CPU 从用户态切换到内核态,内核开始处理系统调用。 -
检查文件格式:内核首先检查文件的格式,以确认它是一个有效的可执行文件。对于 ELF 文件,内核会检查 ELF 头部。
-
读取可执行文件头:内核读取可执行文件的 ELF 头部信息,包括段表、节表等,确定各个段的位置和属性。
-
创建新地址空间:内核为进程创建一个新的地址空间,并且丢弃旧的地址空间。这涉及以下操作:
- 删除旧内存映射:旧的代码段、数据段、堆、栈等被释放。
- 创建新内存映射:根据 ELF 文件的段表信息,内核将新的代码段、数据段、堆、栈等映射到进程的地址空间。
-
加载各个段:内核根据 ELF 文件的段表将代码段、数据段、BSS 段等加载到新创建的地址空间。具体步骤包括:
- 代码段:映射为只读可执行。
- 数据段:映射为读写。
- BSS 段:初始化为零并映射为读写。
-
栈初始化:内核初始化进程的栈,并将
argv
和envp
拷贝到栈中。同时,为进程设置初始栈指针。 -
设置程序入口点:内核将程序的入口地址(通常是
_start
)设置为进程的指令指针(EIP
/RIP
),这是程序执行开始的地方。 -
文件描述符继承:通常情况下,子进程会继承父进程的文件描述符(如标准输入、输出、错误)。
内核完成所有上述步骤后,将 CPU 控制权交给新加载的程序:
- 加载用户态寄存器:恢复进程的寄存器状态,包括栈指针、指令指针等。
- 切换到用户态:CPU 切换到用户态,开始执行新程序的入口函数。
3 程序的执行和终止
3.1 程序的执行
程序加载后,Linux内核会将控制权交给新加载的程序,程序从入口函数(_start
)开始执行,通常由 C 运行时库(crt0
)初始化,然后调用 main
函数,例如C语言程序的入口点是main()
函数。在执行过程中,程序通过系统调用与内核交互,例如文件操作、内存管理等。
3.2 进程的终止
-
进程终止的触发方式
- 正常终止:进程完成了它的任务,调用
exit
系统调用(例如exit(0)
)。 - 错误终止:进程遇到不可恢复的错误,调用
exit
系统调用并传递非零退出码。 - 信号:进程收到终止信号(如
SIGTERM
,SIGKILL
),可能是由其他进程(例如kill
命令)或系统触发的。 - 异常:进程执行过程中发生异常(如段错误
SIGSEGV
)导致异常终止。
- 正常终止:进程完成了它的任务,调用
-
退出码(Exit Code):进程退出时会返回一个退出码(exit code),通常用来表明进程的终止状态。0 表示正常终止,非零值表示不同的错误或异常情况。
-
清理资源
- 关闭文件描述符:关闭所有由进程打开的文件描述符。
- 释放内存:释放进程占用的内存,包括堆、栈、以及其他动态分配的内存。
- 注销内核对象:如信号量、共享内存段、消息队列等。
-
处理父进程:当一个进程终止时,内核会发送一个
SIGCHLD
信号给其父进程,以通知父进程子进程已经终止。- 父进程调用
wait
系统调用:父进程可以通过调用wait
或waitpid
获取子进程的退出状态,并清理子进程的资源。如果父进程没有处理该信号,子进程将进入僵尸状态。 - 清理僵尸进程:当子进程终止但其父进程未调用
wait
清理它时,子进程的进程描述符会保留在系统中,称为僵尸进程。僵尸进程占用系统资源但不再执行任何代码。父进程应当及时调用wait
清理僵尸进程,或者通过进程组和作业控制来确保正确的资源释放。 - 对于孤儿进程:当子进程被终止时,其在父进程处继承的资源应当还给父进程。而当父进程被终止时,该父进程的子进程就变为孤儿进程,会被 1 号进程(init进程)收养,并由 1 号进程对它们完成状态收集工作。
- 父进程调用
4 进程的调度
在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。
- 运行状态(Running):该时刻进程占用 CPU;
- 就绪状态(Ready):可运行,由于其他进程处于运行状态而暂时停止运行;
- 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;
当然,进程还有另外两个基本状态:
- 创建状态(New):进程正在被创建时的状态;
- 结束状态(Exit):进程正在从系统中消失时的状态;
Linux内核使用进程调度算法来管理多个进程的执行。调度器根据调度策略决定哪个进程在什么时候执行。Linux使用的调度算法包括完全公平调度器(CFS)。调度器通过时钟中断周期性地检查当前运行的进程是否需要切换(即上下文切换)。
// 一个简化的调度流程
while (true) {
struct task_struct *next = pick_next_task();
switch_to(next);
}
什么时候会发生 CPU 调度呢?通常有以下情况:
- 当进程从运行状态转到等待状态;
- 当进程从运行状态转到就绪状态;
- 当进程从等待状态转到就绪状态;
- 当进程从运行状态转到终止状态;
其中发生在 1 和 4 两种情况下的调度称为「非抢占式调度」,2 和 3 两种情况下发生的调度称为「抢占式调度」。
非抢占式的意思就是,当进程正在运行时,它就会一直运行,直到该进程完成或发生某个事件而被阻塞时,才会把 CPU 让给其他进程。
而抢占式调度,顾名思义就是进程正在运行的时,可以被打断,使其把 CPU 让给其他进程。那抢占的原则一般有三种,分别是时间片原则、优先权原则、短作业优先原则。
4.1 上下文切换
上下文切换是指CPU从一个进程切换到另一个进程执行。在上下文切换过程中,内核保存当前进程的状态(如寄存器、程序计数器等),然后加载下一个进程的状态。上下文切换开销较大,频繁的上下文切换会影响系统性能。
4.2 进程调度算法
进程调度算法也称 CPU 调度算法,毕竟进程是由 CPU 调度的。当 CPU 空闲时,操作系统就选择内存中的某个「就绪状态」的进程,并给其分配 CPU。
调度算法影响的是等待时间(进程在就绪队列中等待调度的时间总和),而不能影响进程真在使用 CPU 的时间和 I/O 时间。
常见的调度算法:
- 先来先服务调度算法
- 最短作业优先调度算法
- 高响应比优先调度算法
- 时间片轮转调度算法
- 最高优先级调度算法
- 多级反馈队列调度算法
5 小结
总结来说,Linux系统中程序的执行过程可以分为以下几个关键步骤:
- 进程创建:通过
fork()
创建子进程。 - 可执行程序加载:通过
exec()
加载新的程序到进程地址空间。 - 程序执行:从入口点开始执行程序代码,过程中通过系统调用与内核交互。
- 进程调度:内核调度器管理多个进程的执行,决定哪个进程在什么时候运行,通过上下文切换实现进程之间的切换。
- 进程终止:进程调用
exit()
系统调用退出或收到终止信号退出。