进程间通信 管道-信号量

我们先复习一下 之前的进程间通信(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 信号量集),通常遵循这四步:

  1. 创建与初始化 (Create/Init)

    • 造出一个信号量,并给它赋初值(比如设为 1,表示有一个马桶)。

    • 常用函数:semget (获取/创建), semctl (设置初始值)

  2. 执行 P 操作 (Wait/Lock)

    • 进入临界区之前 执行。相当于"敲门、上锁"。

    • 常用函数:semop (将 sem_op 设为 -1)

  3. 执行 V 操作 (Signal/Unlock)

    • 退出临界区之后 执行。相当于"开锁、出来"。

    • 常用函数:semop (将 sem_op 设为 +1)

  4. 释放与删除 (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 = -1P 操作 (占用资源)。

      • sem_op = +1V 操作 (归还资源)。

    • 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 个(对应s1s2
  • 初始值
    • 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=1s2=0 → a 可写,b 不可读;
  • a 写完后:s1=0s2=1 → a 不可写,b 可读;
  • b 读完后:s1=1s2=0 → 回到初始状态,可重复 "a 写→b 读" 的循环。

通过s1s2的 PV 操作,强制实现了 "写操作完成后才能读,读操作完成后才能写" 的同步逻辑。

极简完整流程(适配截图、一句话串完核心)

✅ 信号量配置:2 个信号量s1=1(允许写)、s2=0(禁止读)。

✅ 全程执行:a 先执行ps1→写数据→vs2;b 再执行ps2→读数据→vs1,循环往复,严格实现「a 写一次、b 读一次」的交替顺序。

✅ 超精简版(最凝练,直接抄)

s1初始 1、s2初始 0;a:ps1→写→vs2;b:ps2→读→vs1,完成写一读交替同步。

相关推荐
AndyHeee2 小时前
【瑞芯微rk3576刷ubuntu根文件系统容量不足问题解决】
linux·数据库·ubuntu
李昊哲小课2 小时前
Ubuntu 24.04 在线安装 Redis 8.x 完整教程
linux·redis·ubuntu
sao.hk2 小时前
ubuntu2404,vbox,全屏显示
linux·运维·服务器
危笑ioi2 小时前
linux配置nfs在ubuntu22.04
linux·运维·服务器
社会零时工2 小时前
【ROS2】海康相机ROS2设备服务节点开发
linux·c++·相机·ros2
东城绝神2 小时前
《Linux运维总结:Ubuntu 22.04配置chrony时间同步服务》
linux·运维·ubuntu·chrony
刘程佳2 小时前
Ubuntu 系统没有识别 Pixel 6 的 USB 设备权限
linux·运维·ubuntu
wa的一声哭了3 小时前
矩阵分析 单元函数矩阵微积分和多元向量值的导数
linux·c语言·c++·线性代数·算法·矩阵·云计算
陈葛杰3 小时前
VMware 安装 Rocky Linux 9.6(Minimal 版)超详细图文教程|轻量 · 安全 · 生产级
linux·运维·服务器