Linux IPC 实战:管道与共享内存的使用场景 + 底层原理全剖析

进程间通信概述

  1. 进程间通信⽬的
  • 数据传输:⼀个进程需要将它的数据发送给另⼀个进程
  • 资源共享:多个进程之间共享同样的资源。
  • 通知事件:⼀个进程需要向另⼀个或⼀组进程发送消息,通知它(它们)发⽣了某种事件(如进程终⽌时要通知⽗进程)。
  • 进程控制:有些进程希望完全控制另⼀个进程的执⾏(如Debug进程),此时控制进程希望能够拦截另⼀个进程的所有陷⼊和异常,并能够及时知道它的状态改变。
  1. 进程间通信发展
    管道
    System V进程间通信
    POSIX进程间通信

  2. 进程间通信分类
    管道

    匿名管道pipe

    命名管道
    System V IPC

    System V 消息队列

    System V 共享内存

    System V 信号量
    POSIX IPC

    消息队列

    共享内存

    信号量

    互斥量

    条件变量

    读写锁

管道

什么是管道

答:管道是Unix中最古⽼的进程间通信的形式。我们把从⼀个进程连接到另⼀个进程的⼀个数据流称为⼀个"管道"

匿名管道

cpp 复制代码
#include <unistd.h>//头文件
//功能:创建⼀⽆名管道

int pipe(int fd[2]);
//参数:fd:⽂件描述符数组,其中fd[0]表⽰读端, fd[1]表⽰写端
//返回值:成功返回0,失败返回错误代码


实例代码

例⼦:从键盘读取数据,写⼊管道,读取管道,写到屏幕。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main( void )
{
    int fds[2];
    char buf[100];
    int len;
    if ( pipe(fds) == -1 )
            perror("make pipe"),exit(1);
// 读取键盘输入到buf中
while ( fgets(buf, 100, stdin) ) {
            len = strlen(buf);
// 从buf读取数据到管道写端
    if ( write(fds[1], buf, len) != len ) {
        perror("write to pipe");
        break;
    }   
    //清空buf
    memset(buf, 0x00, sizeof(buf));
// 从管道读端读取数据到buf中
    if ( (len=read(fds[0], buf, 100)) == -1 ) {
        perror("read from pipe");
        break;
    }
// 从buf中读取到标准输出
    if ( write(1, buf, len) != len ) {
        perror("write to stdout");
        break;
    }
}
        close(fds[0]);
        close(fds[1]);
    return 0;
}
⽤ fork 来共享管道原理

下面这张图,可以更直观地理解fork 共享管道的原理与通信规则:

左图:fork 之后的初始状态

  • 管道的内核属性:图中 "管道" 是内核维护的缓冲区,父进程调用pipe()后,获得了管道的读端(fd [0])和写端(fd [1])(对应父进程的文件描述符表)。
  • fork 的复制效果:父进程执行fork()创建子进程后,子进程会复制父进程的文件描述符表------ 因此子进程也拥有了管道的fd[0](读端)和fd[1](写端)。
  • 共享的本质:父子进程的fd[0]、fd[1]都指向同一块内核管道缓冲区(图中蓝色箭头连接的管道),这是它们能通信的核心(操作同一个内核资源)。

右图:fork 之后的 "规范通信状态"

半双工的要求:

  • 管道是 "半双工" 的(数据只能单向流动),因此需要关闭不需要的文件描述符:
    若父进程作为 "写端"、子进程作为 "读端":
    父进程关闭fd[0](读端),只保留fd[1](写端);
    子进程关闭fd[1](写端),只保留fd[0](读端)。
    图中父进程仅保留fd[1](写),子进程仅保留fd[0](读),对应 "父写子读" 的通信方向。

关闭的作用:

  • 避免误操作(比如父进程同时读 / 写),同时确保管道的 "读 / 写端唯一"------ 当写端全部关闭时,读端read()会返回 0(表示数据结束);若不关闭多余描述符,可能导致read()一直阻塞。
站在⽂件描述符⻆度-深度理解管道
站在内核⻆度-管道本质

1) 管道的内核组件(图中核心结构)

图中包含 3 类关键内核结构,共同构成管道:

  • file 结构 :进程操作文件 / 管道的 "句柄"(对应用户态的文件描述符),每个进程的file结构中记录了管道的操作模式(f_mode)、位置(f_pos)、引用计数(f_count)等信息;
  • inode 结构 :管道的 "索引节点",是内核中标识管道的唯一结构,负责关联管道的数据页(存储实际数据的内存);
  • 数据页 :管道的实际数据缓冲区(内核内存),是进程间共享数据的载体。
    2. 管道的 "共享本质"(内核视角)
    进程 1 和进程 2 的file结构,都指向同一个 inode 结构,而 inode 又关联同一个数据页------ 这是管道能实现进程间通信的核心:
  • 进程 1 通过自己的file结构(写模式),向数据页写入数据;
  • 进程 2 通过自己的file结构(读模式),从同一个数据页读取数据;
  • 内核通过inode数据页,将两个进程的file结构 "关联" 起来,实现数据共享。

3. 管道的操作逻辑(内核层)

  • 写操作(进程 1)
    进程 1 调用write()时,内核通过其file结构找到对应的inode,再定位到数据页,将数据写入数据页;
  • 读操作(进程 2)
    进程 2 调用read()时,内核通过其file结构找到同一个inode和数据页,从数据页中读取数据;
  • 引用计数(f_count)
    file结构中的f_count记录了 "使用该管道的进程数",当所有进程的file结构都被关闭(f_count减为 0),内核会释放管道的inode和数据页,回收资源。

管道的本质是内核中由file结构+inode+数据页组成的共享内存缓冲区:
所以,看待管道,就如同看待⽂件⼀样!管道的使⽤和⽂件⼀致,迎合了"Linux⼀切皆⽂件思想"。

管道特点
  • 只能⽤于具有共同祖先的进程(具有亲缘关系的进程)之间进⾏通信;通常,⼀个管道由⼀个进
    程创建,然后该进程调⽤fork,此后⽗、⼦进程之间就可应⽤该管道。
  • 管道提供流式服务
  • ⼀般⽽⾔,进程退出,管道释放,所以管道的⽣命周期随进程
  • ⼀般⽽⾔,内核会对管道操作进⾏同步与互斥
  • 管道是半双⼯的,数据只能向⼀个⽅向流动;需要双⽅通信时,需要建⽴起两个管道

命名管道

管道应⽤的⼀个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。如果我们想在不相关的进程之间交换数据,可以使⽤FIFO⽂件来做这项⼯作,它经常被称为命名管道。命名管道是⼀种特殊类型的⽂件

创建⼀个命名管道
  • 命名管道可以从命令⾏上创建,命令⾏⽅法是使⽤下⾯这个命令: mkfifo filename

  • 命名管道也可以从程序⾥创建,相关函数有:
    int mkfifo(const char *filename,mode_t mode);

创建命名管道

cpp 复制代码
int main(int argc, char *argv[])
{
mkfifo("p2", 0644);
return 0;
}
匿名管道与命名管道的区别
  • 匿名管道由pipe函数创建并打开。
  • 命名管道由mkfifo函数创建,打开⽤open
  • FIFO(命名管道)与pipe(匿名管道)之间唯⼀的区别在它们创建与打开的⽅式不同,⼀但这些⼯作完成之后,它们具有相同的语义。

system V共享内存

共享内存区 是最快的IPC形式。⼀旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递

不再涉及到内核,换句话说是进程不再通过执⾏进⼊内核的系统调⽤来传递彼此的数据

共享内存⽰意图

结合这张图,我们从进程地址空间的角度来聊聊共享内存,共享内存的核心是让多个进程的地址空间映射到同一块物理内存

  1. 进程地址空间的默认隔离性
    通常,进程 A 和进程 B 的地址空间是**相互隔离**的:
  • 图中进程 A、B 各自的地址空间(左右两个独立区域),默认情况下,它们的 "栈、堆、代码" 等区域对应的物理内存是完全独立的,进程 A 无法直接访问进程 B 的内存(操作系统的内存隔离机制)。
  1. 共享内存的实现(图中核心区域)
    图中间标注 "共享内存、内存映射和共享库位于此处" 的区域,就是共享内存的关键:
  • 操作系统会在 物理内存中开辟一块内存区域
  • 然后将这块物理内存,同时映射到进程 A 和进程 B 的地址空间中(图中进程 A、B 的地址空间通过箭头连接到同一区域);
  • 此时,进程 A 在自己地址空间的 "共享内存区域" 写入数据,进程 B 能在自己地址空间的同一区域直接读取到 ------ 因为它们操作的是 同一块物理内存
  1. 共享内存的地址空间特征

从图中的地址范围可以看到:

  • 共享内存通常位于进程地址空间的 "中间区域"(图中0x40000000附近),避开了进程私有的栈(高地址)、堆(向上扩展)、代码 / 数据(低地址)区域;
  • 这块区域对参与共享的进程来说,是 "逻辑地址不同,但物理地址相同" 的内存 ------ 进程 A 看到的是自己地址空间的某段地址,进程 B 看到的是自己地址空间的另一段地址,但两者最终指向同一块物理内存。

共享内存数据结构

cpp 复制代码
/* 
 * 共享内存段的内核数据结构,用于描述共享内存的属性和状态
 * 定义在 linux/shm.h 头文件中
 */
struct shmid_ds {
    struct ipc_perm  shm_perm;       /* 共享内存的操作权限结构体 */
    int              shm_segsz;      /* 共享内存段的大小(单位:字节) */
    __kernel_time_t  shm_atime;      /* 最后一次附加(attach)共享内存的时间 */
    __kernel_time_t  shm_dtime;      /* 最后一次分离(detach)共享内存的时间 */
    __kernel_time_t  shm_ctime;      /* 最后一次修改该结构体的时间 */
    __kernel_ipc_pid_t  shm_cpid;    /* 创建共享内存段的进程PID */
    __kernel_ipc_pid_t  shm_lpid;    /* 最后操作该共享内存的进程PID */
    unsigned short   shm_nattch;     /* 当前附加到该共享内存的进程数 */
    unsigned short   shm_unused;     /* 兼容字段,未使用 */
    void            *shm_unused2;    /* 兼容字段(DIPC使用),未使用 */
    void            *shm_unused3;    /* 未使用字段 */
};

共享内存函数

shmget函数

功能:⽤来创建共享内存

原型:int shmget(key_t key, size_t size, int shmflg);

参数:

  • key:这个共享内存段名字
  • size:共享内存⼤⼩
  • shmflg:由九个权限标志构成,它们的⽤法和创建⽂件时使⽤的mode模式标志是⼀样的
    取值为IPC_CREAT:共享内存不存在,创建并返回;共享内存已存在,获取并返回。
    取值为IPC_CREAT | IPC_EXCL:共享内存不存在,创建并返回;共享内存已存在

返回值:成功返回⼀个⾮负整数,即该共享内存段的标识码;失败返回-1,出错返回

shmat函数

功能:将共享内存段连接到进程地址空间

原型:void *shmat(int shmid, const void *shmaddr, int shmflg);

参数:

  • shmid: 共享内存标识
  • shmaddr:指定连接的地址
  • shmflg:它的两个可能取值是SHM_RND和SHM_RDONLY

返回值:成功返回⼀个指针,指向共享内存第⼀个节;失败返回-1

shmdt函数

功能:将共享内存段与当前进程脱离

原型int shmdt(const void *shmaddr);

参数

  • shmaddr: 由shmat所返回的指针
    返回值:成功返回0;失败返回-1

注意:将共享内存段与当前进程脱离不等于删除共享内存段.

shmctl函数

功能:⽤于控制共享内存

原型 int shmctl(int shmid, int cmd, struct shmid_ds *buf);

参数:

  • shmid:由shmget返回的共享内存标识码
  • cmd:将要采取的动作(有三个可取值)
  • buf:指向⼀个保存着共享内存的模式状态和访问权限的数据结构

返回值:成功返回0;失败返回-1

共享内存核心 IPC 命令

共享内存的核心优势

相比管道、消息队列等通信方式,共享内存是 最快的进程间通信方式

  • 管道 / 消息队列需要 "数据从进程 A 拷贝到内核缓冲区,再从内核缓冲区拷贝到进程 B"(两次拷贝);
  • 共享内存仅需 "数据写入共享区域",进程 B 直接读取(零拷贝)------ 因为它们直接操作同一块物理内存,无需内核中转。

这篇文章到这里也就结束了 ,下篇我们继续来探讨进程通信相关知识并进行代码实战! 感谢阅读,我们下篇见!

相关推荐
HIT_Weston2 小时前
96、【Ubuntu】【Hugo】搭建私人博客:搜索功能(一)
linux·运维·ubuntu
smile_5me2 小时前
RK3588 csm400b调试记录
c语言·开发语言
一尘之中2 小时前
InfiniBand多播组管理:从理论到实现的深度解析
网络·ai写作
Java后端的Ai之路2 小时前
【Python教程02】-列表和元组
服务器·数据库·python·列表·元组
C_心欲无痕2 小时前
JavaScript 常见算法与手写函数实现
开发语言·javascript·算法
客卿1232 小时前
C语言实现数组串联--力扣冒险
c语言·开发语言·leetcode
悟道|养家2 小时前
微服务扇出:网络往返时间的影响与优化实践(5)
网络·微服务
JiMoKuangXiangQu2 小时前
Linux USB 设备驱动框架简析
linux·usb 设备驱动
咕噜企业分发小米2 小时前
如何用云服务器搭建PUBG服务器?
运维·服务器