1. 先弄清楚这道练习到底要你交付什么
“课堂练习3.2:进程的创建”这种题目,第一眼看上去平平无奇,就是让程序里多出一个进程而已。可真坐到机器前面敲代码,你会发现它牵出来的东西特别多:操作系统怎么描述进程、内核用什么系统调用把进程造出来、父进程和子进程谁先跑、跑完之后谁负责收尸、创建失败时错误码怎么读。这些恰好是后面进程池、进程通信、并发调优、乃至排查“后台有一堆 java 进程却看不到窗口”这类现场问题的基础。
我给这道练习的定位是:它不是让你背 API,而是让你亲手体会“创建”这个动作在两套完全不同的操作系统上被设计成了两种思路。Linux 走的是复制再替换的两步走,Windows 走的是创建即完成的一步到位。理解了这层差别,你在写 Python 的 multiprocessing、Java 的 ProcessBuilder、甚至看任务管理器里那一长串同名进程时,脑子里就会自动浮现出父子关系树,而不是一堆看不懂的名字。
适合谁看:正在做操作系统实验的同学、刚转后端需要补并发基础的开发者、以及被“进程占着文件删不掉”“程序启动了但窗口不出现”折腾过的运维和普通用户。下面我把这道练习从原理到实现、从观测到排错,完整走一遍,代码都是可以直接复制运行的版本,坑我也提前给你标出来。
2. 进程创建的本质:内核到底做了哪几件事
2.1 从“程序”到“进程”中间隔了什么
我们写在硬盘上的那份可执行文件,只是一堆指令和数据的静态集合,它占着磁盘,但不占 CPU,也不占调度队列。进程是它被加载进内存、拿到独立地址空间、有了自己的栈和堆、被内核登记到调度器里之后的样子。所以“创建进程”这个词严格说分两步:先造出一个内核能调度的实体,再把可执行文件的内容塞进它的地址空间。
进程在内核里有个名字叫任务控制块,Linux 下是task_struct,Windows 下是 EPROCESS 结构。这个名字本身就说明了重点——它是一个“控制块”,记录着这个进程的 PID、父进程、优先级、状态、打开的文件描述符表、内存映射、信号处理方式等等。创建进程,本质就是让内核分配并初始化这样一个结构,再把它挂到调度队列上。
很多人第一次做练习时会以为创建进程很“重”,其实内核在这一步相当克制。Linux 的fork并不会立刻把父进程的内存整块复制一份,它用的是写时复制:父子进程一开始共享同一批物理页,页表项都指向同一块内存,只是标记成只读。谁要写入,谁就触发一次缺页中断,内核这时候才真正复制那一页。所以fork的开销主要在复制页表和task_struct这类元数据上,真正的内存搬运被推迟到了写入那一刻。这个设计的意义很直接:进程创建后大概率马上要执行exec换成新程序,提前复制一遍纯属浪费。
2.2 三种创建方式的返回值,决定了你代码怎么写
做 Linux 部分的练习,fork的返回值是必须烂熟于心的。它一次调用、两处返回,这不是笔误,是设计。父进程拿到子进程的 PID,子进程拿到 0,出错则拿到负数并设置errno。你的代码分支判断就是靠这个区分的,写错一个等号,逻辑就全乱了。
| 返回值 | 返回在哪个进程 | 含义与后续动作 |
|---|---|---|
pid > 0 | 父进程 | 拿到子进程 PID,通常继续做自己的事或调用 wait 回收 |
pid == 0 | 子进程 | 只可能是子进程,通常接着调用 execve 替换镜像 |
pid < 0 | 父进程 | 创建失败,查看 errno 定位原因 |
这里有个所有人都会踩的坑:fork之后子进程不是从main开始执行的,它从fork这一行之后继续往下跑。所以fork之前打开的文件、申请的缓冲区、打印了一半的输出,子进程都能看见。如果fork之前用了带缓冲的printf,父进程缓冲区里没刷出去的内容会被子进程继承,最后打印两遍。解决方法是在fork前手动fflush(stdout),或者干脆用不带缓冲的write。
2.3 exec 不是创建,是把当前进程“换掉”
execve这一族函数经常和fork一起被提到,但它本身不创建新进程。执行execve后,当前进程的地址空间被完全替换成新程序的代码段、数据段和栈,PID 不变,打开的文件描述符默认保留。正因为“PID 不变”,fork加exec才能组合成一个看起来像“启动了一个新程序”的完整流程:父进程复制出子进程,子进程把自己替换成目标程序,父进程拿着 PID 继续管。
有六个 exec 变体,名字的区别就两点:l表示参数以列表形式逐个传入,v表示参数打包成数组一次传入,p表示去 PATH 环境变量里找程序,e表示可以自定义环境变量。日常用得最多的是execlp和execvp,因为它们会自动搜路径,写起来省事。
注意:
exec系列函数成功调用后不会返回,一旦返回就说明失败了。新手常见的写法是exec(...)后面跟一句“如果失败就退出”,这是对的;但如果写成“exec 成功后就退出”,逻辑就反了。
3. 两套操作系统,两种创建哲学
3.1 Linux 的“复制再替换”和 Windows 的“一步到位”
Linux 的fork加exec是分离式的,好处是灵活:你可以在复制之后、替换之前这段时间里做很多事情,比如修改文件描述符、切换工作目录、设置用户身份、关闭多余句柄,这些操作都在子进程里做,不影响父进程。守护进程的三次 fork、容器启动时的命名空间配置,都依赖这个中间窗口。
Windows 没有等价的fork,它的CreateProcess是创建和加载一条龙。你给一个可执行文件路径或命令行,内核直接建进程、建主线程、加载镜像、返回句柄和 PID,中间不给用户态留操作空间。想在这种模型里“先改点东西再执行”,只能靠命令行参数或者环境变量传递,这也是 Windows 上脚本风格明显不同的原因。
| 维度 | Linux(fork + exec) | Windows(CreateProcess) |
|---|---|---|
| 是否分两步 | 分两步,中间有操作窗口 | 一步完成 |
| 内存复制 | 写时复制,按页触发 | 直接加载新镜像 |
| 创建后返回 | 父进程拿 PID,子进程返回 0 | 返回 PROCESS_INFORMATION 结构 |
| 句柄管理 | 文件描述符默认继承 | 是否继承由参数显式决定 |
| 典型用途 | 服务端、脚本、容器 | 桌面程序、服务、批处理 |
3.2 Windows 上那几个必须记住的参数
CreateProcess参数一长串,实际写起来真正影响结果的没几个。第一个参数是应用程序名,可以为空,此时内核从命令行里解析出可执行文件;第二个参数是命令行,注意它是可写的字符串指针,很多封装库会在内部复制一份再传。第五个参数控制句柄继承,桌面程序一般传 FALSE,避免子进程莫名其妙拿到一堆不该拿的句柄。
创建标志里,CREATE_NEW_CONSOLE会给子进程单独开一个控制台窗口,CREATE_NO_WINDOW表示不要窗口,CREATE_SUSPENDED让主线程挂起不跑,等你配置完再唤醒。这几个标志直接决定你看到的现象:传了CREATE_NO_WINDOW,程序跑起来了但界面上什么都没有,这就是很多人抱怨“只有进程没有窗口”的一个典型来源。还有一点,返回的PROCESS_INFORMATION里有进程句柄和线程句柄,用完必须显式关闭,否则句柄计数一直不降,时间长了会拖垮程序。
3.3 高级语言帮你封装了多少,藏了多少
用 C 写练习是为了看清底层,但真实工作里更多是调语言的封装。Python 的multiprocessing在 Linux 上默认用fork,在 Windows 和 macOS 上默认用spawn。spawn会启动一个全新的解释器,重新导入你的主模块,再把函数参数序列化过去。这就带来一个致命细节:主模块必须有if __name__ == "__main__":保护,否则新解释器一导入模块又去创建进程,无限套娃直到系统撑爆。
Java 这边,Runtime.exec是早期接口,它会把整个字符串按空白拆分,路径里有空格就废了。ProcessBuilder改掉了这个毛病,参数是一个列表,每个元素是独立的,配合redirectErrorStream(true)把标准错误合并到标准输出,能省掉一个读取线程。但无论哪个接口,子进程的输出流都必须有人读,否则缓冲区写满后子进程就卡在那里等,父进程还傻乎乎地waitFor,双方僵住。
4. 动手实操:把三种实现完整跑一遍
4.1 C 语言版本:最能看清 fork 的一版
先看最小可运行版本,重点是返回值判断和回收动作:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/wait.h> int main(void) { fflush(stdout); pid_t pid = fork(); if (pid < 0) { perror("fork failed"); return 1; } if (pid == 0) { printf("child : pid=%d ppid=%d\n", getpid(), getppid()); execlp("echo", "echo", "child is replacing itself", (char *)NULL); perror("execlp failed"); _exit(127); } printf("parent: pid=%d child=%d\n", getpid(), pid); int status = 0; pid_t done = waitpid(pid, &status, 0); if (done < 0) { perror("waitpid failed"); return 1; } if (WIFEXITED(status)) { printf("child exited with code %d\n", WEXITSTATUS(status)); } return 0; }编译运行gcc fork_demo.c -o fork_demo && ./fork_demo,你能同时看到父进程和子进程的打印。三个细节值得留意:fork前我加了fflush,避免输出重复;子进程替换失败时用的是_exit而不是exit,因为exit会刷新并跑一遍父进程继承过来的清理逻辑,可能在缓冲区里留下重复内容;父进程用waitpid阻塞等待,拿到退出状态后再解析。
如果你想知道fork到底有多快,可以在循环里跑一千次并计时,注意每次都要wait回收,不然会积累一堆僵尸进程。实测在普通笔记本上,纯fork加立即exit的单次开销在几十微秒量级,这个数字在你后面决定要不要上进程池时很有参考价值。
4.2 Python 版本:注意启动方式的差异
Python 版能明显提升开发效率,但跨平台行为差异要学会主动控制:
import multiprocessing as mp import os import time def worker(task_id): print(f"[child {os.getpid()}] task={task_id} ppid={os.getppid()}") time.sleep(0.1) return task_id * task_id if __name__ == "__main__": print(f"[parent {os.getpid()}]") with mp.Pool(processes=4) as pool: results = pool.map(worker, range(8)) print("results:", results)在 Linux 上跑,你会发现所有子进程的 ppid 都是主进程;换成spawn启动方式再跑一次:
if __name__ == "__main__": mp.set_start_method("spawn")行为会变,子进程是全新解释器,模块被重新导入,函数和参数都得经过序列化。启动慢一些,但隔离性更好,也避免了fork在有多线程场景下可能带来的锁状态不一致问题。
提示:Windows 上只有
spawn这一种选择。如果你在 Windows 上写多进程代码却忘了加if __name__ == "__main__":,程序会不断创建新进程,任务管理器里的 python 进程数量会爆炸式增长,这就是最常见的“进程数异常”现场。
4.3 Java 版本:流处理是绕不过去的一关
Java 里推荐的写法是ProcessBuilder,下面这个版本把输出、超时、退出码都照顾到了:
import java.io.BufferedReader; import java.io.InputStreamReader; import java.util.concurrent.TimeUnit; public class ProcDemo { public static void main(String[] args) throws Exception { ProcessBuilder pb = new ProcessBuilder("ls", "-l", "/tmp"); pb.redirectErrorStream(true); Process p = pb.start(); try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()))) { String line; while ((line = r.readLine()) != null) { System.out.println("[child] " + line); } } boolean done = p.waitFor(10, TimeUnit.SECONDS); if (!done) { p.destroy(); System.out.println("timeout, process killed"); } else { System.out.println("exit code = " + p.exitValue()); } } }这段代码里有三个关键点。第一,redirectErrorStream(true)让标准错误合并进标准输出,一个流就够了。第二,必须把流读空,不然子进程输出多的时候会阻塞。第三,waitFor带超时,避免程序永远挂在等待上。Java 9 以后还可以用ProcessHandle查进程信息:
ProcessHandle ph = p.toHandle(); System.out.println("pid=" + ph.pid()); ph.parent().ifPresent(parent -> System.out.println("ppid=" + parent.pid())); ph.children().forEach(c -> System.out.println("child pid=" + c.pid()));ProcessHandle.allProcesses()能把整机进程列出来,配合过滤条件,可以写一个简单版的“怎么查找电脑后台进程”工具,按命令行关键字筛,比手动翻列表高效得多。
4.4 进程池:批量任务下这笔账怎么算
如果你的任务是“处理一万个文件”,给每个文件 fork 一次就不划算了。每次创建都要复制页表、分配内核结构,一万次下来光开销就够呛。进程池的思路是提前建好固定数量的进程,任务通过队列分发,进程反复复用。
池子开多大有讲究,不能拍脑袋。下面这张表是我自己压测总结的起点,具体还要看任务特征:
| 任务类型 | 建议池大小 | 判断依据 |
|---|---|---|
| 纯计算密集 | 物理核数,或核数 + 1 | CPU 一直是满的,多了只会互相抢 |
| IO 密集(文件、网络) | 核数的 2 到 4 倍 | 大量时间在等待,进程可以多切几次 |
| 混合型 | 核数 + 2 起步 | 先按经验值跑,再看 P95 延迟调 |
| 有外部资源限制 | 按资源上限倒推 | 比如数据库连接数只有 20,池子别超过它 |
判断方法很朴素:把池子大小当变量,从核数开始往上加,观察吞吐量和单任务平均耗时。当平均耗时开始明显上升、吞吐量不再涨,说明已经过了拐点,再往里塞进程只会增加上下文切换。
注意:进程池里的进程一旦创建,它继承的是创建那一刻的父进程状态。如果你在创建池之前打开了数据库连接或文件句柄,这些资源会被所有子进程共享引用,交叉读写很容易出问题。稳妥做法是池创建放在前面,连接在子进程内部自己建。
5. 进程创建之后要盯住什么
5.1 父子关系、状态和观测命令
创建只是开始,进程跑起来之后你得能看见它。Linux 上最常用的组合是ps -ef看全量、ps -o pid,ppid,stat,cmd -p <pid>看指定进程、top或htop看实时资源、pstree -p <pid>看家族树。pstree那个命令特别直观,一眼就能看出谁是爹谁是儿子,做练习时把结果截图放进实验报告,比文字描述有说服力。
状态字段里的字母要认识:R是运行或就绪,S是可中断睡眠(最常见),D是不可中断睡眠(通常在等 IO),Z是僵尸,T是被停止。看到Z就要警觉,说明子进程已经退出但父进程还没回收。看到一堆D,大概率是磁盘或网络出了状况,这时候连kill -9都杀不掉,因为信号要等到系统调用返回才处理。
Windows 上对应的是任务管理器的详细信息页,可以加“命令行”“父进程 ID”两列,这两个列默认是隐藏的,加上之后排查效率提升明显。命令行视图里wmic process get processid,parentprocessid,name也能用,不过新系统更推荐 PowerShell 的Get-Process和Get-CimInstance Win32_Process。
5.2 僵尸进程和孤儿进程,处理时机完全不同
僵尸进程是子进程退出后留下的一条记录,包含退出状态,等着父进程调用wait或waitpid来取。父进程不取,这条记录就一直挂着占一个进程表项。偶尔几个无所谓,量大了会耗光 PID 资源。解决方式有三种:父进程老老实实回收;父进程忽略SIGCHLD信号(部分系统上会让内核自动回收);或者干脆双 fork,让子进程变成孤儿后被 init 收养,由它负责收尸。
孤儿进程是另一回事,父进程先退出了,子进程还活着,它会被 PID 为 1 的进程接管。这本身不是错误,守护进程就是这么做的。两者的区别记住一句话:僵尸是死了没人埋,孤儿是活着没了爹。
提示:写练习的时候如果不加
wait,程序结束时终端可能会短暂出现一些异常提示或者残留进程。养成创建后必回收的习惯,代码里fork和wait尽量成对出现。
5.3 进程通信的衔接点,从这里开始
单进程跑通之后,下一步自然要传数据。父子进程之间最直接的是管道,pipe()创建一对读写文件描述符,fork之后父子各持一端,写入读出即可。它的限制是只能单向传字节流,而且缓冲区满了会阻塞。要做双向通信就建两根管道,或者干脆用socketpair拿一对全双工描述符。
进程通信还不止这些。信号适合做通知,比如父进程用SIGTERM让子进程优雅退出;共享内存适合大数据量的高频交换,速度最快但要自己处理同步;消息队列介于两者之间,有边界、有类型,用起来省心。实际项目里也经常用外部手段通信,比如通过一个本地文件、通过标准输入输出、通过本机的套接字,这些本质上都还是操作系统提供的机制在支撑。
学习顺序我的建议是:先把管道吃透,理解字节流、阻塞、EOF 这几个概念;再上共享内存和信号量,理解同步为什么必须存在;最后看套接字,因为它能平滑过渡到跨机器通信。
6. 报错和异常现场的排查实录
6.1 创建阶段直接失败:先看错误码
fork失败基本都是资源问题,CreateProcess失败更多是路径和权限问题。把常见错误码整理成一张表,遇到时按图索骥能省不少时间:
| 错误码 | 出现场景 | 常见原因 | 处理方向 |
|---|---|---|---|
| EAGAIN | Linux fork | 进程数或线程数达到上限 | 查ulimit -u、cgroup 的 pids 限制 |
| ENOMEM | Linux fork | 内核内存不足以分配结构 | 查内存和 overcommit 设置 |
| ENOENT | exec / CreateProcess | 可执行文件路径不存在 | 核对绝对路径、PATH |
| EACCES | exec / CreateProcess | 没有执行权限或目录无权限 | 检查 x 位和目录权限 |
| E2BIG | exec | 参数或环境变量太长 | 精简参数,拆分传递 |
| ERROR_FILE_NOT_FOUND | Windows | 路径写错或没写扩展名 | 用完整路径,注意转义 |
| ERROR_ACCESS_DENIED | Windows | 权限不足或被安全软件拦截 | 换有权限的账户执行 |
排查顺序建议是:先确认程序路径能不能单独跑起来,再确认当前账号有没有权限,最后才怀疑系统资源限制。很多人一上来就怀疑内核参数,其实九成问题是路径写错了。
6.2 进程活着但界面不出来,怎么查
这个现象很常见,桌面程序启动后任务管理器里能看到进程,但窗口就是不出现。能想到的原因至少有四类。
第一类是启动参数带了“不显示窗口”的选项,比如 Windows 的CREATE_NO_WINDOW或者程序自身的静默模式参数。第二类是程序启动后卡在初始化,比如在等一个不可用的网络资源、等一把被占用的文件锁、或者等一个已经退出的父进程。第三类是进程被启动在了不同的会话里,图形界面属于当前登录会话,如果程序是从服务或计划任务里拉起来的,它落在另一个会话中,窗口自然不会出现在你面前。第四类是它本来就是个后台进程,压根没有界面,和同名或相似名字的前台程序不是一回事。
排查办法是逐层排除:先用命令行直接启动,看有没有报错输出;再用进程的父进程 ID 往上找,看是谁把它拉起来的;然后看它的命令行参数,确认是不是带了静默开关;最后看它的工作目录和依赖,很多程序因为找不到配置文件而在初始化阶段卡住。
注意:同一款软件出现多个同名进程,通常不是故障。桌面软件普遍采用主进程加若干辅助进程的结构,辅助进程负责渲染、网络、插件隔离,它们由主进程创建,关掉主窗口时会一起退出。判断是否异常要看资源占用和持续时间,而不是数量本身。
6.3 资源占用异常和“进程拒绝访问”
CPU 和内存突然飙升时,第一步是定位到具体进程,第二步才是判断它是否正常。定位用top按 CPU 排序,或者ps aux --sort=-%cpu | head,Windows 上直接任务管理器按列排序。找到之后看它的命令行、启动时间、父进程,一般就能判断是业务程序、系统组件还是可疑的东西。
“进程无法中断,拒绝访问”这类问题,多数是权限层级造成的。你以普通用户身份运行的任务管理器,看不到也动不了以更高权限运行的进程。解决办法是以相同或更高权限去操作。还有一部分是进程正处于不可中断的内核态等待,这时候任何信号都要等它从系统调用返回才行,硬等或者重启是唯一选择。
和文件相关的“进程占用导致无法删除”,在 Linux 上可以用lsof | grep 文件名或fuser -v 文件名找到持有者,Windows 上资源监视器的“关联的句柄”搜索框也能直接搜文件名。虚拟机软件、数据库这类程序会创建锁文件并长时间持有,异常退出后锁文件残留,下次启动就报“另一个程序已锁定文件的一部分”。处理方式很明确:确认原进程确实不在了,删除残留的锁文件再启动。这一步之前一定要确认,不然可能损坏数据文件。
7. 从这道练习往外延伸的两条线
7.1 进程和线程到底怎么取舍
做完进程创建,几乎所有人都会问同一个问题:既然创建进程有开销,那用线程是不是更好。答案取决于你要保护什么。进程有独立的地址空间,一个崩了不影响另一个,隔离性强,适合跑不可信代码或者容易出内存问题的任务;线程共享地址空间,通信成本低,切换快,但一处越界或一个未捕获异常就可能把整个进程带走。
经验上的分界线是这样:任务之间需要频繁交换大量数据,优先线程;任务之间需要强隔离、或者要跑第三方不信任的代码,优先进程;需要压榨多核又怕全局解释器锁,Python 里就得多进程;任务本身是网络等待为主,异步加线程往往比多进程划算。没有银弹,先测量,再选型。
7.2 把监控和资源分析串成一条线
练习里学的观测命令,放大到整机就是一套监控体系。查看单个进程用ps、top、ProcessHandle;找进程持有的文件用lsof、资源监视器;看进程树用pstree、进程资源管理器;想看历史趋势就得上采集工具,把 CPU、内存、句柄数、线程数按时间存下来。
监控里有两个容易被忽略的指标。一个是句柄数或文件描述符数,程序长时间运行后只涨不降,基本可以确定有资源泄漏。另一个是进程数量,短时间内大量新增进程往往是启动脚本出了问题,比如循环里反复拉起子进程没有退出条件,前面提到的 Python 忘记加主模块保护就是典型例子。把这两个指标加入日常观察清单,很多问题能在爆发前被发现。
我个人在做这类练习时最大的体会是:不要满足于“程序跑起来了”。能跑通只说明语法没错,真正把进程创建搞明白,是你能预测它的行为——知道它会复制什么、什么时候回收、失败时报什么错、在系统里长什么样。把fork的返回值判断写对三次、把僵尸进程亲眼看到一次、把子进程的输出流读到 EOF 一次,这三件事做完,后面再遇到进程池、进程通信、后台进程异常,心里就有底了。最后分享一个我常用的偷懒技巧:写多进程调试代码时,先在worker开头把os.getpid()和os.getppid()打出来,一眼就能看清父子关系,比单步调试快得多。