C-study day7-8(补档)

C 语言进程间通信(IPC)学习笔记

课程:C 高级 Day7-8 · 进程间通信
日期:2026-06-29
碎碎念:终于也是补完了笔记,职坐标老师还是挺敬业的,就是这一段maybe我需要一段时间去消化。。。或许晚点要更新项目经历


目录

  1. 僵尸进程(Zombie Process)
  2. Linux 信号(Signal)
  3. 信号量(Semaphore)
  4. 管道(Pipe)
  5. 共享内存(Shared Memory)
  6. IPC 方式横向对比
  7. 附录:常见信号速查表

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]                 # 强制终止进程
posted @ 2026-06-29 01:14  小怪兽zzy  阅读(3)  评论(0)    收藏  举报