进程间通信

• 进程间通信介绍

• 掌握匿名命名管道原理操作

• 编写进程池

• 掌握共享内存

• 了解消息队列

• 了解信号量

• 理解内核管理IPC资源的⽅式,了解C实现多态

进程间通信介绍

IPC = Inter Process Communication。 IPC 解决的是"进程独立性"和"进程协作需求"的矛盾

进程间通信的目的,是让相互独立的进程能够交换数据、协调工作。即

• 数据传输:⼀个进程需要将它的数据发送给另⼀个进程

• 资源共享:多个进程之间共享同样的资源。

• 通知事件:⼀个进程需要向另⼀个或⼀组进程发送消息,通知它(它们)发⽣了某种事件(如进 程终⽌时要通知⽗进程)。

• 进程控制:有些进程希望完全控制另⼀个进程的执⾏(如Debug进程),此时控制进程希望能够 拦截另⼀个进程的所有陷⼊和异常,并能够及时知道它的状态改变。

进程间通信分类。IPC 按类型列出来:

  • 管道

  • 匿名管道

  • 命名管道

  • System V 消息队列

  • System V 共享内存

  • System V 信号量

管道

管道就是一个内核提供的"带缓冲区的数据流通道"。

  • 管道本质是一个内核缓冲区

  • 一端写入,一端读出

  • 它是流式的,不是消息分包天然保留边界的

  • 管道 = 内核中的一段缓冲区 + 两端文件描述符

  • 管道提供的是字节流服务

匿名管道

匿名管道不是"文件路径"级别的对象,而是进程通过文件描述符直接持有的一条通信通道。

cpp 复制代码
 #include <unistd.h>
功能:创建⼀⽆名管道原型
int pipe(int fd[2]);
参数
fd:⽂件描述符数组,其中fd[0]表⽰读端, fd[1]表⽰写端
返回值:成功返回0,失败返回错误代码

⽤fork来共享管道原理

父进程先创建管道,再 fork(),这样子进程会继承父进程打开的文件描述符,于是父子就都拥有同一个管道的读端/写端。

fork 后必须关闭不用的一端

比如你要实现"父写子读":

  • 父进程关闭读端 fd[0]
  • 子进程关闭写端 fd[1]

这是一个超级重要的习惯。为什么一定要关?因为如果不关,会带来两个大问题:

  1. 逻辑不清晰
    明明父进程只负责写,却还留着读端;子进程只负责读,却还留着写端。
  2. EOF 判断会失效
    管道读端什么时候读到 0
    前提是:所有写端都关闭了
    如果某个进程偷偷还保留一个写端没关,读者就会一直以为"可能还有数据要来",从而阻塞。这个问题是面试和实验里最容易踩坑的点之一。

站在⽂件描述符⻆度-深度理解管道

每个进程都有自己的文件描述符表。pipe(fd) 之后:

  • fd[0] 指向管道读端
  • fd[1] 指向管道写端

fork() 之后:

  • 子进程复制父进程的文件描述符表
  • 所以父子各自表里都有相同编号或对应的 fd
  • 这些 fd 最终都指向同一个内核中的管道对象

说明"父子共享管道"并不是共享整数 3、4 这种 fd 编号,而是它们的 fd 表项指向同一个内核资源。这个理解很重要。因为:

  • fd 只是用户态看到的"索引"
  • 真正的资源在内核里

站在内核角度:管道本质

所以,看待管道,就如同看待⽂件⼀样!管道的使⽤和⽂件⼀致,迎合了"Linux⼀切皆⽂件思 想"。

管道样例

给了一个标准父子通信代码:

  • 父进程创建管道
  • fork
  • 子进程关闭读端,往写端写 "hello"
  • 父进程关闭写端,从读端读出数据并打印
点1:子进程为什么先 close(pipefd[0])

因为子进程只写不读。

点2:子进程写完为什么还要 close(pipefd[1])

因为要明确告诉读者:写完了,没有更多数据了

点3:父进程为什么先 close(pipefd[1])

因为父进程只读不写。

点4:为什么最后能打印出 hello

因为父子通过匿名管道完成了一次单向数据传递。这就是匿名管道最经典的模型

cpp 复制代码
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>

#define ERR_EXIT(m) \
    do \
    { \
        perror(m); \
        exit(EXIT_FAILURE); \
    } while(0)

int main(int argc, char *argv[])
{
    int pipefd[2];
    if (pipe(pipefd) == -1)
        ERR_EXIT("pipe error");
    
    pid_t pid;
    pid = fork();
    if (pid == -1)
        ERR_EXIT("fork error");
    
    if (pid == 0) {
        // 子进程
        close(pipefd[0]);              // 关闭读端
        write(pipefd[1], "hello", 5); // 写入数据
        close(pipefd[1]);              // 关闭写端
        exit(EXIT_SUCCESS);
    }
    
    // 父进程
    close(pipefd[1]);                  // 关闭写端
    char buf[10] = {0};
    read(pipefd[0], buf, 10);         // 读取数据
    printf("buf=%s\n", buf);
    
    return 0;
}

创建进程池处理任务

管道读写规则

补充:

  • O_NONBLOCK disable​ = 阻塞模式 = 等待直到条件满足

  • O_NONBLOCK enable​ = 非阻塞模式 = 立即返回不等待

• 当没有数据可读时

◦ O_NONBLOCK disable:read调⽤阻塞,即进程暂停执⾏,⼀直等到有数据来到为⽌。

◦ O_NONBLOCK enable:read调⽤返回-1,errno值为EAGAIN。

• 当管道满的时候

◦ O_NONBLOCK disable:write调⽤阻塞,直到有进程读⾛数据

◦ O_NONBLOCK enable:调⽤返回-1,errno值为EAGAIN

• 如果所有管道写端对应的⽂件描述符被关闭,则read返回0

• 如果所有管道读端对应的⽂件描述符被关闭,则write操作会产⽣信号SIGPIPE,进⽽可能导致 write进程退出

• 当要写⼊的数据量不⼤于PIPE_BUF时,linux将保证写⼊的原⼦性。

• 当要写⼊的数据量⼤于PIPE_BUF时,linux将不再保证写⼊的原⼦性。

管道特点

• 只能⽤于具有共同祖先的进程(具有亲缘关系的进程)之间进⾏通信;通常,⼀个管道由⼀个进 程创建,然后该进程调⽤fork,此后⽗、⼦进程之间就可应⽤该管道。

• 管道提供流式服务 • ⼀般⽽⾔,进程退出,管道释放,所以管道的⽣命周期随进程

• ⼀般⽽⾔,内核会对管道操作进⾏同步与互斥

• 管道是半双⼯的,数据只能向⼀个⽅向流动;需要双⽅通信时,需要建⽴起两个管道

验证管道通信的4种情况

• 读正常&&写满

• 写正常&&读空

• 写关闭&&读正常

• 读关闭&&写正常

管道问题本质就两个:

  1. 缓冲区空/满时怎么处理
  2. 对端关闭时怎么处理

命名管道

匿名管道的限制:只能用于具有共同祖先的进程之间通信。若无关进程之间想交换数据,就用 FIFO,也就是命名管道。命名管道是一种特殊文件。

匿名管道必须靠 fork 继承。那如果是两个本来就独立启动的程序:

  • 一个 server
  • 一个 client

它们没有父子关系怎么办?这时就需要一个大家都知道路径名的通信媒介,于是命名管道出现了。

创建命名管道

课件给了两种方式。

方式1:命令行

cpp 复制代码
mkfifo filename

方式2:程序里

cpp 复制代码
int mkfifo(const char *filename, mode_t mode);

关键理解

命名管道虽然"表现得像文件",但它不是普通磁盘文件。它是文件系统里一个特殊类型文件,用于进程通信。

你可以用路径打开它:

cpp 复制代码
open("mypipe", ...)

这就是它和匿名管道最大的不同。

匿名管道与命名管道的区别

  • 匿名管道:pipe 创建并打开
  • 命名管道:mkfifo 创建,open 打开
  • 除了创建和打开方式不同,之后语义基本相同

命名管道的打开规则

• 如果当前打开操作是为读⽽打开FIFO时

◦ O_NONBLOCK disable:阻塞直到有相应进程为写⽽打开该FIFO

◦ O_NONBLOCK enable:⽴刻返回成功

• 如果当前打开操作是为写⽽打开FIFO时

◦ O_NONBLOCK disable:阻塞直到有相应进程为读⽽打开该FIFO

◦ O_NONBLOCK enable:⽴刻返回失败,错误码为ENXIO

实例1:用命名管道实现文件拷贝

证明命名管道可用于无关进程通信:因为两个程序不需要父子关系,只需要都知道同一个 FIFO 路径 tp

两个程序:

  • 程序 A:读源文件 abc,写入命名管道 tp
  • 程序 B:从 tp 读取,写入目标文件 abc.bak

实例2:命名管道实现 server/client 通信

一个很经典的 demo:serverPipe.cclientPipe.c

server 在做什么

  1. mkfifo("mypipe", 0644) 创建管道
  2. open("mypipe", O_RDONLY) 以只读方式打开
  3. 循环 read
  4. 打印客户端发来的内容
  5. 如果读到 0,说明 client 退出,server 也退出

client 在做什么

  1. open("mypipe", O_WRONLY) 打开命名管道写端
  2. 从键盘读取输入
  3. write(wfd, buf, strlen(buf)) 发给 server

这个模型很像一个极简版聊天室:

  • server 等待消息
  • client 发送消息

System V 共享内存

共享内存区是最快的IPC形式。⼀旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递 不再涉及到内核,换句话说是进程不再通过执⾏进⼊内核的系统调⽤来传递彼此的数据

同一块共享内存被映射进进程 A 和进程 B 的虚拟地址空间。A 写进去,B 立刻就能看到

这就是共享内存的本质。

为什么共享内存最快

先理解普通 IPC(比如管道)的数据流动:

  • 发送进程把数据写进内核缓冲区
  • 接收进程再从内核缓冲区拷走。
  • 这意味着至少有数据复制。

而共享内存是:

  • 内核创建一块共享区
  • 多个进程把这块区域映射到各自地址空间
  • 大家直接读写这同一块物理内存

所以优势是:

  • 少一次甚至多次拷贝
  • 少系统调用开销
  • 大数据传输特别合适

但代价是速度快的同时,管理更难:

  • 不自带同步互斥
  • 数据一致性要自己保证
  • 生命周期管理更复杂

共享内存数据结构

共享内存四大函数

四个函数:shmgetshmatshmdtshmctl

一)shmget ------ 创建/获取共享内存

cpp 复制代码
int shmget(key_t key, size_t size, int shmflg);
参数理解
  • key:共享内存的名字/标识
  • size:大小
  • shmflg:权限和创建标志
常见标志
  • IPC_CREAT:不存在就创建,存在就获取
  • IPC_CREAT | IPC_EXCL:不存在就创建,存在就报错

这一组标志特别重要。因为它决定你到底是:

  • "我要创建一个全新的共享内存"
  • 还是"我只是想连上已有共享内存"

通常:

  • server/发起者 用 IPC_CREAT | IPC_EXCL
  • client/后来者 用 IPC_CREAT 或直接 0

二)shmat ------ 挂接共享内存

cpp 复制代码
void *shmat(int shmid, const void *shmaddr, int shmflg);

这一步是把共享内存映射进当前进程地址空间。

你要记住最常见用法
cpp 复制代码
char *addr = (char*)shmat(shmid, NULL, 0);

意思是:

  • 地址让内核自动选
  • 默认可读可写挂接

为什么叫"挂接"?

因为共享内存段本来是内核里的 IPC 资源。只有挂接到进程虚拟地址空间之后,你才能像操作普通内存一样操作它。

三)shmdt ------ 脱离共享内存

cpp 复制代码
int shmdt(const void *shmaddr);

这是把共享内存从当前进程地址空间中解绑。

注意一个高频误区 shmdt 不等于删除共享内存

它只是:当前进程不再使用它。但共享内存对象本身可能还在内核里。

四)shmctl ------ 控制/删除共享内存

cpp 复制代码
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

最常见操作是:

  • IPC_STAT
  • IPC_SET
  • IPC_RMID

其中你最该记住的是:IPC_RMID:删除共享内存段。这是真正的"资源销毁"。

实例1:共享内存实现通信

共享内存的资源残留问题

如果程序被 ctrl+c 异常终止,再重启 server,可能报:

复制代码
shmget: File exists

并建议通过 ipcs -m 查看、ipcrm -m 删除。这说明 System V IPC 资源生命周期随内核,不随进程自动清理。这点和匿名管道差别很大。

管道:进程结束后,fd 关掉,资源往往自动回收。

System V 共享内存:如果你没显式 IPC_RMID 删除,它可能一直留在内核里,直到:

  • 手动删

  • 或重启系统

这是面试必考点

System V IPC 资源必须显式删除,否则容易残留。

共享内存最大的问题:不同步、不互斥

共享内存没有进行同步与互斥!共享内存缺乏访问控制!会带来并发问题。

**为什么共享内存快?**因为它几乎不管你。

**为什么共享内存危险?**也是因为它几乎不管你。

假设两个进程同时写同一块共享内存:

  • 可能覆盖彼此数据
  • 可能读到中间状态
  • 可能产生竞态条件

所以共享内存通常必须配合:

  • 信号量
  • 管道通知
  • 互斥锁
  • 条件变量

一起使用。

实例2:借助管道实现访问控制版共享内存

信号量

共享资源:多个执行流能看到的同一份公共资源,叫共享资源。

这里"执行流"你先理解成:

  • 两个进程
  • 或两个线程

"共享资源"可以是:

  • 一个全局变量
  • 一块共享内存
  • 一个任务队列
  • 一个数据库连接池里的连接数量
  • 一份日志文件
  • 一个库存计数器

临界资源

  • 被保护起来的资源叫临界资源
  • 一次只允许一个执行流使用的资源,也叫临界资源或互斥资源。

什么是临界区

  • 在进程中涉及互斥资源的程序段叫临界区
  • 你写的代码 = 访问临界资源的代码(临界区) + 不访问临界资源的代码(非临界区)
  • 对共享资源进行保护,本质是对访问共享资源的代码进行保护。

互斥和同步到底区别在哪

  • 任何时刻,只允许一个执行流访问资源,叫互斥.互斥:强调"不能同时"

例子:两个线程同时修改库存。要求是:

  • 同一时刻只能一个线程进来改库存
  • 另一个必须等

这叫互斥

  • 多个执行流访问临界资源时,具有一定顺序性,叫同步。同步:强调"谁先谁后"

例子:生产者/消费者。要求是:

  • 先生产,再消费
  • 没生产之前,消费者不能先读

这叫同步

信号量到底是什么

  • 信号量是一个计数器

信号量不是"数据"。信号量是"资源剩余数量的记录器"。这就是"计数器"的意思。

  • 作用是保护临界区
  • 本质是对资源的预订机制
  • 申请资源,计数器--,P 操作
  • 释放资源,计数器++,V 操作。

P = 申请 + 必要时等待
V = 归还 + 必要时唤醒

信号量怎么做到"互斥"

最常见的是把信号量初值设成 1。这叫二元信号量,你可以把它理解成"只有一个名额"。

逻辑

  • 初始值 = 1,表示"临界区现在空着"
  • 线程 A 进来,P 操作后变成 0,A 进入临界区
  • 线程 B 再来,发现是 0,没有名额,只能等
  • A 出来,V 操作,变回 1
  • B 才能进去

这样就保证:

同一时刻只有一个执行流在临界区里

这就是互斥。

信号量怎么做到"同步"

如果信号量初值不是 1,而是 0,也能有意思。比如:

  • 线程 A 负责"生产数据"
  • 线程 B 负责"消费数据"

一开始没有数据,所以信号量 = 0。

  • B 来消费,先做 P,发现 0,只能等
  • A 生产完,做 V,变成 1
  • B 被唤醒,开始消费

这就是同步:不是防止同时进入,而是保证先后顺序

为什么共享内存快,但还要配同步

共享内存最快,因为映射后,进程间数据传递不再总靠进入内核;但:共享内存没有同步与互斥,缺乏访问控制,会带来并发问题。

共享内存只解决"通信通道快" 解决的是:数据怎么高效地被两边看到 也就是"传得快"。信号量/锁/条件变量才解决"并发访问安全"

内核是如何组织管理 IPC 资源的?

内核会把 IPC 对象的公共信息抽出来统一管理,再把每种 IPC 自己特有的信息单独扩展。

内核不会把 IPC 资源乱放,必须有统一管理表。

不同 IPC 资源有共性,所以抽出公共头 kern_ipc_perm

不同 IPC 资源也有个性,所以各自结构体再扩展私有字段。

这是一种典型的 C 风格"公共头 + 扩展体"的设计,看起来像"用 C 做多态"。

共享资源是多个执行流都能看到的资源;临界资源是不能被多人同时安全访问的资源;临界区是访问临界资源的那段代码。互斥强调同一时刻只能一个执行流进入,同步强调执行顺序。信号量本质是资源计数器,P 操作申请资源,V 操作释放资源。共享内存虽然快,但不自带访问控制,所以通常要搭配信号量、锁或其他同步机制一起用。

内核管理 IPC 资源时,会把公共属性抽成统一的结构,比如 key、权限、所有者、删除状态这些;再让不同资源类型在此基础上扩展自己的私有字段。这样可以统一做权限检查、查找和管理,又能保留各类型资源的差异化数据。这是一种典型的 C 风格"公共头 + 扩展体"的组织方式,类似多态。

相关推荐
add45a2 小时前
C++编译期数据结构
开发语言·c++·算法
灰色小旋风2 小时前
力扣21 合并两个有序链表(C++)
c++·leetcode·链表
创世宇图2 小时前
阿里云Alibaba Cloud Linux 4 LTS 64位生产环境配置-Nginx
linux·nginx
Laurence2 小时前
Qt 前后端通信(QWebChannel Js / C++ 互操作):原理、示例、步骤解说
前端·javascript·c++·后端·交互·qwebchannel·互操作
岁岁种桃花儿2 小时前
AI超级智能开发系列从入门到上天第四篇:AI应用方案设计
java·服务器·开发语言
王老师青少年编程2 小时前
2026年3月GESP真题及题解(C++五级):有限不循环小数
c++·题解·真题·gesp·csp·五级·有限不循环小数
Amnesia0_02 小时前
C++中的IO流
开发语言·c++
2401_891482172 小时前
C++模块化编程指南
开发语言·c++·算法
暮冬-  Gentle°2 小时前
自定义类型转换机制
开发语言·c++·算法