05 - Programs and processes
05 - 程序与进程
本次课开始,我们开始进行系统调用的学习...
学习程序和进程管理。
学习进程管理的API。
1. 虚拟化
物理计算机 - 抽象为 - 虚拟计算机
程序好像独占计算机运行
1.1. 进入 “每一讲都实现一点什么” 的模式
每次课都感到编程能力的增长
2. 程序 v.s. 进程
程序是状态机的静态描述
描述了所有可能的程序状态
程序(动态)运行起来,就形成了进程(进行中的程序)
程序在静态时,不会有任何的状态...就像在 vim 里写代码,如果没有 IDE 的检查,你并不知道你犯了上面错误...知道你运行起来
2.1. 操作系统上的进程
进程:程序的运行状态随时间的演进
除了程序状态,操作系统还会保存一些额外的 (只读) 状态
我们可以探索:试图获取当前进程的各种信息
利用 AI + Prompt
基本信息
进程ID:使用 getpid() 函数可获取当前进程的ID,它是进程的唯一标识,示例代码为 pid_t pid = getpid(); 。
父进程ID:通过 getppid() 函数获取当前进程的父进程ID,代码为 pid_t ppid = getppid(); 。
进程组ID:使用 getpgid(0) 获取当前进程的进程组ID,代码为 pid_t pgid = getpgid(0); 。
会话ID:调用 getsid(0) 可获取当前进程的会话ID,代码为 pid_t sid = getsid(0); 。
状态信息
进程状态:通过读取 /proc/[pid]/stat 文件或使用 waitpid() 函数获取进程状态。 stat 文件中包含进程的运行状态、睡眠状态等信息,
进程优先级:使用 nice() 函数获取或设置进程的优先级,代码为 int priority = nice(0); ,返回值为当前进程的优先级。
资源信息
内存使用情况:通过读取 /proc/[pid]/status 文件获取进程的内存使用信息,如 VmSize 表示进程的虚拟内存大小, VmRSS 表示进程的常驻内存大小等
......
......
2.2. AI 学习 操作系统 system calls
proc.c: 这个示例展示了如何获取进程的基本信息:除了使用 API (例如 getpid 是一个系统调用),Linux /proc
文件系统允许我们使用文件 API (“everything is a file”) 访问当前进程的 ID、状态信息、命令行参数和工作目录等元数据。
./proc 1 2 3 | head -n 2
可以利用管道,保存输出,再打印...
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/processes# ./proc
pid: 1230
ppid: 888
--- Additional Process Information ---
User ID: 0
Group ID: 0
Effective User ID: 0
Effective Group ID: 0
Max open file descriptors: 1024
Process priority: 0
--- Process Status (/proc/self/status) ---
Name: proc
State: R (running)
PPid: 888
Uid: 0 0 0 0
VmSize: 2680 kB
--- Command Line (/proc/self/cmdline) ---
Command line: ./proc
--- Current Directory (/proc/self/cwd) ---
Current directory: /mnt/d/CSLab/osCourse/lec5/processes
3. 啊......我们开始编程了!
3.1. 注意你的代码质量√
要重视所有的架构设计
函数名、变量名、参数设计...所有的设计如果都不重视,最后会导致...程序只能删掉重写
程序既是 “人类” 的,也是 “反人类” 的
永远遵守两个规则:
机器永远是对的
未测代码永远是错的
3.2. 但你们不会好好测试的×
在你还没被 AI 替代的时候,好好重视编程规范
你们甚至不知道有什么主流的 C 语言测试框架
(Unity、CppUTest、CUnit、Google Test)
但看穿一切的 LLM 知道
我们为大家的实验做了一个 “稍稍好用一些” 的库
add workspace folder...
自动获得写测试用例的能力
对你的程序可以进行单元测试等行为...
这时候可以随便加...对原有程序行为没有任何影响...
或者加上环境变量...可以输出失败原因
极大降低写 Unit Test 的难度...
用 AI 读 test lib 的代码...
不会写 test example 可以给 AI 写
主要是养成写 Unit Test 的习惯...以后遇到别的 Project 的时候
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/testkit# make
gcc -Wall -g -I. main.c testkit.c -o main
argv[0] = ./main
TestKit
- [PASS] test_simple (main.c:23)
- [FAIL] test_fail (main.c:27) - Assertion fail
Assertion failed: (114514 == 0x114514)
In __tk_test_fail_27 of main.c:28
Assertion violated
- [FAIL] test_timeout (main.c:31) - Timeout
- [FAIL] test_exit_fail (main.c:35) - Assertion fail
Assertion failed: (result->exit_status == 0)
In __tk_test_exit_fail_35 of main.c:36
Assertion violated
- [PASS] test_exit_pass (main.c:39)
- [FAIL] test_timeout (main.c:43) - Timeout
argv[0] = ./main
argv[1] = arg1
argv[2] = arg2
- [PASS] test_system_pass (main.c:47)
- [FAIL] test_system_segfault (main.c:53) - Segmentation fault
argv[0] = ./main
argv[1] = crash
Ready to crash
- 3/8 test cases passed.
make: [Makefile:7: test] Error 1 (ignored)
今天开始用上 it.
4. 进程(状态机)管理 API
getpid() 询问操作系统,自己的状态
4.1. 进程管理系统调用
操作系统 = 状态机的管理者
进程管理 = 状态机管理
比如:图形化界面管理
就是绘制所有的进程,供使用者调用
作为 CSer 主要是在程序内部对进场进行调用
其他er 则是使用计算机,利用 UI 界面
一个直观的想法
创建状态机:spawn(path, argv)
syscall:给他传递一些参数,之后就可以完成...
文件路径 和 参数
所以这是我们非实现不可的一个 API
销毁状态机: _exit()
> fun fact: exit() 早期被占用了...所以这里要加一个 _
这是一个合理的设计 (例子:Windows)
今天可以花 10s 学习:Linux 和 Windows 的操作系统有什么不同?
Windows 和 Linux 的操作系统调用
命名方式:Linux:quick and dirty || Windows:微软的 匈牙利命名法
AI 对操作系统学习 的 帮助:
(jyy: 我们的课程以 Linux 系统为主。但只要掌握基本思路并适当追问,例如追问 “还有哪些进程管理相关的 API”,我们就能迅速上手另一个系统的编程。)
UNIX 的答案
没有提供 spawn...而是给出一个反直觉的 API...
复制状态机: fork()
复位状态机: execve()
4.2. 创建状态机
Unix:fork()
pid_t fork(void);
现在我们已经有 “一个状态机” 了
只需要 “创建状态机” 的 API 即可
UNIX 的答案: fork
做一份状态机完整的复制 (内存、寄存器现场)
4.3. fork() 的行为
立即复制状态机
包括所有状态的完整拷贝
寄存器 & 每一个字节的内存
Caveat: 进程在操作系统里也有状态: ppid, 文件, 信号, ...
小心这些状态的复制行为
新的pid...这些不会直接复制...
有一些直接复制的 内存 和 寄存器
复制失败返回 -1
errno 会返回错误原因 (man fork)
如何区分两个状态机?
新创建进程返回 0
执行 fork 的进程返回子进程的进程号——“父子关系”
4.4. 进程树
pstree
进程的创建关系形成了进程树
A→B→C,如果 B 终止了……C 的 ppid 是什么?
看似简单:我们 “往上提” 一层就行
实际复杂:
parent 如果比 son 先结束
pid 会被回收?
回收之后 son 的 ppid 是谁?
总有一天会复用
还有可能通过 ppid 进行进程间通信
子进程结束会通知父进程
通过 SIGCHLD 信号
父进程可以捕获这个信号 (参考 testkit 的实现)
“往上提” 就发错人了
PPID 错误
进程 C 的 PPID 可能会指向一个已经不存在的进程(B),或者一个被复用的 PID,这会导致进程间通信(IPC)等操作出错。
资源管理问题
父进程(A)可能没有准备好管理额外的子进程(C),这会导致资源管理混乱。
Form AI:
孤儿进程(orphan)和僵尸进程
孤儿进程:当父进程终止时,子进程(C)会变成孤儿进程。
Linux 系统会自动将孤儿进程的父进程设置为 init 进程(PID 为 1)。
init 进程是系统启动时的第一个进程,负责管理所有孤儿进程。
僵尸进程:如果子进程(C)在父进程(B)终止后仍然运行,而父进程(B)已经终止,那么父进程(B)会变成僵尸进程。
僵尸进程的 PID 仍然存在,但它的资源已经被释放,只是它的状态信息仍然保留在系统中,直到子进程(C)读取这些信息为止。
如何验证这个行为?
让 AI 帮我们写程序!
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/pstree# make
gcc -Wall -g create-tree.c -o create-tree
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/pstree# ./create-tree
```mermaid
graph TD
1296 --> 1297
1297 --> 1298
1301 --> 1303
1298 --> 1301
1301 --> 1304
1302 --> 1305
1298 --> 1302
1302 --> 1306
1296 --> 1300
1300 --> 1308
1311 --> 1312
1308 --> 1311
1311 --> 1314
1297 --> 1299
1299 --> 1313
1313 --> 1315
1313 --> 1317
1309 --> 1318
1308 --> 1309
1309 --> 1319
1316 --> 1320
1299 --> 1316
1316 --> 1321
1300 --> 1307
1310 --> 1323
1307 --> 1310
1310 --> 1324
1322 --> 1325
1307 --> 1322
1322 --> 1326
```
```mermaid
graph TD
887 --> 1298
1301 --> 1303
1302 --> 1306
1301 --> 1304
887 --> 1301
887 --> 1302
1296 --> 1300
887 --> 1308
887 --> 1311
1311 --> 1314
887 --> 1317
887 --> 1299
887 --> 1309
1309 --> 1319
887 --> 1316
1316 --> 1321
887 --> 1323
887 --> 1307
1322 --> 1325
887 --> 1322
887 --> 1315
```
./create-tree > test.md
结果重定向
这样就能得到一个进程树了!
4.5. Fork Bomb
1 变 2,2 变 4,第 k 层有 2^k-1 个进程
很快资源会被耗光
早期的一种 CS 恶作剧...
进程分裂...指数级增长...
4.6. 理解 fork(): 习题(1)
阅读程序,写出运行结果
pid_t x = fork();
pid_t y = fork();
printf("%d %d\n", x, y);
一些重要问题
到底创建了几个状态机?
line 1:2 个 fork
line 2:4 个 fork
父进程: x 是第一个子进程的 PID, y 是第二个子进程的 PID。
第一个子进程: x 是 0, y 是第二个子进程的 PID。
第二个子进程: x 是第一个子进程的 PID, y 是 0。
第一个子进程的子进程: x 是 0, y 是 0。
所以,最终会有四个执行路径,对应四个不同的状态机(进程)。
当操作系统发生并发时,就会变得有点难...
并发编程...
pid 分别是多少?
“状态机视角” 帮助我们严格理解
我的偷懒 (出期末考试题) 方法
拍脑袋 → 估算难度 → 用 model checker(上节课的 mosaic 可以逃课...) 跑结果
4.7. 理解 fork(): 习题(2)
阅读程序,写出运行结果
for (int i = 0; i < 2; i++) {
fork();
printf("Hello\n");
}
状态机视角帮助我们严格理解程序行为
./a.out
./a.out | cat
计算机系统里没有魔法
(无情执行指令的) 机器永远是对的
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/fork-demo# ./demo-1
1437 1438
1437 0
0 1439
0 0
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/fork-demo# ./demo-2
Hello
Hello
Hello
Hello
Hello
Hello
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/fork-demo# ./demo-2
Hello
Hello
Hello
Hello
Hello
Hello
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/fork-demo# ./demo-2 | wc -l
8
但是为什么加入管道之后不对了?
加入重定向...缓冲区问题?
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/fork-demo# ./demo-2 > output.txt
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/fork-demo# cat output.txt
Hello
Hello
Hello
Hello
Hello
Hello
Hello
Hello
在某些情况下,终端的缓冲区可能会导致输出的行数统计不准确。例如,终端可能在某些情况下缓存了额外的输出。如何验证:尝试在不同的终端或环境中运行相同的命令,看看是否会出现相同的结果。
4.8. 复位状态机
int execve(const char *filename,
char * const argv[], char * const envp[]);
UNIX 选择只给一个复位状态机的 API
将当前进程重置成一个可执行文件描述状态机的初始状态
操作系统维护的状态不变:进程号、目录、打开的文件……
(程序员总犯错,因此打开文件有了 O_CLOEXEC)
execve 是唯一能够 “执行程序” 的系统调用
因此也是一切进程 strace 的第一个系统调用
参数:(文件路径,参数)
ppid 是 继承的...
环境变量:
所有的设定,只要是你觉得很神奇的东西,大部分都在里面
报错信息为什么是中文/Eng?
...
4.9. execve() 设置了进程的初始状态
argc & argv: 命令行参数
困扰多年的疑问得到解答:main 的参数是 execve 给的!
envp: 环境变量
使用 env 命令查看
PATH, PWD, HOME, DISPLAY, PS1, ...
export: 告诉 shell 在创建子进程时设置环境变量
小技巧:
export TK_VERBOSE=1
程序被正确加载到内存
代码、数据、PC 位于程序入口
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/execve-demo# ./demo
PWD=/mnt/d/CSLab/osCourse/lec5/execve-demo
HELLO=WORLD
SHLVL=0
_=/usr/bin/env
4.10. PATH
环境变量
PATH
环境变量可执行文件搜索路径
还记得 gcc 的 strace 结果吗?
这个搜索顺序恰好是
PATH
里指定的顺序
计算机系统里没有魔法。机器永远是对的。
4.11. 摧毁状态机
void _exit(int status);
这个没有争议
立即摧毁状态机,允许有一个返回值
返回值可以被父进程获取
4.12. Unix 进程的生命周期
UNIX 中实现 “创建新状态机” 的方式
Spawn = fork + execve
我们会在之后介绍这些系统调用的灵活应用
int pid = fork();
if (pid == -1) { // 错误
perror("fork"); goto fail;
} else if (pid == 0) { // 子进程
execve(...);
perror("execve"); exit(EXIT_FAILURE);
} else { // 父进程
...
int status;
waitpid(pid, &status, 0); // testkit.c 中有
}
Last updated