进程间通信详解(一):管道机制与实现原理

引言

在操作系统中,不同进程之间往往需要交换数据、同步行为,这就涉及到进程间通信(Inter-Process Communication,IPC)。本篇博客将聚焦于 IPC 中最基础也是最常用的一种机制——管道(Pipe)通信。我们不仅会讲解匿名管道与命名管道的使用方法,还会深入内核实现原理、分析管道的读写规则,并通过多个典型示例理解它在实际开发中的应用场景。无论你是在学习操作系统课程,还是在编写多进程程序,这篇文章都将为你打下扎实的基础。

一、进程间通信介绍

1.1 进程间通信目的

  • 数据传输:一个进程需要将它的数据发送给另一个进程
  • 资源共享:多个进程之间共享同样的资源。
  • 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
  • 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

怎么通信?

进程间通信的本质:是让不同的进程看到同一份资源(如“内存”),从而具备通信的条件。
资源由任何一个进程提供?? 不是!!
是由OS提供 -> 系统调用 -> OS的接口 -> 设计统一的通信接口

1.2 进程间通信发展

  • 管道
  • System V进程间通信
  • POSIX 进程间通信

1.3 进程间通信分类

  • 管道:
    • 匿名管道pipe
    • 命名管道
  • System V IPC
    • System V 消息队列
    • System V 共享队列
    • System V 信号量
  • POSIX IPC
    • 消息队列
    • 共享内存
    • 信号量
    • 互斥量
    • 条件变量
    • 读写锁

二、管道

什么是管道

  • 管道是 Unix 中最古老的进程间通信的形式
  • 我们把从一个进程连接到另一个进程的一个数据流称为一个 “管道”
    在这里插入图片描述

三、匿名管道

3.1 实例代码

在这里插入图片描述

#include <unistd.h>
int pipe(int fd[2]);

功能
创建一匿名管道

参数
fd:文件描述符数组,其中 fd[0] 表示读端,fd[1] 表示写端

返回值
成功返回 0,失败返回错误代码

在这里插入图片描述

  1 #include <stdio.h>
  2 #include <stdlib.h>
  3 #include <string.h>
  4 #include <unistd.h>
  5 
  6 int main()
  7 {
  8     int fds[2];
  9     char buf[128];
 10     int len;
 11 
 12     if(pipe(fds) == -1)
 13     {
 14         perror("pipe");
 15         exit(EXIT_FAILURE);
 16     }
 17 
 18     while(fgets(buf, 100, stdin)) // 从键盘中读取数据
 19     {
 20         len = strlen(buf);
 21         if(write(fds[1], buf, len) != len) // 写入管道
 22         {
 23             perror("write");
 24             exit(EXIT_FAILURE);
 25         }
 26 
 27         memset(buf, 0x00, sizeof(buf)); 
 28         if((len = read(fds[0], buf, 100)) == -1) // 读取管道
 29         {
 30             perror("read");
 31             exit(EXIT_FAILURE);
 32         }
 33 
 34         if(write(1, buf, len) != len) // 输出到屏幕
 35         {
 36             perror("write");
 37             exit(EXIT_FAILURE);
 38         }
 39     }
 40 
 41     return 0;
 42 }                                   

演示:
在这里插入图片描述
可以看到,从键盘中输入什么,在屏幕中会再输出一遍

3.2 用 fork 来共享管道

我之前的文章里讲过,父进程在创建子进程的时候,子进程会继承父进程的文件描述符表,这样,子进程就能获取到父进程的资源。我们可以利用这个原理来实现从子进程向父进程的单向通信。

父进程先创建管道(获得读、写端 fd[0]/fd[1]),再通过 fork 创建子进程。子进程会 复制父进程的文件描述符表(属于 PCB 的一部分),因此继承管道的读、写端。此时,父进程关闭写端 fd[1],子进程关闭读端 fd[0],就能严格实现 父进程写 → 子进程读 的单向通信。这既利用了 fork 的资源继承特性,也依赖管道的 半双工 传输规则。

这一部分代码使用C++来写:

  1 #include <iostream>
  2 #include <unistd.h>
  3 #include <cstring>
  4 #include <sys/types.h>
  5 #include <sys/wait.h>
  6 
  7 void ChildWrite(int wfd)
  8 {
  9     int cnt = 0;
 10     char buffer[128];
 11     while(cnt <= 10000)
 12     {
 13         printf("child : %d\n", cnt);                                                              
 14         int len = snprintf(buffer, sizeof(buffer), "cnt: %d\n", cnt++);
 15         write(wfd, buffer, len);
 16         usleep(100);
 17     }
 18 }
 19 
 20 void FatherRead(int rfd)
 21 {
 22     char buffer[128];
 23     while(true)
 24     {
 25         ssize_t n = read(rfd, buffer, sizeof(buffer)-1);
 26         if(n > 0)
 27         {
 28             buffer[n] = 0;
 29             std::cout << "child say: " << buffer << std::endl;
 30         }
 31         else if(n == 0)
 32         {
 33             std::cout << "n : " << n << std::endl;
 34             std::cout << "child 退出,我也退出" << std::endl;
 35             break;
 36         }
 37         else
 38         {
 39             break;
 40         }
 41     }
 42 }
 43 
 44 
 45 int main()
 46 {
 47     // 1. 创建管道
 48     int fds[2] = {0};
 49     int n = pipe(fds);
 50     if(n < 0)
 51     {
 52         std::cerr << "pipe error" << std::endl;
 53         return 1;
 54     }
 55     std::cout << "fds[0]: " << fds[0] << std::endl;
 56     std::cout << "fds[1]: " << fds[1] << std::endl;
 57 
 58     // 2. 创建子进程
 59     pid_t pid = fork();
 60     if(pid == 0)
 61     {
 62         // child
 63         // 3. 子进程关闭读端
 64         close(fds[0]);
 65         ChildWrite(fds[1]);
 66         close(fds[1]);
 67         exit(0);
 68     }
 69     // 3. 父进程关闭写端
 70     close(fds[1]);
 71     FatherRead(fds[0]);
 72     close(fds[0]);
 73 
 74     sleep(1);
 75 
 76     int status = 0;
 77     int ret = waitpid(pid, &status, 0); // 获取子进程退出信息
 78     if(ret > 0)
 79     {
 80         printf("exit code: %d, exit signal: %d\n", (status>>8)&0xFF, status&0x7F);
 81         sleep(5);
 82     }
 83 
 84     return 0;
 85 }

在这里插入图片描述

用图片来解释文件描述符继承问题:
在这里插入图片描述

3.3 管道通信的内核实现原理

在这里插入图片描述

3.3.1 核心组件与关联关系

  1. 进程的 file 结构体
    • 每个进程打开文件(包括管道)时,内核会为其创建 file 结构体,记录文件的操作属性(如 f_mode 读写权限、f_pos 读写偏移、f_flags 标志位等)。
    • 图中 “进程 1” 和 “进程 2” 各自有独立的 file 结构体,但它们的 f_inode 字段指向同一个 inode(关键!实现共享的核心)。
  2. inode(索引节点)
    inode 是内核中描述文件元数据的结构(如文件类型、权限、数据存储位置)。
    对于管道,inode 不对应磁盘文件,而是对应 内核中的一块共享内存区域(数据页),作为管道的 “缓冲区”。
  3. 数据页(管道缓冲区)
    • 物理内存中的一块区域,用于存储管道传输的数据。
    • 进程 1 的 write 操作向这里写入数据,进程 2 的 read 操作从这里读取数据。

3.3.2 通信流程拆解

  1. 进程 1:写操作(write
    1. 进程 1 调用 write 系统调用,传入自己的文件描述符(关联到自身的 file 结构体)。
    2. 内核通过 file 结构体找到 f_inode,定位到共享的 inode
    3. 内核将数据写入 inode 关联的数据页(管道缓冲区)。
  2. 进程 2:读操作(read
    1. 进程 2 调用 read 系统调用,传入自己的文件描述符(关联到自身的 file 结构体)。
    2. 内核通过 file 结构体找到 f_inode,同样定位到同一个 inode(共享的管道)。
    3. 内核从 inode 关联的数据页对应的物理内存中读取数据,返回给进程 2。

3.3.3 设计本质:OS 如何实现 “共享资源”?

  1. 资源由 OS 提供
    管道的 inode 和数据页由内核创建和管理,而非进程自行分配。进程只能通过系统调用(pipe() 创建管道,write()/read() 操作)访问,确保安全。
  2. “看到同一份资源” 的实现
    两个进程的 file 结构体通过 f_inode 指向同一个内核 inode,间接共享同一块数据页。这种设计让进程无需感知物理内存,只需通过文件接口操作,屏蔽了底层复杂度。
  3. 管道的特性映射
    • 半双工通信:图中虽画了读写,但实际管道是单向的(一个进程写,另一个读),若需双向通信,需创建两个管道。
    • 同步与互斥:内核会自动处理数据的读写同步(如写满时阻塞写进程,读空时阻塞读进程),保证通信有序。

3.3.4 对比理解:为何管道是 “OS 介导的共享”?

  • 如果让 “进程自建资源”(比如进程 1 分配一块内存,告诉进程 2 地址),会有 安全问题(进程 2 的虚拟地址可能无效,或权限不足)。
  • OS 统一管理管道的 inode 和数据页,通过 file 结构体为进程提供 “合法访问接口”,既实现了共享,又保证了系统稳定性(如内存回收、权限检查)。

总结
管道通过内核的 inode 和共享数据页,让两个进程的 file 结构体‘间接共享同一份资源’,从而实现通信。这就好比两个用户(进程)在银行(操作系统)注册了各自的账号(file),但他们都指向同一个保险箱(inode + 数据页),从而实现了受控共享。

3.4 管道读写规则

  • 当没有数据可读时
    • 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 将不再保证写入的原子性。

3.5 管道特点

  • 只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常,一个管道由一个进程创建,然后该进程调用 fork,此后父、子进程之间就可应用该管道。
  • 管道提供流式服务。
  • 一般而言,进程退出,管道释放,所以管道的生命周期随进程。
  • 一般而言,内核会对管道操作进行同步与互斥。
  • 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道。
    在这里插入图片描述

3.6 匿名管道适用场景

匿名管道适用于父子进程或兄弟进程之间进行短生命周期、快速数据交换的通信需求。

例如:

  • Linux Shell 中的 ls | grep xxx
  • 父进程向子进程传递初始化参数

注意:匿名管道无法在两个完全无关的进程之间通信,这时应使用命名管道或其他 IPC 机制。

3.7 管道通信的四种情况

Linux 管道默认 64KB

3.7.1 管道有数据 → 读端读取

  • 阻塞模式(默认,O_NONBLOCK 禁用)
    • 若管道有数据,read 正常读取,返回读取的字节数。
    • 若管道暂时无数据,读进程会阻塞(暂停执行),直到写端写入新数据。
  • 非阻塞模式(O_NONBLOCK 启用)
    • 若管道无数据,read 立即返回 -1,并设置 errno = EAGAIN(表示 “资源暂时不可用”)。

3.7.2 管道满 → 写端写入

管道缓冲区有固定容量(如 Linux 中 PIPE_BUF 通常为 4096 字节),当缓冲区被写满时:

  • 阻塞模式(O_NONBLOCK 禁用)
    • 写进程调用 write 时,会阻塞,直到读端取走数据、腾出缓冲区空间。
  • 非阻塞模式(O_NONBLOCK 启用)
    • write 立即返回 -1,并设置 errno = EAGAIN

3.7.3 写端关闭 → 读端读取

所有写端的文件描述符都被关闭(如父子进程均关闭写端):

  • 若管道中还有剩余数据,读端会正常读取数据。
  • 当管道数据被读完后,后续 read 会返回 0(表示 “文件结束”,类似读到普通文件末尾)。

3.7.4 读端关闭 → 写端写入

所有读端的文件描述符都被关闭(如父子进程均关闭读端):

  • 写端调用 write 时,操作系统会向写进程发送 SIGPIPE 信号(默认行为是终止进程),导致写进程崩溃。
  • 若需避免崩溃,需捕获 SIGPIPE 信号(如通过 signal 注册信号处理函数)。

四、命名管道

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

4.1 创建命名管道

  • 命名管道可以从命令行上创建,命令行方法是使用下面这个命令:
mkfifo filename
  • 命名管道也可以从程序里创建,相关函数有:
#include <sys/types.h>  
#include <sys/stat.h>  

int mkfifo(const char *pathname, mode_t mode);  
  • 参数
    • pathname:管道文件路径(如 "/tmp/my_fifo")。
    • mode:权限位(如 0666),实际权限为 mode & ~umask(受进程 umask 影响)。
  • 返回值
    • 成功:0;失败:-1,错误码存于 errno

错误码解析

错误码含义
EEXIST管道文件已存在
EACCESS路径所在目录无写权限
ENOSPC文件系统空间不足
ENOTDIR路径中某组件不是目录

创建命名管道:

int main(int argc, char *argv[])
{
	mkfifo("p2", 0644);
	return 0;
}

4.2 结合 open 使用:控制阻塞行为

命名管道的读写行为受 openO_NONBLOCK 标志 影响:

  • 默认(无 O_NONBLOCK
    • 打开读端(O_RDONLY):阻塞,直到有进程打开写端。
    • 打开写端(O_WRONLY):阻塞,直到有进程打开读端。
  • 开启 O_NONBLOCK
    • 打开读端:若无写端,立即返回 -1errno = ENXIO
    • 打开写端:若无读端,立即返回 -1errno = ENXIO

4.3 示例1:父子进程通过命名管道通信

  1 #include <stdio.h>
  2 #include <unistd.h>
  3 #include <sys/types.h>
  4 #include <sys/stat.h>
  5 #include <fcntl.h>
  6 #include <string.h>
  7 #include <stdlib.h>
  8 #include <sys/wait.h>
  9 
 10 int main()
 11 {
 12     const char *fifo_path = "/home/zkp/linux/25/6/9/fifo/my_fifo";
 		umask(0); // 设置权限掩码为 0,,以免影响后面的操作
 13     // 1. 创建命名管道
 14     if(mkfifo(fifo_path, 0666) == -1)
 15     {
 16         perror("mkfifo");
 17         return 1;
 18     }
 19 
 20     // 2. 创建子进程
 21     pid_t pid = fork();
 22     if(pid < 0)
 23     {
 24         perror("fork");
 25         return 1;
 26     }
 27     else if(pid == 0)
 28     {
 29         // 子进程:读端
 30         int fd = open(fifo_path, O_RDONLY);
 31         char buf[100];
 32         ssize_t n = read(fd, buf, sizeof(buf));          
 33         buf[n] = '\0';
 34         printf("子进程读到:%s\n", buf);
 35         close(fd);
 36         exit(0);
 37     }
 38     else
 39     {
 40         // 父进程:写端
 41         int fd = open(fifo_path, O_WRONLY);
 42         const char *msg = "Hello, named pipe!";
 43         write(fd, msg, strlen(msg));
 44         close(fd);
 45         wait(NULL); // 等待子进程退出
 46     }
 47 
 48     // 3. 删除管道文件(也可以不删除,长期保留)
 49     unlink(fifo_path);
 50 
 51     return 0;
 52 }          

运行结果:
在这里插入图片描述

4.4 示例2:利用命名管道简单模拟用户端与服务端的通信

我这里就用C语言简单模拟一下,真要写的话可以将 FileOper(用于支持读写操作)、NameFifo(用于创建管道文件)写到头文件中,再直接在 server.cclient.c 中直接调用接口。

4.4.1 server.c

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

int main() 
{
    int fd = open("myfifo", O_RDONLY);
    if (fd == -1)
    {
        perror("open");
        exit(1);
    }

    char buf[128];
    while (read(fd, buf, sizeof(buf)) > 0) 
    {
        printf("Received: %s", buf);
    }

    close(fd);
    return 0;
}

4.4.2 client.c

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main() {
    int fd = open("myfifo", O_WRONLY);
    if (fd == -1) {
        perror("open");
        exit(1);
    }

    char buf[128];
    while (fgets(buf, sizeof(buf), stdin)) {
        write(fd, buf, strlen(buf));
    }

    close(fd);
    return 0;
}

命令行演示:

mkfifo myfifo
./fifo_read   # 在一个终端运行
./fifo_write  # 在另一个终端运行
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值