1. 进程的创建
在操作系统中,进程是通过系统调用创建的。不同的操作系统使用不同的系统调用来创建进程。在 Unix 和 Linux 系统中,常见的系统调用是 fork()
和 exec()
,Linux 系统中,fork()
系统调用用于创建一个新的进程。fork()
调用会创建当前进程的一个拷贝,这个拷贝称为子进程。子进程与父进程几乎完全相同,但有一个不同之处:子进程有自己独立的进程 ID。exec()
系列函数用于替换当前进程的内存空间,并执行一个新的程序。通常,exec()
与 fork()
结合使用,先使用 fork()
创建子进程,然后在子进程中使用 exec()
加载并执行新的程序。
以下是一个使用 fork()
和 exec()
的简单示例:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main() {
pid_t pid;
pid = fork(); // 创建子进程
if (pid < 0) { // fork 失败
fprintf(stderr, "Fork Failed");
return 1;
} else if (pid == 0) { // 子进程
execlp("/bin/ls", "ls", NULL); // 执行 ls 命令
} else { // 父进程
wait(NULL); // 等待子进程完成
printf("Child Complete\n");
}
return 0;
}
2.可执行程序的加载
程序文件被执行时,操作系统将可执行文件从磁盘复制到内存中的一个合适的位置,这个过程称为加载。在 Linux 系统中,可执行程序的加载过程是由内核负责的,主要通过以下几个步骤来完成:
1. 内核将程序文件标识为一个进程,并为其分配必要的资源,如内存、文件描述符和安全性ID等。
2. 程序调用 exec
系列函数:当一个进程调用 exec
系列函数(例如 execl
, execv
, execle
, execve
, 等等)时,它请求内核用一个新的可执行程序来替换当前进程的地址空间。
3. 内核首先验证可执行文件的存在性和可执行性,然后为该进程创建一个新的地址空间,并清理旧的地址空间。
4. 内核将程序的代码段、数据段和堆栈段等加载到内存中,并开始执行程序的入口点(例如,在ELF格式的可执行文件中,这是ELF头中指定的入口地址)。
当执行 ./example
命令时,shell 进程会通过系统调用 execve()
来加载 example
可执行文件。内核将程序加载到内存中,并为其创建一个新的进程环境。然后,CPU 将执行权限转移到程序的入口点,开始执行程序的机器代码。
3. 进程的调度
一个进程的生命周期包括以下几个阶段:
- 创建(Creation):进程通过系统调用(如
fork
或clone
)创建。在这个阶段,操作系统为新进程分配必要的资源,并初始化进程控制块(PCB)。 - 就绪(Ready):新创建的进程进入就绪队列,等待操作系统调度。
- 运行(Running):当调度器选择该进程运行时,它进入运行状态,占用 CPU 资源执行指令。
- 等待(Waiting/Blocked):如果进程需要等待某个事件(如 I/O 操作完成),它会进入等待状态,暂时放弃 CPU 资源。
- 结束(Termination):当进程完成执行或被强制终止时,它进入终止状态,操作系统回收进程占用的资源。
Linux 的进程调度机制是内核负责的重要功能之一,它决定了每个进程何时运行、运行多长时间,以确保系统的高效运行和响应。Linux支持多种调度策略:如先来先服务(FCFS),短作业优先(SJF),现代 Linux 常用的是基于完全公平调度(CFS, Completely Fair Scheduler)的调度算法。
完全公平调度(CFS)
CFS 的目标是尽可能公平地分配 CPU 时间给所有进程。它通过虚拟运行时间(vruntime)和红黑树(red-black tree)来实现这一目标。
1. 虚拟运行时间(vruntime)
- 概念:vruntime 是每个进程的一个属性,表示进程的“虚拟”运行时间。它与实际运行时间不同,因为它考虑了进程的优先级。
- 权重:优先级高的进程具有较小的权重,vruntime 增长较慢;优先级低的进程具有较大的权重,vruntime 增长较快。这确保了低优先级进程不会占用过多 CPU 时间。
2. 红黑树
- 数据结构:CFS 使用红黑树来组织所有可运行的进程。红黑树是一种自平衡二叉搜索树,它保证了在最坏情况下基本操作(插入、删除、查找)的时间复杂度为 O(log N)。
- 排序:进程按照 vruntime 存储在红黑树中,vruntime 最小的进程位于树的最左边,这个进程将被调度运行。
3. 调度过程
- 选择进程:调度程序从红黑树中选择 vruntime 最小的进程进行调度。这确保了每次调度都是最公平的选择。
- 更新 vruntime:当进程运行时,它的 vruntime 会增加,增加的速率取决于它的优先级。运行结束后,进程重新插入红黑树,并按照新的 vruntime 重新排序。
CFS 调度器的特点
- 公平性:CFS 通过 vruntime 和红黑树确保了每个进程获得尽可能公平的 CPU 时间。
- 高效性:使用红黑树实现进程的快速查找、插入和删除,保证了调度的高效性。
- 灵活性:可以处理不同优先级的进程,适应不同的工作负载和系统需求。
具体示例
假设系统中有三个进程 A、B、C,它们的优先级和 vruntime 如下:
- 进程 A:优先级高,vruntime 较小。
- 进程 B:优先级中等,vruntime 中等。
- 进程 C:优先级低,vruntime 较大。
红黑树中的排序可能如下:
B
/ \
A C
调度程序会选择 vruntime 最小的进程 A 来运行。随着进程 A 的运行,它的 vruntime 会增加。当它的 vruntime 超过 B 时,红黑树会重新排序,可能变成:
A
/ \
B C
然后调度程序会选择新的 vruntime 最小的进程 B 来运行,以此类推。
4. 程序的执行
在完成上述步骤1,2(进程创建,程序加载)后,进入程序的执行。如下面示例:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork(); // 创建子进程
if (pid < 0) { // fork 失败
fprintf(stderr, "Fork Failed\n");
return 1;
} else if (pid == 0) { // 子进程
printf("Child Process: PID = %d\n", getpid());
execlp("/bin/ls", "ls", NULL); // 执行 ls 命令
} else { // 父进程
wait(NULL); // 等待子进程完成
printf("Child Complete\n");
}
return 0;
}
详细步骤说明
-
调用
fork()
:- 父进程调用
fork()
创建一个新的子进程。fork()
返回两次:一次在父进程中返回子进程的 PID,一次在子进程中返回 0。
- 父进程调用
-
子进程调用
execlp()
:- 子进程调用
execlp("/bin/ls", "ls", NULL)
,使用ls
命令替换当前进程的地址空间。这个调用不会返回,除非发生错误。
- 子进程调用
-
执行
ls
命令:- 内核加载
/bin/ls
可执行文件,将其映射到子进程的地址空间中,并将控制权交给ls
程序。
- 内核加载
-
父进程等待:
- 父进程调用
wait(NULL)
,等待子进程完成。当子进程执行完ls
命令并退出后,父进程继续执行,打印 "Child Complete"。
- 父进程调用
5. 总结
Linux 系统下,进程的创建、可执行程序的加载、程序的执行和进程的调度是操作系统核心功能的组成部分。其中每个过程都有很多Linux独有的特点和细节需要注意。通过本次实验,我更加深刻理解了这些内容的实现过程和步骤,更好地掌握了Linux系统的程序的活动和进程之间的关系,对于个人以后更好的使用Linux进行编程和提升代码编写的能力。