前言:

如何理解进程之间的通信?

可以理解为:进程的"独门独户"

想象一下,每个进程都像一座独立的房子,有自己的门、窗户和内部空间。一个房子里的人不能直接看到另一个房子里的人,更不能随意拿取对方的东西。这种独立性是操作系统刻意设计的——目的是防止一个程序出问题导致整个系统崩溃。

通信的本质:寻找"公共空间"

既然进程之间不能直接通信,那它们怎么交流呢?

答案很简单:找一个双方都能看到的地方。

进程间通信的本质就是:让不同的进程看到同一份资源这个"资源"通常是一块由操作系统提供的内存空间。一个进程往里面写数据,另一个进程从里面读数据,这样信息就传递成功了

那说明操作系统扮演的角色很重要!

操作系统的"中介"角色

操作系统就像一个物业公司,它知道每个进程的"住址",也拥有管理公共空间的权限。为了让进程们能交流,操作系统提供了多种"公共空间"方案:

  • 管道:像一根水管,一端进水一端出水
  • 消息队列:像信箱,可以存放和取走信件
  • 共享内存:像合租房,多个进程可以共同使用一片区域
  • 信号量:像交通信号灯,控制访问权限

那本节内容主要讲的是:

管道是最经典、最基础的进程间通信方式。本文将从匿名管道和命名管道入手,一步步揭开进程间通信的神秘面纱

一.进程间通信介绍

1-1 进程间通信⽬的

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

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

  • 通知事件:⼀个进程需要向另⼀个或⼀组进程发送消息,通知它(它们)发⽣了某种事件(如进程终⽌时要通知⽗进程)。
  •  进程控制:有些进程希望完全控制另⼀个进程的执⾏(如Debug进程),此时控制进程希望能够拦截另⼀个进程的所有陷⼊和异常,并能够及时知道它的状态改变。

进行进程间通信的原因是进程的独立性是操作系统为了安全与稳定而设计的基本特性,但这也导致了进程之间默认无法直接交互。然而,在现实的计算场景中,多个进程往往需要协同工作,这就产生了进程间通信的需求。


1-2 进程间通信发展

进程间通信的发展过程中,逐渐形成了两种主流标准:

System V:旧标准,只在单机内部通信,相当于局域网内部聊天。功能齐全但接口老旧,使用起来不太方便。

POSIX:新标准,支持跨网络通信,相当于"微信",不仅能本地聊还能远程聊。接口更加统一,可移植性更好。

补充:管道既不属于 System V,也不属于 POSIX。管道是 Unix 系统自带的原住民,是内核直接提供的最朴素的通信方式,比这两个标准都要古老。本篇先学习管道和 System V,POSIX 放到后面网络编程再讲。

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

1-3 进程间通信分类

管道

匿名管道pipe
命名管道


System V IPC:只在单机内部通信

System V 消息队列
System V 共享内存
System V 信号量


POSIX IPC支持跨网络通信

消息队列
共享内存
信号量
互斥量
条件变量
读写锁


1-4进程间通信的技术背景

进程具有天然的独立性,这种独立性是通过虚拟地址空间配合页表映射来保障的,包括进程的内核数据结构以及进程自身的代码和数据都与其它进程相互隔离。正是因为这种独立性,使得进程间通信的成本相对较高——要让原本相互隔离的不同进程看到同一份资源,本身就是一件不容易的事情。


1-5进程间通信的本质理解

进程间通信需要解决一个核心问题:首先要让不同的进程看到同一块内存空间,并且这块空间需要由特定的数据结构来组织管理。那么这块被多个进程共享的"内存"究竟隶属于哪一个进程呢?答案是否定的——它不能隶属于任何一个进程,而应该强调其共享属性,即由操作系统提供并独立于任何进程存在。


1-6进程间通信的必要性

单进程无法发挥多核CPU的并发能力,也无法实现多进程之间的协同工作。进程间通信的目的多种多样,包括数据传输、执行流同步、事件通知等,归根结底都是为了实现多进程协同。需要明确的是,进程间通信本身不是目的,而是达成系统整体目标的一种手段


二. 管道

2-1什么是管道

  我们把从⼀个进程连接到另⼀个进程的⼀个数据流称为⼀个“管道”

现实生活中随处可见管道的存在,它们都有一个共同特征:拥有一个入口和一个出口,最典型的特点是只能单向传输,在其中流淌着人们所需的自来水、石油等资源。

计算机中的管道与之类似,只不过传送的是数据资源既然是数据传送,必然有人想写入数据,有人想读取数据,这里的"有人"就分别对应发送进程和接收进程

现实管道的材料是钢铁,而计算机中管道的缓冲区所使用的材料是系统内存,这片内存正是让不同进程能够看到的同一块系统资源。管道的本质就是内核在内存中开辟的一块缓冲区,多个进程通过访问这片缓冲区来实现数据传递。

管道是 Unix 系统中最古老的进程间通信方式。简单来说,管道就是从一个进程连接到另一个进程的数据流,数据从一端流入,从另一端流出。

需要特别强调的是,管道不能由进程 A 或进程 B 提供,必须由操作系统提供。因为任何中间通信资源都不能隶属于某个进程——进程具有独立性,一旦资源属于某个进程,其它进程必然无法访问到它。操作系统作为可信的第三方,提供这块共享缓冲区,两个进程恰好通过某种方式利用这片区域进行通信。

管道一共有两种通信方案:匿名管道和命名管道。它们的底层原理基本一致,区别在于适用场景的不同——匿名管道适用于有亲缘关系的进程,命名管道则突破了这一限制


2-2匿名管道

匿名管道专为具有亲缘关系的进程之间提供通信服务,最常见的场景就是父子进程之间的数据交换。这里需要澄清一个容易误解的概念:即便父子进程之间存在着天然的继承关系,它们各自的数据仍然是私有的,并非共享。之所以看起来某些数据是共享的,只是因为双方都没有去写入修改而已——一旦发生写入操作,写时拷贝机制就会立刻将数据分离,确保各自的独立性。

所有进程间通信方式都必须遵循一个根本前提让不同的进程看到同一份资源。匿名管道之所以称为"匿名",是因为它在文件系统中没有对应的路径名,不需要通过文件名来标识自身

它的工作方式是通过子进程继承父进程的文件描述符表来实现的——父进程先创建管道,得到两个文件描述符,分别对应管道的读端和写端,然后 fork 出子进程,子进程自然就继承了这两个文件描述符,从而父子进程能够访问到内核中的同一块管道缓冲区。

从底层来看,匿名管道的本质是内核在内存中创建的一个环形缓冲区,通过文件描述符进行访问,依赖于进程的亲缘关系来传递访问权限。


2-3匿名管道的底层原理与进程通信机制

1.管道链的构建过程:从命令到进程

当我们执行一条命令的时候 ,这背后究竟发生了什么?

sleep 10000 | sleep 20000 | sleep 30000 &

当我们执行这条命令时,shell 是如何处理的呢?

首先需要明确一点:sleep 在系统中也是一条普通命令。我们让这三个 sleep 进程不要立马退出,并通过 & 将它们放到后台运行。执行 ps 后可以看到这三个进程是兄弟进程,而它们的父进程一定是当前的 bash。

那么 | 这个符号在底层到底做了什么?它在 shell 中被解析为管道操作符。

对于 sleep 10000 | sleep 20000 | sleep 30000 这条命令,实际上存在两个管道:管道1连接第一个和第二个 sleep,管道2连接第二个和第三个 sleep。

shell 的处理流程是这样的:系统通过 for 循环依次创建三个子进程。每次 fork 时,如果是父进程就继续创建下一个子进程,最终形成三个并行的兄弟进程。在创建这些子进程之前,shell 已经创建好了两个匿名管道文件。当 fork 发生时,子进程会继承父进程的所有文件描述符,因此这三个兄弟进程都能够访问到这两个管道文件。

关键的一步是:每个进程根据自身在管道链中的位置,关闭不需要的读写端,从而形成一条单一方向的数据流。比如第一个 sleep 只负责写,关闭读端;中间的 sleep 既要读又要写;最后一个 sleep 只负责读,关闭写端。这样就形成了一条完整的数据流通路。

2.资源可见性的实现

那么,这些兄弟进程是如何看到同一份资源呢?答案是文件描述符的继承机制。

父进程 bash 先通过 pipe 系统调用创建管道,获得对应的文件描述符当 fork 子进程时,子进程完全复制了父进程的文件描述符表,这意味着子进程拥有了和父进程相同的文件描述符编号,它们都指向内核中同一个管道缓冲区。

从底层来看,文件描述符本质上是进程描述符中文件描述符数组的下标,指向内核中的 struct file 对象。fork 之后,父子进程的文件描述符指向的是内核中同一个 struct file,而这个 struct file 指向的正是内核缓冲区。因此,多个进程通过各自进程中相同或不同的文件描述符,最终访问到了同一块内核缓冲区。

3.匿名管道的本质

通过上面的分析我们可以得出结论:曾经我们在命令行中使用的 |,本质上就是匿名管道。它不需要在文件系统中暴露任何名字,通过父子进程间的文件描述符继承,让具有亲缘关系的进程能够访问同一份内核资源。

换而言之,匿名管道是 shell 解析 | 操作符后在底层实现的通信机制。不同的命令通过管道连接在一起,前一个命令的输出成为后一个命令的输入,形成一条流畅的数据处理链。这就是管道最核心的价值所在——它让多个独立的进程能够协同工作,分工合作完成复杂的任务。

4.管道的生命周期

进程退出时,其打开的所有文件都会被自动关闭,因为进程的内核数据结构中保存着打开文件的相关信息,进程终止后这些资源会被内核回收。管道作为文件的一种,同样遵循这个规律

我们可以这样理解管道的生命周期:管道本身由内核维护,当最后一个引用它的进程关闭管道文件描述符后,内核才会释放管道缓冲区所以管道的生命周期实际上取决于使用它的进程的生命周期。

匿名管道机制设计得十分精妙:

  • 通过文件描述符的继承,让有亲缘关系的进程能够访问同一份内核资源

  • 不需要在文件系统中暴露任何名字,这正体现了"匿名"的含义

  • 通过父进程创建管道、子进程继承的方式,自然实现了资源的共享

这种设计依赖于进程间的血缘关系来传递访问权限,让操作系统巧妙地解决了让不同进程看到同一份资源的问题,同时利用进程的自然生命周期来管理管道的生命周期,避免了额外的资源管理负担。


那问题来了?父子进程如何共享同一份资源?

回顾文件描述符的本质:文件描述符本质上是进程文件描述符数组的索引下标,通过它可以找到内核中对应的 struct file 对象,进而访问到真正的文件数据。

5.管道原理

结合文件描述符来看

1.文件描述符在内核中的组织结构

从图片中可以看出,进程的 task_struct 内部有一个 struct files_struct 结构体,其中包含一个文件描述符数组 fd_array[]。数组的下标就是文件描述符,每个元素指向内核中的 struct file 对象。每个 struct file 对象包含了文件的属性以及对应的操作方法(函数指针),不同类型的文件(普通文件、管道、设备文件等)有各自的操作函数集。

当我们说"父进程打开了某个文件",实际上是在说:父进程的文件描述符数组中某个下标指向了内核中对应的 struct file 对象。

2.管道是怎么创建出来的

父进程调用 pipe 接口,内核会专门创建一个管道文件对象,并在内存中开辟一段缓冲区作为管道的数据空间。这个 pipe 接口会返回两个文件描述符,比如 3 号给读端、4 号给写端。这两个描述符在父进程的 fd_array[] 中分别指向同一个 struct file 对象,只不过一个标记为读、一个标记为写。

3.fork 之后发生了什么

当父进程 fork 创建子进程时,子进程会复制父进程的 task_struct,包括 files_struct 和 fd_array[] 的内容。图片中展示了这一结构关系:子进程的 fd_array[] 中也会有 3 号和 4 号描述符,它们指向内核中同一个 struct file 对象。这也解释了为什么父子进程都能向显示器打印——因为它们的 1 号描述符都指向了同一个终端设备文件。

虽然父子进程的地址空间、页表是各自独立的,但文件描述符表的内容一致,这意味着它们能够指向内核中同一份文件资源。管道的本质就是通过这种方式,让不同的进程看到同一份内核缓冲区。

4.管道与普通文件的区别

从 struct file 的角度来看,管道文件和普通文件都有自己的属性和操作函数,但普通文件的操作函数会涉及磁盘驱动,数据最终要刷新到磁盘上;而管道文件的操作函数只读写内存缓冲区,不需要经过磁盘驱动,这也是管道通信效率更高的原因。


6.问题补充:

管道单向通信(为什么管道在设计时只支持单向通信?)

一个管道在内核中只维护一组读写指针,如果同时支持双向数据流动,读写位置就会相互干扰。比如管道缓冲区从头部写入、从头部读出,两头都在操作同一个位置,数据流向必然混乱。虽然从技术上讲可以通过修改内核实现双向,但代价太大,不如创建两个管道各自负责一个方向来得干净。这种设计也符合管道最朴素的使用场景——让数据像水流一样,从一个源头流向一个目的地。


问题一:怎么实现单向数据流?

单向通信的实现方式很简单:通信双方各自关闭不需要的那一端。如果父进程负责写入,就关闭自己的读端,子进程负责读取,就关闭自己的写端。反之亦然。通过关闭操作,数据就只能沿着一个方向流动,另一端只能被动接收,无法反向注入数据,这样就形成了严格的单向信道。


问题二:为什么创建管道时要同时打开读写两端?

假设父进程只打开了读端,那么fork出来的子进程也只有读端,两个进程都在等对方写数据,谁也写不了。同样,如果只打开了写端,两个进程都只能写不能读。这就导致无法形成一读一写的配对关系。所以必须先把读写两端都打开,让父子进程都拥有完整的访问能力,然后再根据需求各自关闭不需要的一端,这样才能灵活地决定谁写谁读。


问题三:不关闭多余的读写端行不行?

从功能上讲,不关闭也能完成数据传递,但这样做不够规范如果两端都开着,进程随时可能误操作写数据到不该写的方向,导致数据流向混乱。不同操作系统对管道行为的实现细节也可能有差异,保持关闭多余端的习惯能保证代码在各种系统上行为一致,同时也让代码意图更清晰,一看就知道这个方向是关闭的。


问题四:为什么不能用普通文件替代管道通信?

用普通文件来做通信在理论上是可行的——写进程把数据写到磁盘文件,读进程再从磁盘读出来但这样做的问题是:磁盘I/O相比内存操作慢了几个数量级,而且涉及频繁的读写同步问题,比如写进程还没写完读进程就开始读了怎么办进程间通信追求的是高效、稳定、实时,管道直接在内核内存中操作,不经过磁盘,天然满足这些要求,所以管道才是正确的选择。


三.实现进程间通信

我们主要是站在⽂件描述符⻆度-深度理解管道

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

pipe 是用于创建匿名管道的系统调用接口。调用该接口的进程会在内核中开辟一段缓冲区,并通过参数返回两个文件描述符,分别对应管道的读端和写端。

  • pipe:是系统调用的函数名,代表"创建管道"这个动作

  • pipefd:是函数的参数名,是一个数组,用来"接收"创建好的文件描述符

参数解读

pipefd 是一个输出型参数,传入的是一个大小为 2 的整型数组。数组传参会退化为指针,内核会将创建好的文件描述符填回到这个数组中。调用者不需要预先传入任何值,只需提供一个数组容器,函数执行完毕后从中获取结果即可。

数组的两个位置分别存放:pipefd[0] 表示读端,pipefd[1] 表示写端。按照文件描述符的分配规则,进程启动时 0、1、2 已经被标准输入、输出、错误占用,所以新打开的管道文件通常会拿到 3 和 4 这两个描述符。

返回值

成功时返回 0,表示管道创建成功,管道缓冲区已就绪,可以通过返回的两个描述符进行读写操作。失败时返回 -1,同时 errno 会被设置为相应的错误码,用于定位失败原因。


1.父进程创建管道

int main()
{
    // 1. 创建管道
    // 定义一个大小为2的整型数组,并初始化为0,用来存放管道的两个文件描述符
    // fds[0] 用于存放读端的文件描述符,fds[1] 用于存放写端的文件描述符
    int fds[2] = {0};

    // 调用 pipe 系统调用创建匿名管道,传入 fds 数组作为输出型参数
    // pipe 函数会在内核中开辟一块缓冲区,并将读端描述符填入 fds[0],写端描述符填入 fds[1]
    // 返回值 n:成功返回 0,失败返回 -1
    int n = pipe(fds);

    // 检查管道是否创建成功,如果 n < 0 说明创建失败
    // 失败时通过标准错误输出打印错误信息,并以返回值 1 退出程序
    if (n < 0)
    {
        std::cerr << "pipe error" << std::endl;
        return 1;
    }

    // 打印读端的文件描述符编号
    // 由于 0、1、2 已被标准输入、输出、错误占用,新分配的描述符通常从 3 开始
    std::cout << "fds[0]: " << fds[0] << std::endl;
    std::cout << "fds[1]: " << fds[1] << std::endl;
    // 到这里,管道已经创建成功,fds[0] 和 fds[1] 已经拿到了有效的文件描述符
   
    return 0;
}


2.父进程fork子进程


3.各自关闭不需要的一端

  • 父进程关闭自己的写端(fds[1]),只保留读端

  • 子进程关闭自己的读端(fds[0]),只保留写端

  • 这样数据只能从子进程流向父进程,形成了单向通信信道


4.父子之间形成通信

代码补充


1.关于 '\0' 的处理

'\0' 是 C 语言中字符串结束的标志,这是语言层面的规定,并不是文件系统层面的要求。管道作为一种特殊的文件,它在传输数据时不关心内容是什么,只负责原封不动地传递二进制数据。所以子进程在向管道写入数据时,不需要写入 '\0'。父进程从管道读取数据后,为了把数据当成字符串来使用,需要手动在末尾补上一个 '\0'。这就要求父进程在读数据时提前预留一个字节的位置给 '\0',保证 buffer 不会溢出。


2.read 返回值的三种情况

read 函数的返回值有三种情况需要处理。第一种是返回值大于 0,这表示读取成功,返回的是实际读到的字节数,程序可以正常处理这些数据。第二种是返回值等于 0,这表示读到了文件结尾,说明写端已经被关闭,对端的进程已经退出,此时读端也应该跟着退出。第三种是返回值小于 0,这表示读取出错,程序需要做相应的错误处理。


3.管道数据的消费特性

管道中的数据被读取之后就不再有效了,这个特性叫做消费。子进程每写入一个字节的数据,父进程读取之后,管道中对应的位置就会被释放出来,可以被新的数据覆盖。这种机制保证了管道中的数据始终是最新的,不会累积旧数据,也体现了生产者和消费者的协作关系。生产者不断生产数据,消费者不断消费数据,双方通过管道的容量来协调工作节奏。


4.为什么每次循环要清空 buffer

父进程在读取管道数据之前先把 buffer 的第一个字节置 0,是为了验证打印出来的数据确实是从管道中读到的,而不是 buffer 中残留的旧数据。如果不做这个清空操作,一旦读取失败或者读到的数据长度不够,buffer 中可能还保留着上一次的内容,就容易造成数据混乱。


5.通信完成后要关闭文件描述符

通信完成后关闭文件描述符是良好的编程习惯。子进程完成写入后关闭写端,父进程完成读取后关闭读端。这样做有两个好处,一是让对端能够通过 read 返回 0 来感知到本端已经退出,二是及时释放系统资源,防止文件描述符泄露。文件描述符是有限的系统资源,如果不及时关闭,长期运行的程序可能会因为描述符耗尽而出错。


6.执行流程

整个程序从父进程创建管道开始,pipe 系统调用在内核中开辟缓冲区并返回读写两端。接着父进程 fork 出子进程,子进程继承了父进程的文件描述符表,于是子进程也拥有了管道的读写两端。为了形成单向通信,子进程关闭读端只留写端,父进程关闭写端只留读端。子进程进入死循环不断向管道写入一个字节的数据,父进程先休眠 100 秒,这期间子进程会把管道写满然后阻塞父进程醒来后从管道读取数据,打印输出。由于代码中父进程只读一次就退出了循环,父进程随后等待子进程结束并回收资源


5.站在内核⻆度-管道本质

所以,看待管道,就如同看待⽂件⼀样!管道的使⽤和⽂件⼀致,迎合了我在前面说到的“Linux⼀切皆⽂件思想”。

6.管道特点

1.只能⽤于具有共同祖先的进程(具有亲缘关系的进程)之间进⾏通信;

通常,⼀个管道由⼀个进程创建,然后该进程调⽤fork,此后⽗、⼦进程之间就可应⽤该管道。

2.管道提供流式服务--面向字节流:

什么是流?

想象一下水管里的水。水从水龙头流出来,你可以选择接一杯、接一桶,或者接任意多的量,没有人规定你每次必须接多少。管道通信也是同样的道理。

管道就像一个缓冲区,写入数据的进程可以一次写 1 个字节,也可以一次写 100 个字节,完全由自己决定。读取数据的进程也一样,可以一次读 5 个字节,也可以一次读 20 个字节,想读多少就读多少。读写双方都不受固定大小的限制,这种灵活的数据传输方式就叫做字节流。


字节流的特点

字节流的核心就是没有边界。数据从写端流入管道缓冲区,再从读端流出,整个过程是连续的、没有分隔的写入端写了多少字节,读取端不一定要一次读完,可以分多次慢慢读,先读进来的一部分数据就相当于被"消费"掉了,剩下的数据下次继续读。

就像喝饮料,你可以一口气喝完,也可以慢慢喝,喝完多少杯子里的饮料就少多少。


3. ⼀般⽽⾔,进程退出,管道释放,所以管道的⽣命周期随进程

体现一:子进程退出时关闭写端

if (id == 0)
{
    close(fds[0]);       // 子进程关闭读端
    ChildWrite(fds[1]);  // 子进程写入数据(死循环,正常情况下不退出)
    close(fds[1]);       // 子进程关闭写端
    exit(0);             // 子进程退出
}

当子进程退出时,操作系统会自动关闭该进程打开的所有文件描述符,包括管道的写端 fds[1]。一旦写端被关闭,父进程的 read 就会返回 0,感知到子进程已经退出。所以子进程只要退出,它对管道的引用就自动释放了。


体现二:父进程退出时关闭读端

close(fds[1]);           // 父进程关闭写端
FatherRead(fds[0]);      // 父进程读取数据
close(fds[0]);           // 父进程关闭读端

父进程读完数据后关闭读端 fds[0],然后父进程继续执行后续代码,最终 main 函数返回,父进程也就退出了。父进程退出时,操作系统同样会关闭它打开的所有文件描述符。


体现三:waitpid 等待子进程

int status = 0;
int ret = waitpid(id, &status, 0);
if (ret > 0)
{
    printf("exit code: %d, exit signal: %d\n", (status >> 8) & 0xFF, status & 0x7F);
}

父进程调用 waitpid 等待子进程退出,确保子进程的退出状态被正确回收。子进程退出后,它所占用的资源(包括打开的文件描述符)才会被内核完全释放。这也说明管道的引用计数归零后,内核才会真正释放管道缓冲区。

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

同步性:

1. 管道写满时,子进程自动阻塞

子进程在 ChildWrite 函数中不断调用 write,每次写入 1 个字节。管道缓冲区的大小是有限的,当子进程写满管道后,内核会自动让子进程进入阻塞状态,子进程被挂起不再执行。等到父进程读取数据腾出空间后,子进程才会被内核唤醒继续写入。整个阻塞和唤醒的过程由内核自动完成,不需要程序员手动控制。

2. 管道为空时,父进程自动阻塞

父进程在 FatherRead 函数中调用 read 从管道读取数据。如果此时管道里还没有数据,read 会阻塞等待,直到子进程写入数据后 read 才返回。这个等待过程也是内核自动完成的,父进程在阻塞期间不占用 CPU。

3. 子进程退出时,父进程 read 返回 0

子进程退出时,操作系统会自动关闭子进程打开的所有文件描述符,包括管道的写端。写端被关闭后,父进程的 read 会立即返回 0,表示读到了文件结尾,父进程感知到子进程已退出并跟着退出循环。这是一种同步机制,通过返回 0 来通知读端写端已经关闭。


互斥性:

1. 读写操作的原子性

子进程正在往管道写入数据的过程中,父进程不会同时从同一个管道读取数据。内核通过内部的锁机制确保每一次 read 和 write 操作都是原子性的,要么全部完成,要么什么都不做,不会出现数据错乱的情况。

2. 管道数据的一致性

子进程和父进程同时操作管道时,内核保证管道中的数据始终保持一致。比如子进程正在写入 100 个字节,写了一半的时候父进程不会读到一半写一半的数据,父进程要么读到完整的 100 个字节,要么读到的是之前的数据。这种保护是内核自动提供的,不需要程序员额外加锁。

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

举个例子:

对讲机就是典型的半双工通信。两个人各拿一部对讲机,说话的时候要按住按钮,说完之后松开按钮,对方才能开始说话。同一时刻只能有一个人在说话,另一个人在听,数据只能单向流动。如果两个人同时按住按钮说话,双方都听不清对方在说什么。这就和管道一样,同一时刻数据只能往一个方向传输。

电话则是全双工通信的例子。两个人通电话时,双方可以同时说话,也能同时听到对方的声音,不需要等对方说完自己才能开口。数据在两个方向上可以同时传输,互不干扰。


7.管道的读写规则

  • 当没有数据可读时

 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将不再保证写⼊的原⼦性。

 8.验证管道通信的4种情况

代码准备工作:

#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>

int main()
{
    int fds[2] = {0};
    pipe(fds);
    
    pid_t id = fork();
    if (id == 0)
    {
        // 子进程 —— 写端
        close(fds[0]);  // 关闭读端
        // 子进程写数据
        close(fds[1]);  // 关闭写端
        exit(0);
    }
    else
    {
        // 父进程 —— 读端
        close(fds[1]);  // 关闭写端
        // 父进程读数据
        close(fds[0]);  // 关闭读端
        
        wait(NULL);
    }
    return 0;
}
  •  读正常&&写满

子进程(w端)不断写数据,父进程(r端)不读或读得很慢,管道被写满后写端阻塞。

void ChildWrite(int wfd)
{
    int cnt = 0;
    while (true)
    {
        write(wfd, "a", 1);   // 不断写入 1 个字节
        printf("写入了 %d 个字节\n", ++cnt);
        // 管道容量是 64KB,写满 65536 个字节后,write 会阻塞
    }
}

void FatherRead(int rfd)
{
    sleep(100);  // 父进程先睡 100 秒,让子进程把管道写满
    // 读得很慢,每次只读 1 个字节
    char c;
    while (true)
    {
        read(rfd, &c, 1);
        printf("读取到 1 个字节\n");
    }
}

现象: 子进程写入 65536 个字节(管道容量)后,write 阻塞,不再打印"写入了 多少个字节"。父进程醒来后每读 1 个字节,子进程才能继续写入 1 个字节。

结果: 读端正常但读得慢,写端写满管道后阻塞,等待读端腾出空间。


  • 写正常&&读空

 父进程(r端)不断读数据,子进程(w端)不写或写得很慢,管道为空时读端阻塞。

void ChildWrite(int wfd)
{
    sleep(100);  // 子进程先睡 100 秒,不写数据
    while (true)
    {
        write(wfd, "a", 1);
        printf("写入了 1 个字节\n");
    }
}

void FatherRead(int rfd)
{
    char c;
    while (true)
    {
        read(rfd, &c, 1);  // 不断读取,管道为空时会阻塞
        printf("读取到 1 个字节\n");
    }
}

现象: 父进程调用 read 时管道为空,read 阻塞,不打印任何内容。100 秒后子进程开始写数据,父进程才恢复读取并打印。

结果: 写端正常但写得太慢,读端读空管道后阻塞,等待写端写入数据。


  •  写关闭&&读正常

子进程(w端)关闭写端,父进程(r端)继续读。

void ChildWrite(int wfd)
{
    const char* msg = "hello";
    write(wfd, msg, strlen(msg));  // 写一次数据
    close(wfd);                    // 立即关闭写端
    printf("子进程关闭写端\n");
}

void FatherRead(int rfd)
{
    char buffer[1024];
    while (true)
    {
        ssize_t n = read(rfd, buffer, sizeof(buffer)-1);
        if (n > 0)
        {
            buffer[n] = 0;
            printf("读取到: %s\n", buffer);
        }
        else if (n == 0)
        {
            printf("读到了 0,写端已关闭\n");
            break;
        }
    }
}

现象: 父进程第一次 read 读到 "hello",第二次 read 返回 0,表示读到了文件结尾。

结果: 写端关闭后,读端 read 返回 0,表示不会再收到数据了。

  •  读关闭&&写正常

父进程(r端)关闭读端,子进程(w端)继续写

void ChildWrite(int wfd)
{
    while (true)
    {
        int n = write(wfd, "a", 1);
        if (n < 0)
        {
            perror("write error");
            break;
        }
        printf("写入成功\n");
    }
}

void FatherRead(int rfd)
{
    close(rfd);  // 父进程立即关闭读端
    printf("父进程关闭读端\n");
    sleep(5);    // 等一会让子进程尝试写入
}

现象: 子进程尝试写入时,write 返回 -1,错误码是 Broken pipe(管道破裂)。

结果: 读端关闭后,写端写入会触发 SIGPIPE 信号,write 返回 -1,errno 设为 EPIPE。

用表格总结

情况 写端状态 读端状态 行为
写满 写满阻塞 正常读但慢 写端阻塞,等读端消费腾空间
读空 正常写但慢 读空阻塞 读端阻塞,等写端写入数据
写关闭 已关闭写端 继续读 读端先读完剩余数据,read 返回 0
读关闭 继续写 已关闭读端 写端触发 Broken pipe,进程收到 SIGPIPE 信号

9.管道的大小

管道有多大?

管道在内核中是一个固定大小的缓冲区,默认是 64KB(65536 字节)。

64KB 是什么意思呢?

就是管道里最多能存 65536 个字节的数据。如果子进程写了 65536 个字节,父进程还没读,那管道就满了。子进程再想写,就会被阻塞,直到父进程读走一些数据腾出空间。

为什么是 64KB?

Linux 2.6.11 之前管道只有 4KB(一页内存的大小),后来觉得太小了,就改成了 64KB。64KB 不算大也不算小,既能满足大部分场景的需求,又不会占用太多内存。

管道大小能改吗?

当然可以改,但一般不需要。

#include <fcntl.h>

int pipefd[2];
pipe(pipefd);

// 把管道大小改成 1MB
int ret = fcntl(pipefd[1], F_SETPIPE_SZ, 1024 * 1024);

改完之后,管道最多能存 1MB 的数据。不过大多数普通用户能改的最大值受系统限制,一般是 1MB 左右,想改更大需要 root 权限。

在你的代码里意味着什么?

你的代码中子进程每次只写 1 个字节,所以大概要写 65536 次才能把管道写满。写满之后子进程的 write 就会阻塞,等父进程读数据才能继续写。

这也解释了为什么子进程不会一直疯狂输出——因为管道大小是有限的,写满了就必须等。如果管道无限大,子进程早就把系统内存耗光了。


四.命名管道FIFO

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

1.命名管道的原理:

两个不相干的进程,怎么看到同一份资源?匿名管道靠 fork 继承文件描述符,但不相关的进程没有继承关系,这条路走不通解决办法就是靠路径——不管两个进程有没有关系,只要它们打开同一个路径下的文件,就能在内核中指向同一个 struct file 对象,也就看到了同一份资源。但普通文件数据要刷盘,太慢了,没有实用价值。所以系统提供了一种特殊的文件——命名管道,它也有路径、有名字,在文件系统中可见,打开方式和普通文件一样,但数据不刷盘,只在内存缓冲区里流转,既解决了“怎么找到对方”的问题,又保证了通信效率。命名管道的底层和匿名管道一样,都是内核缓冲区,都支持单向通信、字节流、同步阻塞这些特性,唯一区别就是匿名管道靠继承找资源,命名管道靠路径找资源。


2.创建⼀个命名管道

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

mkfifo filename


命名管道也可以从程序⾥创建,相关函数有:

int mkfifo(const char *filename,mode_t mode);

创建命名管道:

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

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

  •  匿名管道由pipe函数创建并打开。
  •  命名管道由mkfifo函数创建,打开⽤open
  •  FIFO(命名管道)与pipe(匿名管道)之间唯⼀的区别在它们创建与打开的⽅式不同,⼀但这些⼯作完成之后,它们具有相同的语义。

4.命名管道的打开规则

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

 O_NONBLOCK disable:阻塞直到有相应进程为写⽽打开该FIFO
 O_NONBLOCK enable:⽴刻返回成功


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

 O_NONBLOCK disable:阻塞直到有相应进程为读⽽打开该FIFO
 O_NONBLOCK enable:⽴刻返回失败,错误码为ENXIO

5实例说明

实例1. ⽤命名管道实现⽂件拷⻉

需要创建两个 C 程序,分别负责写入和读取。

写端程序(write.c)


读端程序(read.c)


1.进行编译:

//编译写端程序
gcc -o write write.c

//编译读端程序
gcc -o read read.c

2.准备源文件

// 创建一个用来拷贝的文件
echo "hello world" > abc

3.执行

需要两个终端

终端1:先运行写端

./write

输出:

命名管道 tp 已创建
源文件 abc 已打开

上面这个输出这两行后会出现卡住等待读端连接管道:


终端2:再运行读端

./read

此时终端1:


验证结果:

查看文件是否生成:

输出应该和 abc 内容一样:


接下来就是对命名管道实现文件拷贝的解析:

1.这个程序要干嘛

把 abc 文件的内容拷贝成 abc.bak。但不用普通文件的方式,而是用命名管道在两个独立的进程之间传递数据。一个进程负责读源文件写管道,另一个进程负责读管道写目标文件,两个进程配合完成拷贝。

2.为什么这么设计

这两个程序之间没有任何关系(不是父子进程),通过命名管道 tp 连接在一起。这演示的是命名管道的核心价值让两个毫不相干的进程通过路径找到同一份资源,实现通信。 虽然这里只是拷贝一个文件,但实际场景中,两个进程可能运行在不同的终端、不同的用户下,甚至是一个负责采集数据、一个负责存储数据,它们通过命名管道协作完成任务。

3.命名管道在这里的作用

命名管道 tp 充当了两个程序之间的数据通道。写端把从 abc 读到的数据往管道里写,读端从管道里把数据读出来往 abc.bak 里写。数据从管道一头流进去、另一头流出来,全程只在内存里中转,不涉及磁盘读写,保证了通信效率。管道文件 tp 只是一个路径标识,让两个进程能够找到同一块内核缓冲区,真正的数据传递发生在内存里。

4.两个程序各自干了什么

写端程序:先创建命名管道文件 tp,再打开源文件 abc 准备读取数据,然后打开管道 tp 用于写入。如果读端还没运行,这一步会阻塞等待。连接成功后,循环从 abc 读数据写入管道,读完关闭所有文件退出。

读端程序:打开管道 tp 用于读取,如果写端还没运行也会阻塞等待。连接成功后创建目标文件 abc.bak,循环从管道读数据写入目标文件。读完关闭所有文件,最后用 unlink 删除管道文件 tp

5.两个程序怎么配合

先运行写端,它创建管道后卡在 open 等待读端连接。再运行读端,它打开管道后两端连接成功,开始传输数据。写端把 abc 的内容一点一点读出并通过管道发过去,读端收到后一点一点写到 abc.bak 里。写端读完 abc 后关闭写端,读端读到 0 就知道写端关了,也跟着退出并删除管道文件。

6.分析流程

第一步:写端先启动

写端先启动 --> 创建管道文件 tp --> 打开源文件 abc --> 打开管道 tp(阻塞等待读端连接)

第二步:读端后启动

读端后启动 --> 打开管道 tp --> 两端连接成功

第三步:传输数据

写端从 abc 读数据 --> 写入管道 tp --> 数据在管道中流动 --> 读端从管道 tp 读数据 -->写入 abc.bak

(这一步循环,直到 abc 文件读完)

第四步:收尾

写端读完 abc --> 关闭写端 --> 读端读到 0(知道写端关了) --> 关闭所有文件 --> 删除管道文件 tp --> 读端退出

7.实例一总结

命名管道实现文件拷贝,本质就是两个独立进程通过同一个管道文件找到同一块内核缓冲区,一个往里写数据、一个往外读数据。两个程序各自干各自的事,通过管道串联成完整的拷贝任务整个过程数据只在内存中流转,不落盘,高效且灵活。这就是命名管道最朴素的应用。

8.注意:

先创建源文件,分别编译两个程序,两个终端各跑一个,用完记得删管道文件。


实例2. ⽤命名管道实现server&client通信

1.创建两个源文件

在当前目录下创建 serverPipe.c 和 clientPipe.c 两个文件。

2.准备serverPipe.c 代码

3.准备clientPipe.c 代码

4.创建 Makefile

5.make编译+运行+输入消息

终端1:先运行 server(服务端)

服务端卡在这里,等待客户端连接。


终端2:再运行 client(客户端)

此时客户端等待你输入消息。因为运行一开始只显示please Enter我输入后面的东西就有了这个结果,当你不输入东西的时候只用please Enter,表示:此时客户端等待你输入消息。.


终端2(客户端):输入

Please Enter # hello

终端1(服务端) 会同步显示:


6.退出

在客户端终端按 Ctrl+C 或者 Ctrl+D,客户端退出。

终端2(客户端) 退出,回到命令行。

终端1(服务端) 检测到客户端退出,打印并退出:


7.注意事项

必须先运行 server:server 创建管道并等待连接,client 才能连接
两个终端:必须同时运行两个程序才能通信
管道文件:程序结束后 mypipe 不会自动删除,需要手动清理:rm -f mypipe
读取的是用户输入:客户端从标准输入读取用户输入,通过管道发给服务端


8.命名管道实现 Server & Client 通信 的底层原理

1.这个程序要干嘛

实现一个最简单的聊天程序。服务端等着收消息,客户端发消息,服务端收到并显示。一个负责发,一个负责收,通过命名管道连接在一起。

2.两个程序各自的任务

服务端(serverPipe):负责创建管道、等待接收消息、打印消息。
客户端(clientPipe):负责连接管道、读取用户输入、发送消息。

两个程序没有任何关系,通过命名管道 mypipe 连接。服务端只收不发,客户端只发不收。

3.通信流程


这张图展示的是服务端和客户端通过命名管道通信的完整过程,包含三个关键阶段:连接建立阶段、正常通信阶段和退出阶段。

连接建立阶段

服务端先启动,这是整个通信的前提。服务端一运行就立即调用 mkfifo 创建了名为 mypipe 的管道文件,这个文件相当于双方约定的见面地点,有了它客户端才知道该去哪找服务端。紧接着服务端打开管道准备读取数据,但此时客户端还没启动,没有进程以写方式打开管道,所以服务端的 open 会阻塞在这里,等着客户端来连接。随后客户端启动,一上来就以写方式打开同一个管道文件,此时服务端阻塞的 open 返回了,客户端的 open 也成功了,两端正式连接成功,管道打通了,可以开始传数据了。

正常通信阶段

两端连接成功后,客户端提示用户输入消息,用户敲完文字按回车,客户端就把这段消息写入管道。数据从客户端进入内核的管道缓冲区后,服务端马上从管道读出来并打印显示。这个过程可以反复进行,用户每次输入一条消息,服务端就收到并显示一条,就像两个人在聊天一样,客户端说话服务端听着。

退出阶段

当用户不想继续通信了,在客户端按 Ctrl+C,客户端进程终止,操作系统自动关闭了管道写端。此时服务端还在循环等待下一条消息,但 read 调用返回了 0,这在管道通信中表示读到了文件结尾,意味着写端已经关闭了。于是服务端知道客户端已经退出了,自己也跟着退出。至此整个通信过程结束。

这张图核心讲的就是:两个不相干的进程通过命名管道建立连接,服务端等着收消息,客户端主动发消息,客户端走了服务端也不等了。整个过程从启动到退出一气呵成。

这两个程序就是命名管道最典型的应用——两个完全不相关的进程,通过同一个管道文件路径找到了同一份内核资源,一个写一个读,实现了通信。和匿名管道的区别只在于:匿名管道靠继承,命名管道靠路径。底层的通信机制完全一样,都是内核中的缓冲区,都是单向、字节流、同步阻塞。

4.服务端在干什么

服务端的工作很简单:先创建管道文件,然后打开管道等着读数据。每次循环都打印 "Please wait..." 表示自己在等待,然后调用 read 从管道读数据。读到数据就打印 "client say:....",读到 0 就说明客户端退出了,自己跟着退出。如果读出错就报错退出。

5.客户端在干什么

客户端的工作更简单:直接打开管道准备写数据,然后循环提示用户输入 "Please Enter # ",从键盘读取用户输入的内容,写入管道。用户输入一次就发一次,直到用户按 Ctrl+C 或 Ctrl+D 退出。

6.为什么先运行服务端

服务端要先启动,因为服务端负责创建管道文件 mypipe。如果客户端先运行,它找不到 mypipe 这个管道文件,open 就会失败。所以顺序必须是:服务端先跑起来创建好管道,客户端才能连接上来。这就像现实中的对讲机,得有人先打开频道等着,另一个人才能呼进来。

7.服务端是怎么知道客户端退出的

当客户端退出时,操作系统会自动关闭客户端打开的所有文件描述符,包括管道的写端。写端被关闭后,服务端的 read 会返回 0(读到了文件结尾)。服务端看到 s == 0,就知道客户端已经不在了,于是打印 "client quit, exit now!" 然后自己退出。这是 read 的返回值在告诉服务端"对面走了,别等了"。

8.小结一下

Server & Client 通信本质上就是两个程序通过命名管道文件找到同一块内核缓冲区,服务端等着收、客户端主动发,一个负责发消息、一个负责收消息。先启动服务端创建管道,再启动客户端连接上来,客户端输入什么服务端就显示什么,客户端退出服务端也跟着退出。管道文件只是个"接头地点",真正的数据在内存里流转。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐