这周操作系统课做了015号实验《进程的创建》,说实话这个实验名字看着简单,真正动手写完代码、跑出结果、再把进程树一张张画出来,才发现里面值得嚼的细节比想象中多得多。如果你正在学操作系统,或者已经进了Linux的坑但对fork、进程生命周期这些概念还有点模糊,这篇内容应该能帮你把零散的知识点串起来——它既是一份可复现的实验记录,也是一份避坑手册。
做这个实验最大的收获不是“会用fork创建进程”,而是终于理解了“进程到底是什么”以及“操作系统是如何管理进程的”。后面很多概念,比如僵尸进程、孤儿进程、写时复制、进程调度顺序,都是从这个看似简单的实验里延伸出来的。下面我按自己的实验过程完整记录一遍,包括原理分析、代码拆解、运行结果解读和常见问题排查,希望能给同样在做操作系统实验的同学一些参考。
1. 实验目标与开发环境:先把工具链准备到位
1.1 实验目标拆解:不是“跑通程序”那么简单
这个实验的核心任务是使用fork这类系统调用完成进程的创建,但如果你只满足于“程序能跑出两行printf”,那基本等于白做了。梳理下来,实验真正要考察的是以下几点:
第一,理解进程不是程序。程序是磁盘上的静态文件,进程是程序被加载到内存后动态执行的过程,它包含代码、数据、堆栈、进程控制块(PCB)等一系列运行上下文。第二,掌握进程创建的完整生命周期,从创建、就绪、运行、阻塞到终止,每一步操作系统都做了什么。第三,理解父子进程之间的关系,包括PID、PPID、父子进程地址空间为什么是独立的。第四,掌握fork返回值的区分逻辑,这是判断当前代码运行在父进程还是子进程的关键。第五,能观察并解释进程状态,比如就绪态R、睡眠态S、僵尸态Z分别在什么情况下出现。
围绕这些目标,我设计了两个小demo:一个是最简fork示例,用来验证返回值机制;另一个是循环创建多个子进程并配合wait同步的进阶示例,用来观察进程树和同步机制。
1.2 开发环境:Linux、gcc、编辑器怎么选
我这次实验的环境是Ubuntu 20.04虚拟机,内核版本5.15,gcc版本9.4.0。选Linux做操作系统实验基本是标配,原因在于fork、exec、wait这些经典接口是Unix/Linux体系的核心,教学和面试都绕不开。Windows上虽然也有CreateProcess等API,但进程模型和fork完全不同,实验价值反倒没那么高。
编辑器方面我用了vim,因为实验环境是纯命令行的服务器或者虚拟机,没有图形界面,vim是稳定且随处可用的选择。如果你不习惯vim,用VS Code通过SSH远程连上去写代码也行,但至少要学会基本的命令行操作,比如cd、mkdir、vim、gcc、./程序名,因为这些在实验里都会用到。
有一点要提前说清楚:如果你用的是虚拟机,建议先给系统做一次快照,免得后续误操作把环境折腾坏了。另外编译时尽量把警告开起来,gcc编译命令里加上-Wall,很多隐藏问题提前暴露出来会很省事。
sudo apt update sudo apt install -y build-essential vim安装好后的验证命令:
gcc --version1.3 为什么实验通常不选Windows
很多第一次做这个实验的同学会问:Windows也能写C语言,为什么非得在Linux上做?这背后的原因主要有三个。
第一个原因是系统调用接口不同。Windows的进程创建走的是CreateProcessA/CreateProcessW这套机制,参数非常繁琐,涉及进程句柄、线程句柄、安全属性、启动信息等一大堆结构体;而Linux的fork一句话就搞定,语义清晰,适合教学。第二个原因是进程模型不同。Windows进程都是通过CreateProcess显式创建,不存在父子进程共享代码、写时复制这种机制;而Unix体系里fork的设计是线程出现之前的经典方案,理解fork对理解整个Linux进程管理和内存管理都有帮助。第三个原因是可观察性。Linux下可以用ps、pstree、/proc文件系统实时观察进程状态,操作直观,而Windows下要打开任务管理器还要各种设置,调试体验差很多。
2. 进程与进程创建的原理:fork之前先搞懂这几个概念
2.1 进程不是程序:进程四件套和PCB
很多教材上来就讲进程的三大状态、五态模型,但真正动手做实验时,最关键的是先建立一个直觉:进程是用来承载程序运行的一个“容器”。
一个进程大致包含四部分内容:代码段,也就是机器指令的集合;数据段,存放全局变量和静态变量;堆和栈,分别管理动态分配的内存和函数调用的局部变量;进程控制块PCB,这是内核为每个进程维护的数据结构,记录了进程PID、PPID、状态、优先级、打开的文件描述符、寄存器上下文等。操作系统就是靠着PCB来感知和管理进程的,PCB消失,进程就彻底不存在了。
父进程创建子进程,相当于复制了父进程的PCB以及地址空间,然后给子进程一份独立的副本。注意“独立”这个词,意味着修改子进程的变量不会影响父进程的变量。这个特性后面实验里会看到非常直观的表现。
2.2 fork()的返回值机制:靠PID区分父子
fork是本次实验的核心系统调用。它的调用方式极其简洁:
pid_t pid = fork();神奇之处在于,fork被调用一次,返回两次。一次在父进程中返回,返回值是子进程的PID;另一次在子进程中返回,返回值是0。如果创建失败,则返回-1。
为什么要设计成“父进程得到子进程PID,子进程得到0”?这其实是为了让同一个表达式在父子进程里区分身份。父进程需要拿到子进程PID,以后才能调用waitpid来回收子进程;子进程知道自己返回0,就明白自己是刚被创建出来的那个“新手”,可以走子进程的执行路径。
配合下面两个接口可以拿到更详细的信息:
getpid(); // 获取当前进程的PID getppid(); // 获取当前进程的父进程PID我在实验里写了一个简单的输出逻辑,运行后能看到父子进程PID之间的关系:子进程的PPID正好等于父进程的PID,这就是最直接的验证。
2.3 写时复制:为什么fork这么快
以前总听老师说fork很轻量,但没做实验前很难有体感。这个“轻量”主要归功于写时复制(Copy on Write,简称COW)技术。
fork瞬间并不会把父进程的全部内存都复制一份给子进程,而是让父子进程先共享同一批物理页面。只要双方都只读不写,大家相安无事,效率极高。一旦父子进程中的任意一方打算修改某个页面,内核才会真正分配一个新页面,把旧页面的内容复制过去,然后让当前进程指向新页面。
用生活化的例子来说:你写了两页纸的笔记,现在要复印一份给别人。最快的方式是拿原件直接给对方看,两个人都读同一份,谁也不改动它;但对方如果在上面划了线或者写了字,就需要立刻去复印店做一次真正的复印,把改动落到自己的那份副本上。这种延迟拷贝的策略非常高效,因为很多子进程fork出来之后马上就exec换程序了,根本不碰父进程的数据,之前白拷贝的话就是纯浪费。
这个知识点虽然实验代码里看不见、摸不着,但在面试题里经常出现,也是理解进程性能的一个关键点。
2.4 进程创建后的执行顺序:到底谁先跑
fork之后,父子进程并不是严格按“父先子后”的顺序执行的。现代操作系统采用基于优先级的抢占式调度,内核的调度器根据算法从就绪队列里挑一个进程运行,这个过程对用户态完全不可见。
所以在实验里,你会看到第一种运行结果是父进程先打印、子进程后打印;多跑几次,又变成子进程先打印、父进程后打印。这个随机性本身就是实验结果的一部分,它证明了进程的执行顺序由内核调度决定,而不是由代码的书写顺序决定。
这点尤其要注意:如果没有显式的同步手段(比如wait、信号量、管道),两个进程之间的执行时序是没法保证的。很多初学者刚写fork实验的时候,会惊讶“为什么我的程序有时候输出顺序不一样”,这不是代码错了,而是机制本身就是如此。
3. 代码实操:从demo_fork到多进程同步
3.1 第一个最简demo:编译运行别踩到坑
实验第一步,我在home目录下建了一个专门的实验文件夹,避免文件到处都是:
mkdir -p ~/os_lab/process_create && cd ~/os_lab/process_create然后创建了第一个源文件demo_fork.c。代码如下:
#include <stdio.h> #include <unistd.h> #include <sys/types.h> int main() { pid_t pid = fork(); if (pid < 0) { perror("fork error"); return 1; } else if (pid == 0) { printf("我是子进程,PID = %d,我的父进程PID = %d\n", getpid(), getppid()); } else { printf("我是父进程,PID = %d,我创建的子进程PID = %d\n", getpid(), pid); } return 0; }这段代码的逻辑很直白:先fork,然后根据返回值做分支。小于0是失败,等于0是子进程分支,大于0是父进程分支。
编译运行:
gcc -Wall -o demo_fork demo_fork.c ./demo_fork我当时看到的一次输出是:
我是子进程,PID = 3451,我的父进程PID = 3450 我是父进程,PID = 3450,我创建的子进程PID = 3451注意顺序是子进程先打印的,这并不奇怪,调度器决定的。我前前后后跑了十几次,有时父进程先打印,有时子进程先打印,确实验证了调度顺序的不确定性。同时可以看到3450这个PID出现在父进程的“我的PID”里,也出现在子进程的“父进程PID”里,这就形成了父子链。
3.2 进阶demo:循环创建多个子进程并用wait同步
第一个demo其实只是热身。为了深入观察进程树,我又写了第二个demo:在一个进程中连续fork出3个子进程,然后用waitpid统一回收。这里的关键点是fork之后的代码是所有子进程都会继续执行的,所以循环里必须通过pid==0分支让子进程及时return,防止子进程继续fork孙进程。
#include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> int main() { pid_t pids[3]; for (int i = 0; i < 3; i++) { pid_t pid = fork(); if (pid < 0) { perror("fork error"); return 1; } if (pid == 0) { printf("子进程[%d]:PID=%d, PPID=%d\n", i, getpid(), getppid()); sleep(2); return 0; } pids[i] = pid; } for (int i = 0; i < 3; i++) { waitpid(pids[i], NULL, 0); } printf("我是父进程(PID=%d),所有子进程都已结束\n", getpid()); return 0; }编译运行后,一次典型输出如下:
子进程[0]:PID=3610, PPID=3609 子进程[1]:PID=3611, PPID=3609 子进程[2]:PID=3612, PPID=3609 我是父进程(PID=3609),所有子进程都已结束三个子进程的PPID都是父进程PID 3609,这就构成了一棵以3609为根的进程树。父进程在最后一行才打印“所有子进程都已结束”,是因为它调用了waitpid,会阻塞等待对应子进程退出,然后再继续往下走。
3.3 用ps和pstree确认进程树结构
代码跑完还不够,实验报告里最好能贴出进程树的证据。我在代码里插了一行sleep(2),为的就是在这2秒钟时间里打开另一个终端,用命令观察进程树。
查看进程树有两个常用命令:
ps -ef | grep demo_fork pstree -p 3609pstree的输出示例:
demo_fork(3609)─┬─demo_fork(3610) ├─demo_fork(3611) └─demo_fork(3612)这个树形结构和代码预期完全一致。pstree这个命令非常好用,能一目了然地看到进程之间的父子关系、兄弟关系,对整个进程家族的理解特别有帮助。
如果不方便开第二个终端,也可以在代码里通过system("pstree -p 父进程PID")来调用,但那样会引入shell,有点偏离主题,我建议还是手动开一个终端看效果更直观。
4. 实验结果分析:输出顺序、缓冲区与进程状态
4.1 结果解读:为什么父进程有时先打印、有时后打印
刚才已经提到,fork之后父子进程进入就绪队列,由内核调度器决定下一个运行谁。CPU时间片、优先级、当前系统负载都会影响调度结果,所以输出顺序具有不确定性。
我曾试着在代码里强制让子进程先运行,最简单的方法是给子进程分支前面加一句sleep(1),让父进程先跑完。但反过来想让父进程让路就不好写了,所以实际工程中进程间同步必须靠wait、信号量、管道等内核提供的同步原语来保证,而不能靠“猜顺序”。这一点对后续学并发编程非常重要。
4.2 printf输出两遍的坑:fork复制了stdio缓冲区
这个坑是我在实验课上踩到的,当时把代码简化成了下面这样:
#include <stdio.h> #include <unistd.h> int main() { printf("before fork"); fork(); printf("after fork\n"); return 0; }预期输出应该是:
before forkafter fork结果却是:
before forkafter fork before forkafter fork整行输出重复了一遍,当时我盯着终端愣了半天。直到查了资料才明白,printf是带缓冲的库函数,它在用户态维护了一个缓冲区。第一次printf时,字符串“before fork”因为末尾没有换行符,被暂存在了缓冲区里,并没有立即写入标准输出。接着fork执行,内核复制了父进程的整个地址空间,包括这个stdio缓冲区,于是子进程继承了那份还没刷出去的数据。等到程序整体退出,父子进程各自刷新自己的缓冲区,就出现重复输出。
解决办法很简单:printf的格式串末尾加\n就能触发行缓冲刷新,或者在fork之前显式调用fflush(stdout)强制刷出。这里面的教训是:fork复制的不只是变量,还有各种用户态维护的中间状态,缓冲区的行为必须心里有数。
4.3 进程状态观察:R、S、Z等状态到底代表什么
用ps -aux命令查看进程时,STAT列能看到一堆字母,我当时最快的理解方式是把它们对应到实验里实际遇到的状态。
- R(running/runnable):进程正在运行或处于就绪队列,随时可能得到CPU。实验里子进程刚创建出来的一瞬间基本是R。
- S(sleeping):可中断睡眠,进程主动调用sleep或等待I/O时进入。实验里的子进程因为sleep(2)会出现S状态。
- D(disk sleep):不可中断睡眠,一般在等待磁盘I/O时出现,正常实验很少见到。
- Z(zombie):僵尸状态,进程已经终止,但父进程没有调用wait/waitpid回收它的PCB,导致内核还保留着它的退出信息。
- T(stopped):暂停状态,通常由SIGSTOP或SIGTSTP信号触发,可以用Ctrl+Z让一个进程暂停。
实验里最容易出现的就是Z状态。子进程return 0之后,在没有wait的demo里,子进程会立刻变成僵尸进程,直到父进程退出或者调用wait回收为止。这正好用来验证“进程终止不等于PCB立刻消失”这个概念。
5. 常见问题与排查技巧:这些坑我全踩过
5.1 fork返回-1:资源不够的错误排查
fork失败的原因主要有两类。
一类是系统进程数达到上限。每个用户在系统里能创建的进程数是有限制的,可以通过下面的命令查看和临时修改:
ulimit -u # 查看当前用户最大进程数 ulimit -u 4096 # 临时提高限制另一类是内存不足。虽然fork用写时复制减少了内存压力,但创建进程本身仍需要分配PCB和少量内核资源。如果虚拟机分配的内存很小,同时跑多个重型程序后再fork,就可能因为内存不足而失败。
遇到fork返回-1时,别急着怀疑代码,先看perror输出的错误信息。如果是EAGAIN,多半是资源限制问题;如果是ENOMEM,那就是内存不够了。针对性地扩容或调整ulimit就能解决。
5.2 僵尸进程:子进程退出了但PCB还在
实验里我故意在第一个demo中不调用wait,让子进程直接退出。马上用ps查看,就能看到子进程状态变成Z:
3451 pts/0 00:00:00 demo_fork <defunct>defunct就是僵尸进程的标记。僵尸进程不占用CPU,也不占用内存页,但它保留着PCB里的少量内核资源,同时占用了进程表的一个槽位。如果父进程一直不回收,僵尸进程会越积越多,最终导致进程号耗尽,新的进程无法创建。
回收僵尸进程有几种方式:调用wait/waitpid阻塞等待子进程退出,这是一种;给父进程注册SIGCHLD信号处理函数,在回调里调用waitpid,这是一种;如果父进程自己先退出,僵尸进程被init进程领养,init会负责清理,这也是一种。
实验中最推荐的方式还是waitpid,这也是我第二个demo里反复强调同步回收的原因。它既是代码层面的好习惯,也是避免僵尸进程堆积的直接手段。
5.3 还是尽快养成看man手册的习惯
做实验过程中我一度很依赖搜索引擎,后来发现官方文档其实更可靠。Linux的man手册里,系统调用相关的最常用两页是:
man 2 fork man 2 waitpid这两页写得非常清楚,返回值、错误码、参数含义都有详细解释。比如waitpid的三个参数:第一个是子进程PID,第二个是保存退出状态的指针,第三个是控制选项(0表示阻塞等待,WNOHANG表示非阻塞轮询)。我后来能写出更健壮的代码,很大程度上是因为认真读了man手册。
5.4 虚拟机环境下的一些注意点
做这个实验的不少同学是在虚拟机里进行的。虚拟机的性能和真机有差距,但做这种轻量级进程实验没有问题。我遇到的一个问题是虚拟机内存分配得太小,2GB内存下连续创建大量进程更容易出现EAGAIN错误,后来把内存调到4GB就很稳了。
还有一点,在虚拟机里做实验时,别把物理机的Windows/Mac快捷键习惯带进来。比如在终端里粘贴快捷键可能不是Ctrl+V,而是Ctrl+Shift+V,不然你会发现终端毫无反应,甚至误触发一些命令,白白浪费时间。
5.5 常见故障排查速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| fork返回-1,perror显示EAGAIN | 进程数达到用户上限 | ulimit -u调高限制,或关闭多余进程 |
| fork返回-1,perror显示ENOMEM | 内存不足 | 虚拟机加大内存或释放内存 |
| printf的输出重复出现 | 用户态stdio缓冲区被fork复制 | 加\n或fflush(stdout) |
| 子进程输出顺序随机 | 内核调度顺序不确定 | 正常现象,需要同步则使用wait |
| 出现大量defunct进程 | 父进程没有回收子进程 | 调用wait/waitpid |
| 子进程PPID变成1 | 父进程先退出,子进程被init进程领养 | 正常现象,注意进程树结构 |
6. 实验心得与后续扩展
这个实验做完,我最想说的一句话是:一定要真正动手写代码、跑结果,而且要反复研究进程状态,而不是只停留在背概念。第一次跑出demo_fork的时候,看到PID和PPID的关系完全符合预期,我才真正理解了“进程树”是什么概念。后面还会学到的exec族函数,我强烈建议你在这个实验基础上再动手试试:先用fork创建子进程,再在子进程分支中调用exec,把当前进程的代码段替换成一个全新的程序,比如执行ls、ps。把这两个接口放在一起用,就是Linux下最经典的“创建进程之后执行新程序”的组合套路,Shell在执行外部命令时干的基本就是这件事。
另外一个小技巧:做这类实验时,建议把代码放在有意义的目录名下,比如~/os_lab/process_create,而不是随手写在home目录。别看这是小事,后面实验多了,文件和代码分散得到处都是,你每次找源码都要花一段时间,非常影响效率。
做实验的过程免不了踩坑,尤其是缓冲区输出重复、僵尸进程堆积这两种情况,我是在第二次运行完ps之后才彻底搞明白的。现在回头看,这些坑恰恰是操作系统课程里最值的部分——它们让我看到了fork的完整行为,而不是只停留在printf层面上自嗨。如果你在跑这个实验时遇到了类似的困惑,别慌,按上面说的方式一步步排查,很快就能跑通并理解。