1. 从这道上机题说起为什么进程与线程通讯是操作系统的分水岭吉林大学操作系统上机实验一选题是 Linux 进程与线程通讯。很多人第一次看到这个题目会觉得不就是 fork 一下、开个线程、发个信号嘛然后花两个小时把代码敲出来交差。但我见过太多同学在后面几个实验里反复卡壳根源几乎都出在实验一没吃透——他们只是让程序跑起来了却没搞清楚进程之间为什么要通讯、线程之间共享什么、内核在这个过程中到底做了什么。这篇文章我想按一个过来人重新做一遍实验的思路把这道题背后的东西摊开讲。适合三类人看正在做这个实验、想拿高分而不是拿及格的同学学过操作系统理论但不知道代码怎么落地的人以及后面要碰进程池、线程池、分布式模拟这些内容的开发者因为实验一里的每一个概念都会在那些场景里反复出现。先给一个最直白的区分。进程是操作系统分配资源的基本单位它有独立的地址空间、文件描述符表、信号处理表线程是 CPU 调度的基本单位同一进程内的线程共享地址空间和大部分资源。这个说法教科书上都有但真正做实验时你才会发现独立地址空间意味着你在父进程里改一个全局变量子进程根本看不见共享地址空间意味着两个线程同时对一个变量加一结果可能少加好几次。实验一让你给进程和线程分别实现通讯机制本质上是在逼你亲手撞上这两堵墙。通讯方式的选择直接决定了代码的形态。进程通讯IPC在 Linux 下有管道、命名管道、消息队列、共享内存、信号量、信号、套接字这几大类线程通讯虽然也能借用这些但更多时候靠的是互斥锁、条件变量、读写锁、信号量这些同步原语加上共享内存这种天然的通讯方式。实验的难点从来不是调用哪个函数而是我该选哪个、为什么选它、选错了会怎样。我把这道题拆成几个必须自己想明白的问题进程之间怎么传递数据、线程之间怎么避免打架、父进程怎么知道子进程干完了、SIGCHLD 信号到底什么时候来、僵尸进程是怎么产生的。下面逐一展开每一个我都会给出可运行的代码和踩过的坑。2. 进程通讯的四种落地方式与选型逻辑2.1 匿名管道最简单也最容易踩 SIGPIPE匿名管道是用得最多的进程通讯方式pipe()返回两个文件描述符一个读端一个写端。父子进程通过fork继承这两个描述符就能一个写一个读。它的本质是内核里的一块环形缓冲区默认 64KB写满之后写端会阻塞读空之后读端会阻塞。一个典型的坑fork之后父进程和子进程各自都持有读端和写端。如果你忘了关闭不用的那一端读端永远等不到 EOFread会一直阻塞。正确的做法是父进程写数据就关掉自己的读端子进程读数据就关掉自己的写端。我在第一次做这个实验时就在这里卡了半小时程序既不报错也不退出用strace跟了一下才发现卡在read上。#include stdio.h #include unistd.h #include string.h #include sys/wait.h int main() { int fd[2]; pid_t pid; char buf[64]; if (pipe(fd) 0) { perror(pipe); return 1; } pid fork(); if (pid 0) { close(fd[1]); // 子进程只读关掉写端 int n read(fd[0], buf, sizeof(buf) - 1); buf[n] \0; printf(child got: %s\n, buf); close(fd[0]); } else { close(fd[0]); // 父进程只写关掉读端 write(fd[1], hello from parent, 17); close(fd[1]); wait(NULL); } return 0; }管道还有个经典问题如果读端已经关闭写端继续写会触发SIGPIPE默认行为是直接杀死进程。做实验时如果程序莫名其妙无输出退出先在写操作前加signal(SIGPIPE, SIG_IGN)排查一下多半就是这个原因。2.2 命名管道 FIFO让不相干的进程也能对话匿名管道只能用于有亲缘关系的进程。实验里如果要求两个独立编译的程序通讯就得上命名管道。mkfifo创建一个特殊文件任何进程都能像打开普通文件一样open它。这里有个反直觉的点FIFO 的阻塞行为取决于打开方式。以只读方式open会阻塞直到有进程以写方式打开反过来也一样。如果不想阻塞可以用O_NONBLOCK。做实验时经常遇到A 程序先跑卡住、B 程序后跑才通的现象就是这个原因。// writer.c 编译成 writer int fd open(/tmp/myfifo, O_WRONLY); write(fd, ping, 4); close(fd); // reader.c 编译成 reader int fd open(/tmp/myfifo, O_RDONLY); read(fd, buf, sizeof(buf)); close(fd);用之前记得mkfifo /tmp/myfifo 0666建好且用完unlink删掉否则下次运行mkfifo会报File exists。2.3 共享内存最快的 IPC也是最需要同步的共享内存是所有 IPC 方式里速度最快的因为它省掉了两次数据拷贝。shmget申请shmat挂载到进程地址空间之后两个进程就像访问普通内存一样访问同一块区域。但问题也来了内核不提供任何同步。两个进程同时写同一块内存结果完全不可预测。所以共享内存几乎总是和信号量配对使用。实验里如果只做共享内存不做同步老师一看就知道你没理解通讯和同步是两回事——共享内存解决的是怎么让两个进程看到同一份数据信号量解决的是怎么保证同一时刻只有一个进程动这份数据。int shmid shmget(IPC_PRIVATE, 4096, IPC_CREAT | 0666); char *shm (char *)shmat(shmid, NULL, 0); strcpy(shm, shared); // 用完 detach最后 shmctl(shmid, IPC_RMID, NULL)注意用IPC_PRIVATE申请的话父子进程能继承但独立进程之间需要用ftok生成 key。另外一个坑是共享内存的生命周期独立于进程——进程退出了共享内存段还在必须显式删除否则ipcs -m里会堆一堆残留系统重启前一直占着内存。2.4 消息队列与信号告诉对方数据到了消息队列允许进程按类型发送和接收带边界的消息比管道的字节流更结构化。msgget建队列msgsnd发msgrcv收。它和管道的核心区别是管道是流式的消息队列是记录式的每条消息有明确的长度和类型。信号则是另一类东西——它是通知不是传数据。kill(pid, SIGUSR1)发一个信号对方在信号处理函数里做响应。实验里常用信号来通知数据准备好了。这里必须注意信号处理函数里能安全调用的函数很有限叫异步信号安全函数像printf就不在其中严格来说会引发未定义行为。稳妥做法是设一个volatile sig_atomic_t标志主循环去轮询。下表把四种方式的关键属性列在一起方便选型方式通讯类型同步支持适用场景生命周期匿名管道字节流无父子进程简单传递随进程命名管道字节流无任意进程传递随文件系统共享内存任意结构需手动加锁大数据量高频通讯随内核消息队列记录流内置阻塞结构化消息传递随内核选型的判断顺序我一般是这样有亲缘关系且只传一点数据用管道要传结构体或大量数据用共享内存加信号量需要消息边界和优先级用消息队列只是通知对方某个事件用信号。3. 线程通讯共享是福利也是麻烦的开始3.1 线程之间根本不用传递数据麻烦在同步做进程通讯时你在想数据怎么送过去做线程通讯时你几乎不用想这件事——同一进程的线程共享全局变量、堆内存、文件描述符直接访问就行。真正的难题变成了多个线程同时碰一个变量怎么办。这里必须先讲清楚一个底层事实i在汇编层面是三步——读内存到寄存器、寄存器加一、写回内存。两个线程如果交错执行这三步就会丢更新。这不是理论是每次不假锁都会复现的必然结果。我做过一个测试两个线程各自加一千万次不加锁的结果每次都不一样且都小于两千万。#include pthread.h #include stdio.h int counter 0; pthread_mutex_t mtx PTHREAD_MUTEX_INITIALIZER; void *worker(void *arg) { for (int i 0; i 10000000; i) { pthread_mutex_lock(mtx); counter; pthread_mutex_unlock(mtx); } return NULL; }把pthread_mutex_lock/unlock去掉结果立刻崩加上结果永远精确。这就是互斥锁存在的全部意义。3.2 条件变量解决生产者等消费者的经典模型光有互斥锁还不够。生产者和消费者模型里消费者发现队列空了如果只是不停地加锁、检查、解锁、再检查这是忙等待白白烧 CPU。正确做法是用条件变量消费者在队列空时pthread_cond_wait进入等待队列并释放锁生产者放数据后pthread_cond_signal唤醒它。pthread_cond_wait必须和一个 mutex 配合而且它内部做了三件事释放 mutex、阻塞、被唤醒后重新获取 mutex。这个重新获取是很多人忽略的——唤醒不代表立刻拿到锁可能还要等。还有一个必须遵守的规则pthread_cond_wait必须放在while循环里检查条件不能用if。因为存在虚假唤醒操作系统可能在没有signal的情况下把你唤醒。用while才能保证醒来后条件真的成立。pthread_mutex_lock(mtx); while (queue_empty()) { pthread_cond_wait(not_empty, mtx); } pop_item(); pthread_mutex_unlock(mtx);这个while的写法我建议直接背下来实验和以后写线程池都会用到。3.3 信号量与读写锁按场景选同步原语互斥锁的值只有 0 和 1信号量可以是任意整数。用信号量能实现最多 N 个线程同时访问的限流效果这在实验里常用来模拟资源池。sem_wait减一减到 0 就阻塞sem_post加一唤醒等待者。读写锁适合读多写少的场景。多个读者可以同时持有读锁但写锁是排他的。判断标准很简单如果你发现用互斥锁时大部分线程都只是在读那读写锁能明显提升并发度。不过要注意读写锁的实现比互斥锁复杂读少写多时反而更慢别盲目上。同步原语值域允许多个同时进入典型场景互斥锁0/1否保护临界区信号量非负整数是N 个资源计数、限流条件变量无状态配合 mutex等待某条件成立读写锁读/写读可并发缓存、配置读取3.4 线程通讯里最容易忽略的死锁死锁在实验一里可能不出现但实验二、三一定会撞上。四个必要条件互斥、持有并等待、不可抢占、循环等待。打破其中任何一个就能避免。最实用的是打破循环等待——给所有锁编号强制按固定顺序加锁。我踩过的一个坑是两个线程各持有 A、B 中的一个然后互相等对方释放程序直接挂死。用gdb attach上去看info threads加bt能看到两个线程都卡在lock上但看不出谁持有谁。后来装了helgrindvalgrind 的线程检查工具才定位到加锁顺序问题。这类工具建议在做实验时就养成用的习惯。4. 父进程如何确知子进程的命运wait 与 SIGCHLD 的配合4.1 僵尸进程是怎么产生的为什么要 wait子进程退出时它的资源大部分被回收但内核会保留它的一部分信息退出状态、PID在进程表里等着父进程来读取。这段时间的进程叫僵尸进程状态显示为Z。如果父进程一直不wait僵尸就一直占着进程表项PID 被占用不能重用数量多了会导致fork失败。wait和waitpid就是来收尸的。wait阻塞到任意一个子进程退出waitpid可以指定 PID 并配合WNOHANG做非阻塞查询。实验里最稳妥的写法是循环waitpid因为信号可能丢失——如果多个子进程几乎同时退出SIGCHLD只会被记录一次信号不排队一次wait只能收一个。用循环能保证全部回收。while (waitpid(-1, status, WNOHANG) 0) { // 逐个回收直到没有可回收的 }4.2 SIGCHLD 处理函数的正确姿势很多人第一反应是在SIGCHLD处理函数里直接wait。这有风险如果处理函数执行时又来一个SIGCHLD默认可能阻塞在同类信号上导致漏收。更稳的写法是在处理函数里只设置一个标志主循环里再去做回收。还有一点SIGCHLD处理函数里绝对不能调用printf、malloc这类非异步信号安全函数。我见过同学在信号里printf导致偶发卡死查了半天以为是逻辑错其实是信号安全的锅。要么用write它是安全的要么只设标志。volatile sig_atomic_t child_exited 0; void handler(int sig) { child_exited 1; // 只做这一件事 }如果实验要求父进程在子进程退出后做处理这种标志位 主循环回收的模式是最安全的别图省事在信号里干活。4.3 进程退出状态里藏着的信息wait拿到的status不是一个简单的整数得用宏来解读。WIFEXITED判断是否正常退出WEXITSTATUS取退出码WIFSIGNALED判断是否被信号杀死WTERMSIG取信号号。如果子进程是段错误死的SIGSEGV你通过WIFSIGNALED就能发现而不是傻等一个正常退出码。宏作用典型用法WIFEXITED是否正常退出判断后取 WEXITSTATUSWEXITSTATUS退出码0 通常表示成功WIFSIGNALED是否被信号终止判断后取 WTERMSIGWTERMSIG终止信号号11 代表段错误实验里要求判断子进程是否执行成功时直接用WEXITSTATUS(status) 0就够了。5. 上机调试那些让程序看起来没错却过不了的细节5.1 编译命令里最容易漏的 -lpthread线程相关函数在pthread库里编译时必须加-lpthread否则链接报错undefined reference to pthread_create。更隐蔽的是在某些环境下它可能不报错但运行行为异常。稳妥写法是-lpthread放在源文件之后gcc -o demo demo.c -lpthread我习惯再加几个开关-Wall -Wextra -g把警告全打开把调试信息带上。警告里经常藏着真 bug比如printf格式不匹配、未初始化变量。5.2 用 ipcs 和 ps 检查残留资源做 IPC 实验最烦的是资源残留。共享内存、消息队列、信号量如果没删干净下次运行会因为 key 冲突或资源泄漏出各种怪问题。我一般会准备这几条命令随时查ipcs -m看共享内存段ipcs -q看消息队列ipcs -s看信号量ps -ef | grep defunct看僵尸进程lsof -p pid看进程打开的 fd清理可以用ipcrm -m id单独删或者写个脚本一次性清空自己的 IPC 资源。养成用完就删的习惯比出问题再排查省事得多。5.3 strace 和 ltrace卡住时的第一反应程序不报错也不退出八成是阻塞在某个系统调用上。这时候strace是最快的定位手段strace -f -o trace.log ./demo-f跟子进程-o输出到文件。跑一次看最后停在哪基本就锁定了。我遇到过的典型场景卡在read上说明对端没写或没关卡在open上说明 FIFO 对端没打开卡在futex上说明线程在等锁。而ltrace看的是库函数调用排查pthread、malloc相关的问题更直观。这两个工具属于做实验必备比在代码里到处加printf高效得多。5.4 常见错误速查表现象可能原因排查手段程序不退出read/wait/lock 阻塞strace 看停在哪结果每次不同数据竞争valgrind helgrind无输出直接退出SIGPIPE加 signal(SIGPIPE, SIG_IGN)链接报错漏 -lpthread补编译参数进程列表有 Z没 wait加 waitpid 循环资源冲突IPC 残留ipcs 查、ipcrm 清做实验一的时候把这些过一遍基本上不需要老师帮你调。而且这些工具和思路在后面做模拟分布式、进程池的题目时会一直用到。6. 把实验一吃透之后这套知识能迁移到哪里实验一最大的价值不是让你会写fork和pthread_create而是让你建立起一套资源怎么分、数据怎么走、竞争怎么防的思维模型。我后面做线程池的时候核心就三样东西一个任务队列用互斥锁和条件变量保护、一组工作线程、一个任务投递接口。任务队列的每一行几乎都能在实验一的线程通讯里找到原型。进程池则是把线程池的思路搬到了进程层面通讯方式换成管道或共享内存。再往后做模拟分布式本质上是把管道和套接字从同一台机器扩展到两台机器同步原语的思路完全一致——只不过从线程级的锁变成了分布式锁。PNG 图片、视频编码里用的多线程管线底层也是这套多个工作线程处理不同的帧用条件变量控制节奏用信号量限制在途任务数。你会发现整个计算机世界里并发相关的代码骨架都长得差不多。所以我给做这道实验的同学一个建议别只追求跑通在fork前后打印一下 PID 和变量地址看看地址空间怎么变的在加锁和去掉锁的两版代码上各跑几次记录counter的差异用strace完整跟一遍自己的程序。这些动作花不了多少额外时间但能让你对进程和线程到底差在哪这件事有肌肉记忆而不是停留在背概念。Linux 下还有几个和这道题高度相关的常识顺带提一下。进程查看用ps、top、pstree实时进程树状态可以看/proc/pid/status里面Threads字段直接给出线程数内存和 CPU 异常排查用top -H看线程级别的占用。这些命令和实验一合起来看能把 Linux 进程模型这一块补得比较完整。