C-study day7-8(补档)
C 语言进程间通信(IPC)学习笔记
课程:C 高级 Day7-8 · 进程间通信
日期:2026-06-29
碎碎念:终于也是补完了笔记,职坐标老师还是挺敬业的,就是这一段maybe我需要一段时间去消化。。。或许晚点要更新项目经历
目录
- 僵尸进程(Zombie Process)
- Linux 信号(Signal)
- 信号量(Semaphore)
- 管道(Pipe)
- 共享内存(Shared Memory)
- IPC 方式横向对比
- 附录:常见信号速查表
1. 僵尸进程(Zombie Process)
1.1 什么是僵尸进程?
类比:你雇了一个临时工(子进程),他干完活后下班了,但你(父进程)一直没去 HR 那边办理他的离职手续——他的工号、工资记录还占着系统的名额,这就是"僵尸员工"。
技术定义:子进程已经运行完毕(exit() 退出),但父进程没有调用 wait() / waitpid() 来回收它的资源。此时子进程就处于僵尸状态(Z,Zombie)。
僵尸进程:
- 代码、数据段已经释放
- 但 PCB(进程控制块)仍保留,占用 PID 资源
- 不可以被
kill杀死(它已经死了,只是"尸体"没清走)
进程状态(ps -e u 查看)
R = Running(运行中)
S = Sleeping(休眠)
Z = Zombie(僵尸) ← 就是它
T = Stopped(暂停)
D = 不可中断睡眠
1.2 僵尸进程的危害
- 长期积累会耗尽系统 PID 资源(Linux 默认最大 PID 约 32768)
- 无法通过
kill删除
1.3 如何产生僵尸进程
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程:做完就退出
printf("子进程运行完毕,PID=%d\n", getpid());
// exit(0); ← 退出后父进程没有 wait,就变成僵尸
} else {
// 父进程:一直循环,不回收子进程
while (1) {
sleep(1);
}
}
return 0;
}
此时 ps -e u 会看到子进程状态为 Z。
1.4 解决僵尸进程的三种方法
| 方法 | 说明 | 适用场景 |
|---|---|---|
wait() |
阻塞父进程,等待任意子进程退出并回收 | 简单场景 |
waitpid() |
可以非阻塞等待指定子进程 | 生产环境 |
signal(SIGCHLD, handler) |
子进程退出时内核发 SIGCHLD,父进程在信号处理中回收 | 事件驱动 |
| 强杀父进程 | kill -KILL [父进程PID] → 父进程死后,其僵尸子进程被 init 接管并自动回收 |
应急处理 |
// 方法一:阻塞式等待
#include <sys/wait.h>
wait(NULL); // 等任意子进程,不关心返回状态
// 方法二:非阻塞 waitpid
int status;
pid_t ret = waitpid(child_pid, &status, WNOHANG);
// WNOHANG: 若子进程还没结束则立即返回 0,不挂起父进程
// 方法三:信号回收(见第2章)
signal(SIGCHLD, [回收函数]);
1.5 孤儿进程(顺带了解)
类比:父母(父进程)去世了,孩子(子进程)没人管,但政府(init 进程)会收养他。
父进程先退出,子进程变为孤儿进程,由 init(PID=1)接管,会被正常回收。孤儿进程没有危害,自然结束即可。
2. Linux 信号(Signal)
2.1 什么是信号?
类比:信号就像手机的"震动提醒"——你在做某件事(运行代码),突然收到一个通知(信号),你可以选择:① 按系统默认处理(默认行为)② 忽略它 ③ 自己定义处理方式(信号处理函数)。
技术定义:信号是 Linux 操作系统提供的一种软中断机制,用于进程间通知事件、控制进程行为。
kill -KILL 2223
↓
内核收到命令
↓
内核向 PID=2223 的进程发送 SIGKILL 信号
↓
进程被强制终止
2.2 查看所有信号
kill -l # 显示所有信号编号和名称
常见信号:
| 编号 | 名称 | 含义 | 默认动作 |
|---|---|---|---|
| 1 | SIGHUP | 终端断开 | 终止 |
| 2 | SIGINT | Ctrl+C 中断 |
终止 |
| 3 | SIGQUIT | Ctrl+\ 退出 |
终止+核心转储 |
| 9 | SIGKILL | 强制杀死(不可捕获) | 终止 |
| 11 | SIGSEGV | 段错误(非法内存访问) | 终止+核心转储 |
| 14 | SIGALRM | 定时器到时 | 终止 |
| 15 | SIGTERM | 优雅终止(默认 kill) | 终止 |
| 17 | SIGCHLD | 子进程状态变化 | 忽略 |
| 18 | SIGCONT | 恢复运行 | 继续 |
| 19 | SIGSTOP | 暂停进程(不可捕获) | 暂停 |
⚠️ SIGKILL(9) 和 SIGSTOP(19) 是唯二无法被进程捕获或忽略的信号,是内核的"王牌"。
2.3 信号处理函数 signal()
#include <signal.h>
// 原型
void (*signal(int signum, void (*handler)(int)))(int);
// 简化理解:
signal(信号编号, 处理函数);
三种处理方式:
| 处理方式 | 写法 | 效果 |
|---|---|---|
| 自定义函数 | signal(SIGINT, my_handler) |
调用 my_handler |
| 忽略信号 | signal(SIGINT, SIG_IGN) |
忽略该信号 |
| 恢复默认 | signal(SIGINT, SIG_DFL) |
使用默认行为 |
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// 自定义 Ctrl+C 处理
void my_handler(int signum) {
printf("\n捕获到信号 %d(Ctrl+C),即将退出...\n", signum);
// 这里可以做清理工作
_exit(0); // 手动退出
}
int main() {
signal(SIGINT, my_handler); // 绑定 Ctrl+C(SIGINT=2)
while (1) {
printf("进程运行中... 按 Ctrl+C 触发自定义处理\n");
sleep(1);
}
return 0;
}
2.4 用信号回收子进程(SIGCHLD)
#include <stdio.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
void sigchld_handler(int sig) {
// 非阻塞等待所有退出的子进程
while (waitpid(-1, NULL, WNOHANG) > 0);
printf("子进程已被回收!\n");
}
int main() {
signal(SIGCHLD, sigchld_handler); // 注册信号处理
pid_t pid = fork();
if (pid == 0) {
printf("子进程 %d 即将退出\n", getpid());
_exit(0); // 子进程退出 → 触发 SIGCHLD
}
// 父进程继续做自己的事,不需要显式 wait
sleep(3);
printf("父进程结束\n");
return 0;
}
2.5 用 SIGALRM 制作定时器
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
int count = 0;
void alarm_handler(int sig) {
count++;
printf("定时器触发:已过 %d 秒\n", count);
alarm(1); // 重新设定下一次 1 秒后触发
}
int main() {
signal(SIGALRM, alarm_handler);
alarm(1); // 1 秒后发送 SIGALRM
while (1) {
pause(); // 等待信号
}
return 0;
}
3. 信号量(Semaphore)
3.1 什么是信号量?
类比:停车场门口有一个显示牌,写着"剩余 5 个车位"。
- 进车(P 操作):牌子 -1,如果显示 0 就在门口等(阻塞)
- 出车(V 操作):牌子 +1,门口等待的车进来
这块显示牌就是信号量,它被所有司机(进程)共同看见,用来协调谁能进停车场(临界区)。
技术定义:信号量是一种特殊的整型计数器,可被不同进程同时访问,用于控制对共享资源的访问。Linux 中的 System V 信号量使用以下操作:
- P 操作(减少/等待):信号量 -1;若减后 < 0 则阻塞等待
- V 操作(增加/释放):信号量 +1;唤醒等待的进程
3.2 信号量三步走
信号量的使用分三个步骤:创建 → 控制/初始化 → 操作(P/V)
步骤一:创建/获取信号量 semget()
#include <sys/sem.h>
int semget(key_t key, int nsems, int semflg);
| 参数 | 说明 |
|---|---|
key |
信号量的唯一键值,用 ftok() 生成或直接指定整数 |
nsems |
信号量集中信号量的数量(通常为 1) |
semflg |
权限标志,IPC_CREAT | 0666 表示不存在就创建 |
| 返回值 | 信号量 ID(semid),失败返回 -1 |
// 生成一个 key
key_t key = ftok("/tmp", 'A'); // 用路径和字符生成唯一 key
// 或者
key_t key = 1234; // 直接用整数(进程间约定同一个数即可)
int semid = semget(key, 1, IPC_CREAT | 0666);
if (semid == -1) { perror("semget"); exit(1); }
步骤二:控制信号量 semctl()(初始化/删除)
int semctl(int semid, int semnum, int cmd, ...);
| 参数 | 说明 |
|---|---|
semid |
信号量 ID |
semnum |
信号量集中的第几个(从 0 开始) |
cmd |
操作命令,常用 SETVAL(设置值)、IPC_RMID(删除) |
| 第四个参数 | 联合体 union semun(设置值时用) |
// 联合体定义(有些系统需要手动定义)
union semun {
int val;
struct semid_ds *buf;
unsigned short *array;
};
// 初始化信号量值为 1(相当于互斥锁)
union semun arg;
arg.val = 1;
semctl(semid, 0, SETVAL, arg);
// 删除信号量
semctl(semid, 0, IPC_RMID);
步骤三:P/V 操作 semop()
int semop(int semid, struct sembuf *sops, size_t nops);
struct sembuf 结构:
struct sembuf {
unsigned short sem_num; // 操作哪个信号量(0 表示第一个)
short sem_op; // >0 是 V 操作,<0 是 P 操作,0 是等待为 0
short sem_flg; // 通常填 0 或 SEM_UNDO
};
// P 操作(申请资源,信号量 -1)
struct sembuf p_op = {0, -1, 0};
semop(semid, &p_op, 1); // 若信号量为 0 则阻塞
// V 操作(释放资源,信号量 +1)
struct sembuf v_op = {0, +1, 0};
semop(semid, &v_op, 1);
3.3 完整例子:父子进程交替输出
#include <stdio.h>
#include <sys/sem.h>
#include <unistd.h>
#include <stdlib.h>
union semun { int val; };
void P(int semid) { // 等待(-1)
struct sembuf op = {0, -1, 0};
semop(semid, &op, 1);
}
void V(int semid) { // 释放(+1)
struct sembuf op = {0, +1, 0};
semop(semid, &op, 1);
}
int main() {
key_t key = ftok("/tmp", 'S');
int semid = semget(key, 1, IPC_CREAT | 0666);
// 初始值为 1:父进程先执行
union semun arg; arg.val = 1;
semctl(semid, 0, SETVAL, arg);
pid_t pid = fork();
for (int i = 0; i < 5; i++) {
if (pid == 0) {
P(semid); // 子进程等父进程释放
printf("子进程输出 %d\n", i);
V(semid); // 释放给父进程
} else {
P(semid); // 父进程先拿到信号量
printf("父进程输出 %d\n", i);
V(semid); // 释放给子进程
}
}
if (pid > 0) {
wait(NULL);
semctl(semid, 0, IPC_RMID); // 清理
}
return 0;
}
4. 管道(Pipe)
4.1 什么是管道?
类比:两栋楼(两个进程)之间埋了一根水管(管道)。进程 A 往水管里倒水(写数据),进程 B 从水管另一头取水(读数据)。水管里的水是先进先出的(FIFO),而且同一时间只能单向流动(半双工)。
管道是最基础的 IPC 方式,数据从写端流向读端,遵循 FIFO(先进先出)。
4.2 三类管道
| 类型 | 标识 | 是否需要提前创建文件 | 适用范围 |
|---|---|---|---|
| 标准 I/O 管道 | popen() |
否 | 与外部命令通信 |
| 匿名管道 | pipe() |
否 | 有亲缘关系的进程(父子/兄弟) |
| 命名管道(FIFO) | mkfifo() |
是 | 任意进程间通信 |
4.3 标准 I/O 管道:popen()
最简单的管道,相当于在 C 代码里运行一个 shell 命令并读取输出。
#include <stdio.h>
FILE *popen(const char *command, const char *mode);
int pclose(FILE *stream);
| 参数 | 说明 |
|---|---|
command |
Shell 命令字符串 |
mode |
"r" 读取命令输出,"w" 向命令写数据 |
| 返回值 | FILE 指针,失败返回 NULL |
#include <stdio.h>
int main() {
// 读取 uname -a 命令的输出
FILE *fp = popen("uname -a", "r");
if (!fp) { perror("popen"); return 1; }
char buf[256];
while (fgets(buf, sizeof(buf), fp)) {
printf("%s", buf);
}
pclose(fp); // 必须用 pclose,不是 fclose
return 0;
}
4.4 匿名管道:pipe()
匿名管道"没有名字",无法通过文件系统找到它——它只活在内核的内存里。
只有父子进程(fork 后)能共享这个管道的"两端"文件描述符。
#include <unistd.h>
int pipe(int pipefd[2]);
// pipefd[0] = 读端(read end)
// pipefd[1] = 写端(write end)
使用步骤:
1. 父进程调用 pipe() 创建管道,拿到 pipefd[0](读)和 pipefd[1](写)
2. 父进程 fork() 子进程,子进程继承两个 fd
3. 确定通信方向(父→子 或 子→父),各自关闭不用的那端
4. 写端写数据,读端读数据
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main() {
int pipefd[2];
pipe(pipefd); // 创建管道
// pipefd[0] = 读端, pipefd[1] = 写端
pid_t pid = fork();
if (pid == 0) {
// 子进程:接收数据
close(pipefd[1]); // 关闭写端(子进程只读)
char buf[64] = {0};
read(pipefd[0], buf, sizeof(buf));
printf("子进程收到:%s\n", buf);
close(pipefd[0]);
} else {
// 父进程:发送数据
close(pipefd[0]); // 关闭读端(父进程只写)
char msg[] = "Hello, 子进程!";
write(pipefd[1], msg, strlen(msg));
close(pipefd[1]);
wait(NULL);
}
return 0;
}
⚠️ 重要细节:
- 写端全部关闭后,读端
read()会返回 0(EOF)- 读端全部关闭后,写端
write()会触发SIGPIPE信号- 管道是半双工的,不能同时双向传输
4.5 半双工说明
半双工(对讲机模式):
A → B:A 说话,B 听(B 不能同时说话)
B → A:B 说话,A 听(A 不能同时说话)
全双工(电话模式):
A 和 B 可以同时说话、同时听
一个 pipe() 只能单向通信。如果需要双向通信,需要创建两个管道。
4.6 命名管道(FIFO):mkfifo()
类比:匿名管道是两个人之间的"私信",命名管道是广场上的"公告栏"——任何知道地址的进程都能来读写。
#include <sys/stat.h>
int mkfifo(const char *path, mode_t mode);
// path:管道文件的路径,如 "/tmp/myfifo"
// mode:权限,如 0666
使用步骤:
# 或者在 shell 里创建
mkfifo /tmp/myfifo
// ========= 写进程(发送方) =========
#include <stdio.h>
#include <fcntl.h>
#include <string.h>
#include <unistd.h>
int main() {
int fd = open("/tmp/myfifo", O_WRONLY); // 打开管道(会阻塞直到有读端)
char msg[] = "进程A向进程B发送的消息";
write(fd, msg, strlen(msg));
close(fd);
return 0;
}
// ========= 读进程(接收方) =========
int main() {
mkfifo("/tmp/myfifo", 0666); // 如果不存在则创建
int fd = open("/tmp/myfifo", O_RDONLY);
char buf[128] = {0};
read(fd, buf, sizeof(buf));
printf("进程B收到:%s\n", buf);
close(fd);
return 0;
}
命名管道 vs 匿名管道:
对比 匿名管道 命名管道(FIFO) 是否有文件 ❌ 纯内存 ✅ 文件系统中有对应文件(特殊文件) 通信范围 只能父子/兄弟进程 任意进程(知道路径就行) 生命周期 随进程消亡 随文件删除而消亡
5. 共享内存(Shared Memory)
5.1 什么是共享内存?
类比:多个同事共用一块白板(共享内存)。任何人写上去(写数据),其他人立刻就能看到(读数据)。这是最直接高效的沟通方式——但需要注意:如果两个人同时在白板上乱写(竞争条件),信息就会混乱,所以通常配合信号量一起使用。
技术定义:共享内存是在物理内存中分配一块区域,将它映射到多个进程的地址空间。各进程直接读写这块内存,不经过内核——速度是所有 IPC 方式中最快的。
进程A的虚拟地址空间 物理内存
┌────────────────┐
│ 代码段 │
│ 数据段 │
│ 堆 │
│ 共享内存映射 ├──────────→ ┌──────────────┐
│ (ptr 指向这里)│ │ 共享内存块 │←── 进程B也映射到这里
└────────────────┘ └──────────────┘
5.2 共享内存四步走
共享内存使用顺序:创建 → 连接(映射)→ 使用 → 解除连接 → 删除
步骤一:创建共享内存 shmget()
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
| 参数 | 说明 |
|---|---|
key |
共享内存的唯一键值(同一 key 同一块内存) |
size |
共享内存大小(字节),实际分配为内存页的整数倍 |
shmflg |
IPC_CREAT | 0666 创建权限 |
| 返回值 | 共享内存 ID(shmid) |
key_t key = ftok("/tmp", 'M');
int shmid = shmget(key, 1024, IPC_CREAT | 0666);
// 申请 1024 字节的共享内存
步骤二:连接共享内存 shmat()
void *shmat(int shmid, const void *shmaddr, int shmflg);
| 参数 | 说明 |
|---|---|
shmid |
共享内存 ID |
shmaddr |
映射到的地址,通常填 NULL(让内核自动分配) |
shmflg |
0(读写)或 SHM_RDONLY(只读) |
| 返回值 | 共享内存的起始地址指针,失败返回 (void*)-1 |
char *ptr = (char *)shmat(shmid, NULL, 0);
if (ptr == (void *)-1) { perror("shmat"); exit(1); }
// 现在可以像操作普通指针一样读写 ptr
步骤三:使用共享内存
// 写入数据
sprintf(ptr, "Hello from 进程A!");
// 读取数据
printf("读到:%s\n", ptr);
步骤四:解除连接 shmdt()
int shmdt(const void *shmaddr);
shmdt(ptr); // 进程不再使用,解除映射
// 注意:只是解除本进程的映射,共享内存本身仍然存在
步骤五:删除共享内存 shmctl()
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
shmctl(shmid, IPC_RMID, NULL); // 删除共享内存
⚠️ 共享内存不会随进程退出而自动销毁!需要手动
shmctl(IPC_RMID)删除,否则一直占用系统内存。使用ipcs -m查看系统中的共享内存,ipcrm -m [shmid]手动删除。
5.3 完整双进程通信例子
// ========= 写进程(进程A)=========
#include <stdio.h>
#include <sys/shm.h>
#include <string.h>
#include <unistd.h>
int main() {
key_t key = ftok("/tmp", 'M');
int shmid = shmget(key, 512, IPC_CREAT | 0666);
char *ptr = (char *)shmat(shmid, NULL, 0);
sprintf(ptr, "进程A的消息:当前时间戳 %ld", (long)time(NULL));
printf("写入完毕:%s\n", ptr);
sleep(5); // 等进程B读完
shmdt(ptr);
shmctl(shmid, IPC_RMID, NULL); // 只有一方删除即可
return 0;
}
// ========= 读进程(进程B)=========
#include <stdio.h>
#include <sys/shm.h>
#include <unistd.h>
int main() {
sleep(1); // 等进程A写入
key_t key = ftok("/tmp", 'M');
int shmid = shmget(key, 512, 0666); // 不加 IPC_CREAT,获取已有的
char *ptr = (char *)shmat(shmid, NULL, SHM_RDONLY);
printf("进程B读到:%s\n", ptr);
shmdt(ptr);
return 0;
}
6. IPC 方式横向对比
类比总结:
- 信号:手机通知推送,告诉你"有事了",但不传数据
- 管道:水管,数据流动,只能单向,先进先出
- 信号量:停车场剩余车位牌,控制谁能进入临界区
- 共享内存:公用白板,速度最快,但需要配合信号量防止乱写
| 对比维度 | 信号 Signal | 管道 Pipe | 信号量 Semaphore | 共享内存 |
|---|---|---|---|---|
| 通信内容 | 整数信号编号 | 字节流数据 | 计数器(无数据) | 任意数据 |
| 速度 | 快 | 中 | 快 | ⭐ 最快 |
| 适用进程 | 任意 | 父子/任意 | 任意 | 任意 |
| 是否有缓冲 | 无 | 有(内核缓冲) | 无 | 无(直接内存) |
| 是否需要同步 | 否 | 自带顺序 | 自带同步机制 | ⚠️ 需要配合信号量 |
| 能否持久化 | 否 | 否 | 否 | 否(需手动删除) |
| 头文件 | <signal.h> |
<unistd.h> |
<sys/sem.h> |
<sys/shm.h> |
7. 附录:常见信号速查表
7.1 默认终止的信号
| 信号 | 编号 | 触发方式 |
|---|---|---|
| SIGINT | 2 | Ctrl+C |
| SIGQUIT | 3 | Ctrl+\ |
| SIGKILL | 9 | kill -9 (不可捕获) |
| SIGSEGV | 11 | 非法内存访问(段错误) |
| SIGTERM | 15 | kill(默认) |
| SIGALRM | 14 | alarm() 定时器到时 |
7.2 默认暂停的信号
| 信号 | 编号 | 触发方式 |
|---|---|---|
| SIGSTOP | 19 | kill -STOP(不可捕获) |
| SIGTSTP | 20 | Ctrl+Z |
7.3 默认忽略的信号
| 信号 | 编号 | 说明 |
|---|---|---|
| SIGCHLD | 17 | 子进程状态变化 |
| SIGURG | 23 | socket 紧急数据 |
7.4 IPC 系统命令速查
# 查看当前系统 IPC 资源
ipcs # 查看全部
ipcs -m # 查看共享内存
ipcs -s # 查看信号量
ipcs -q # 查看消息队列
# 删除 IPC 资源
ipcrm -m [shmid] # 删除共享内存
ipcrm -s [semid] # 删除信号量
# 查看进程
ps -e u # 查看所有进程
ps -o pid,ppid,stat,command # 定制显示字段
kill -l # 查看所有信号
kill -KILL [pid] # 强制终止进程

浙公网安备 33010602011771号