我们先复习一下 之前的进程间通信(IPC)机制的所有方法:管道、信号量、共享内存、消息队列、套接字。
管道又分为有名管道和无名管道。
一.无名管道
让父进程写子进程读

.角色分配
-
左侧方框(父进程):
-
你看它保留了
fd[1](写端)。 -
箭头指向下方的管道盒子(Write),表示正在往里写数据。
-
注意虚线框 :图中的
fd[0]被画在虚线框里或者被淡化了,这代表父进程关闭了它不需要的读端。
-
-
右侧方框(子进程):
-
它保留了
fd[0](读端)。 -
箭头从下方的管道盒子指出来(Read),表示正在从里读数据。
-
同理,虽然图里没画那么细,但在代码中子进程也应该关闭它不需要的
fd[1](写端)。
-
2. 数据的流向
图中间的箭头流向非常明确: 父进程 fd[1] -> 管道 (Kernel Buffer) -> 子进程 fd[0]
3. 为什么那个"虚线框"(关闭无用端口)很重要?
这不仅仅是画图的规范,更是编程的铁律 。如果你不关闭父进程的 fd[0] 或者子进程的 fd[1],会出现严重 Bug:
- 读不到 EOF :如果子进程试图读取数据,而父进程忘记关闭写端(或者子进程自己忘记关闭继承来的写端),管道的引用计数就不会归零。子进程的
read()会一直认为"还有人可能会写数据",从而永远阻塞(卡死),无法结束。
如上图所示,
代码如下:
cpp
#include <stdio.h> // 对应 printf, fgets
#include <unistd.h> // 对应 pipe, fork, read, write, close
#include <string.h> // 对应 strlen
#include <stdlib.h> // 对应 exit
int main()
{
// --- 第一阶段:准备工作 (对应你刚发的 image_67220f.png) ---
int fd[2]; // fd[0]是读(r), fd[1]是写(w)
// 1. 创建管道
// 【关键点】:必须在 fork 之前创建!
// 这样父子进程才能共享这同一个管道的文件描述符。
if (pipe(fd) == -1)
{
perror("pipe error"); // 建议加上报错打印,方便调试
exit(1);
}
// 2. 创建子进程
pid_t pid = fork();
if (pid == -1)
{
perror("fork error");
exit(1);
}
// --- 第二阶段:分流与通信 (对应 image_671a52.jpg) ---
// 3. 子进程逻辑 (对应手绘图右侧:读)
if (pid == 0)
{
close(fd[1]); // 子进程不需要写,关闭写端
char buff[128] = {0};
// 从管道读取数据。如果管道为空,这里会阻塞等待
read(fd[0], buff, 127);
printf("child read:%s\n", buff);
close(fd[0]); // 读完关闭
}
// 4. 父进程逻辑 (对应手绘图左侧:写)
else
{
close(fd[0]); // 父进程不需要读,关闭读端
printf("input:\n"); // 提示用户输入
char buff[128] = {0};
// 从键盘获取输入
fgets(buff, 128, stdin);
// 将键盘输入的数据写入管道
write(fd[1], buff, strlen(buff));
close(fd[1]); // 写完关闭
}
exit(0);
}
二.总结这里面试可能会问的问题:
1.有名管道(mkfifo)和无名管道(pipe(fd))的区别?
有名可以在任意两个进程间通信,因为任意两个进程之间不需要有任何关系,只要open同一个管道即可。
无名必须是父子关系,因为无名管道没有名字,需要一个进程打开一个管道得到多个写入描述符号,再fork把它复制到子进程中。
2.管道的通讯方式?是全双工、半双工还是单工?
半双工,
单工,信号的传递是固定的,例如:电台发送信号 收音机接收信号 这不可能反过来
半双工,对讲机。可以从A->B 也可以从B->A,但不能同时发送和接收
全双工,打电话。
3.写入管道的数据在哪里?
在内存中
4.说一下管道的实现,怎么理解管道。

1. 本质是内存
"这张图说明管道不是文件,而是内核中的一块固定大小的内存缓冲区。"
2. 机制是队列 (FIFO)
"数据像图中 a, g, c 一样先进先出 。那两个指针分别代表 读指针 和 写指针,它们在缓冲区上像追逐游戏一样循环移动:写指针往后填数据,读指针往后追着取数据。"
3. 同步靠阻塞
"缓冲区有大小限制(size)。写满了会阻塞写进程,读空了会阻塞读进程,以此实现父子进程的自动配合。"
三.信号量
1. 核心概念辨析
-
信号量 \\neq 信号:
-
这是两个完全不同的概念。
-
信号 (Signal) :是你在上一节学的
kill/signal,用于传递消息或通知(类似发短信)。 -
信号量 (Semaphore) :是为了解决进程间"打架"问题的,用于 协调进程对共享资源的访问(类似红绿灯)。
-
2. 临界资源与临界区 (Critical Resource & Critical Section)
为了理解信号量,必须先理解它保护的是什么:
-
临界资源 (Critical Resource):
-
定义 :系统中 一次仅允许一个进程使用 的资源。
-
形象比喻 :你的笔记里那个 "马桶" 的例子非常精准。同一个马桶,不能两个人同时用。这里的"马桶"还可以是打印机、全局变量、数据库连接等。
-
-
临界区 (Critical Section):
-
定义 :本质是代码段 。程序中 访问和操作临界资源的那部分代码 叫做临界区。
-
关系:进入"临界区"执行代码,就意味着正在占用"临界资源"。
-
3. 信号量的本质
-
比喻 :程序中的红绿灯。
- 它控制着进程流:绿灯行(继续执行),红灯停(阻塞等待)。
-
物理意义 :一个计数器 (整数)。
-
这个整数代表 当前可用资源的数量。
-
当值为 1 时 :代表只有 1 个资源(如马桶),这被称为 二值信号量 (Mutex),专门用于互斥。
-
当值 > 1 时 :代表有多个资源(如银行有 3 个柜台),这被称为 计数信号量。
-
4. 核心操作:P 操作与 V 操作 (原子操作)
这是信号量最关键的动作,必须死记硬背:
-
P 操作 (申请资源):
-
关键字 :获取、减 1、阻塞。
-
逻辑 :我要用资源了 \\rightarrow 把计数器 减 1。
-
判断 :如果减完后值 \< 0(或者原值为 0),说明没资源了,进程必须 阻塞 (睡觉等待)。
-
-
V 操作 (释放资源):
-
关键字 :释放、加 1、唤醒。
-
逻辑 :我用完了 \\rightarrow 把计数器 加 1。
-
判断 :如果有其他进程因为没有资源在睡觉,V 操作会把它 唤醒。
-
5. 使用信号量的四个标准步骤 (编程流程)
我们在写代码时(使用 System V 信号量集),通常遵循这四步:
-
创建与初始化 (Create/Init):
-
造出一个信号量,并给它赋初值(比如设为 1,表示有一个马桶)。
-
常用函数:
semget(获取/创建),semctl(设置初始值)
-
-
执行 P 操作 (Wait/Lock):
-
在 进入临界区之前 执行。相当于"敲门、上锁"。
-
常用函数:
semop(将sem_op设为 -1)
-
-
执行 V 操作 (Signal/Unlock):
-
在 退出临界区之后 执行。相当于"开锁、出来"。
-
常用函数:
semop(将sem_op设为 +1)
-
-
释放与删除 (Delete):
-
程序结束时销毁信号量,归还系统内核资源。
-
常用函数:
semctl(IPC_RMID)
-
一句话总结 (记忆口诀)
信号量是红绿灯,保护资源不乱冲。
P 是申请减 1 等,V 是释放加 1 松。
临界区里独自行,代码逻辑记心中
二.方法

下面将对上面这三种方法进行解读:
1. semget ------ "盖房子" (获取/创建)
-
全称:Semaphore Get
-
作用 :创建一个新的信号量集,或者获取一个已经存在的信号量集。
-
参数解读:
-
key_t key: 钥匙 。给这个信号量取个唯一的 ID(通常用ftok函数生成),这样父子进程就能通过同一个 ID 找到它。 -
int num_sems: 数量。你要创建几个信号量?(通常互斥只需要 1 个)。 -
int sem_flags: 权限 。比如IPC_CREAT | 0666,表示"没有就创建,且权限设为可读写"。
-
-
返回值 :成功返回一个 信号量 ID (
sem_id),后续的操作都要凭这个 ID 办事。
2. semctl ------ "装修或拆迁" (控制)
-
全称:Semaphore Control
-
作用 :对信号量进行控制。 最常用的两个功能是:给信号量赋初值 (SETVAL) 和 删除信号量 (IPC_RMID)。
-
参数解读:
-
int sem_id: 刚才semget返回的那个 ID。 -
int sem_num: 操作第几个? (如果你只建了 1 个,这里就是 0)。 -
int command: 干什么?-
SETVAL:初始化 。比如设为 1(表示有一个资源)。需要配合第 4 个参数(union semun)传入具体的值。 -
IPC_RMID:删除。程序结束时把信号量炸掉,回收资源。
-
-
-
记忆点:这个函数是个多面手,既管"生"(初始化)也管"死"(删除)。
3. semop ------ "进出使用" (P/V 操作)
-
全称:Semaphore Operation
-
作用 :执行 P 操作(申请/-1)或 V 操作(释放/+1)。 这是最核心的干活函数。
-
参数解读:
-
int sem_id: 信号量 ID。 -
struct sembuf *sem_ops: 关键结构体。你需要定义一个结构体告诉内核怎么操作:-
sem_op = -1:P 操作 (占用资源)。 -
sem_op = +1:V 操作 (归还资源)。
-
-
size_t num_sem_ops: 一次执行几个操作(通常是 1)。
-
一张表总结 (背诵版)
| 函数名 | 对应动作 | 你的笔记对应 | 核心参数/标志 |
|---|---|---|---|
semget |
创建 | "1. 创建" | IPC_CREAT (创建标记) |
semctl |
初始化 / 删除 | "1. 初始化" & "4. 删除" | SETVAL (设初值), IPC_RMID (删除) |
semop |
P / V 操作 | "2. P操作" & "3. V操作" | sem_op = -1 (P), sem_op = 1 (V) |
现在我们实验性的 模拟一下 A和B进程轮流访问打印的方法
A程序代表回周期性的访问打印机 到底间隔多久访问一次 暂时不知道,程序如下:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/sem.h>
#include <time.h> // 用于 srand
int main()
{
// 初始化随机数种子,避免每次运行休眠时间一样
srand(time(NULL));
for(int i = 0; i < 5; i++ )
{
// --- 临界区开始 ---
printf("A");
fflush(stdout); // 强制刷新缓冲区,确保A立刻显示
int n = rand() % 3; // 随机休眠 0-2 秒
sleep(n);
printf("A");
fflush(stdout);
// --- 临界区结束 ---
n = rand() % 3;
sleep(n); // 模拟非临界区的时间消耗
}
return 0;
}
B程序和A程序 干的工作是一样的 所以下面的就改了print("B")
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/sem.h>
#include <time.h> // 用于 srand
int main()
{
// 初始化随机数种子,避免每次运行休眠时间一样
srand(time(NULL));
for(int i = 0; i < 5; i++ )
{
// --- 临界区开始 ---
printf("B");
fflush(stdout); // 强制刷新缓冲区,确保A立刻显示
int n = rand() % 3; // 随机休眠 0-2 秒
sleep(n);
printf("B");
fflush(stdout);
// --- 临界区结束 ---
n = rand() % 3;
sleep(n); // 模拟非临界区的时间消耗
}
return 0;
}
创建信号量 ,并赋初值为1 我能创建就创建 如果别人创建了 我们就直接用别人的即可
这个函数 会被 A 与 B程序来回调用。
cpp
void sem_init()
{
// 尝试全新创建信号量(key=1234,1个信号量,权限0600)
semid = semget((key_t)1234, 1, IPC_CREAT | IPC_EXCL | 0600);
if (semid == -1) { // 全新创建失败,尝试获取已存在的信号量
semid = semget((key_t)1234, 1, IPC_CREAT | 0600);
if (semid == -1) {
printf("semget err\n");
return;
}
} else { // 全新创建成功,初始化信号量值为1
union semun a;
a.val = 1;
if (semctl(semid, 0, SETVAL, a) == -1) {
printf("semctl setval err\n");
}
}
}
P操作
`sem_p()` 是信号量的P操作,核心是对信号量执行减1操作,本质为申请临界区访问权限(加锁);若信号量当前值≥1则减1成功,进程可进入临界区,若信号量值为0则进程阻塞,直至其他进程释放信号量后再执行减1并继续运行,以此实现多进程互斥访问临界区。
cpp
// 全局变量semid(来自之前的sem_init函数)
int semid;
void sem_p()
{
// 定义信号量操作的结构体(原代码漏了变量名,这里补为buf)
struct sembuf buf;
buf.sem_num = 0; // 操作第0个信号量(因为我们只创建了1个信号量)
buf.sem_op = -1; // P操作:信号量的值减1(申请资源)
buf.sem_flg = SEM_UNDO; // 进程异常退出时,自动恢复信号量值(防止死锁)
// 执行信号量操作(semop:signal operation)
if (semop(semid, &buf, 1) == -1)
{
printf("semop p err\n");
}
}
sem_v()是信号量的 V 操作,本质是释放临界区访问权(解锁):通过把信号量值加 1,完成「归还资源」的动作 ------ 若有其他进程因 P 操作阻塞等待,此时信号量值从 0 变为 1,阻塞的进程会被唤醒,继续执行 P 操作抢占锁;最终实现临界区资源的有序释放与复用,配合 P 操作完成进程间的互斥同步。
cpp
void sem_v()
{
struct sembuf buf; // 定义信号量操作结构体
buf.sem_num = 0; // 操作第0个信号量(仅创建了1个)
buf.sem_op = 1; // V操作:信号量值加1(释放资源)
buf.sem_flg = SEM_UNDO; // 进程异常退出时自动撤销操作
// 执行信号量操作
if (semop(semid, &buf, 1) == -1)
{
printf("semop v err\n");
}
}
`sem_destroy()`是System V信号量的销毁函数,核心通过`semctl`的`IPC_RMID`命令彻底删除已创建的信号量,释放其占用的系统资源;通常在程序结束前调用,避免系统中残留无用的IPC资源,若销毁失败则会打印错误提示;需注意信号量被销毁后,其他进程将无法再访问该资源,若不主动销毁,信号量会长期驻留系统,需手动清理。
cpp
void sem_destroy()
{
// 通过semctl的IPC_RMID命令,销毁信号量(释放系统资源)
if (semctl(semid, 0, IPC_RMID) == -1 )
{
printf("semctl destroy err");
}
}
四.例题 需要几个信号量 初始值分别是多少 如何执行p v操作 来完成 a写入一次 b读取一次

信号量数量与初始值
- 数量 :2 个(对应
s1和s2) - 初始值 :
s1初始值 = 1(标记 "缓冲区为空,可写入")s2初始值 = 0(标记 "缓冲区无数据,不可读取")
2. PV 操作的定义(对应图中ps/vs)
psX= 对信号量X执行P 操作(申请资源,值减 1,无资源则阻塞)vsX= 对信号量X执行V 操作(释放资源,值加 1,唤醒阻塞进程)
3. 完整执行流程(写→读的严格同步)
步骤 1:生产者 a(写数据)的操作
- 先执行
ps1(对s1做 P 操作):s1初始值 = 1 → 减 1 后变为 0,a 获得 "写权限"(此时s1=0,其他写操作会阻塞)。 - 执行
write:将数据写入中间缓冲区。 - 再执行
vs2(对s2做 V 操作):s2初始值 = 0 → 加 1 后变为 1,释放 "读权限"(唤醒等待的消费者 b)。
步骤 2:消费者 b(读数据)的操作
- 先执行
ps2(对s2做 P 操作):s2当前值 = 1 → 减 1 后变为 0,b 获得 "读权限"(此时s2=0,其他读操作会阻塞)。 - 执行
read:从中间缓冲区读取数据。 - 再执行
vs1(对s1做 V 操作):s1当前值 = 0 → 加 1 后变为 1,释放 "写权限"(唤醒等待的生产者 a,可开始下一轮写操作)。
流程验证(保证 "写一次、读一次")
- 初始状态:
s1=1、s2=0→ a 可写,b 不可读; - a 写完后:
s1=0、s2=1→ a 不可写,b 可读; - b 读完后:
s1=1、s2=0→ 回到初始状态,可重复 "a 写→b 读" 的循环。
通过s1和s2的 PV 操作,强制实现了 "写操作完成后才能读,读操作完成后才能写" 的同步逻辑。
极简完整流程(适配截图、一句话串完核心)
✅ 信号量配置:2 个信号量 ,s1=1(允许写)、s2=0(禁止读)。
✅ 全程执行:a 先执行ps1→写数据→vs2;b 再执行ps2→读数据→vs1,循环往复,严格实现「a 写一次、b 读一次」的交替顺序。
✅ 超精简版(最凝练,直接抄)
s1初始 1、s2初始 0;a:ps1→写→vs2;b:ps2→读→vs1,完成写一读交替同步。