在这里插入图片描述

◆ 博主名称: 小此方-CSDN博客
大家好,欢迎来到小此方的博客。
⭐️Linux系列个人专栏: 【主题曲】Linux
⭐️此方的GitHub: github_此方
⭐️ Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)


概要&序論

  Hello大家好我是此方,本文将带你硬核拆解 Linux 匿名管道。我们将从父子进程共享内核文件的通信本质出发,手把手实现单向信道,并深度剖析 close(fds) 泄漏、waitpid 死锁等开发避坑细节,系统讲解五种特性与原子性写限制,好,我们开始吧。

一、匿名管道的原理

1.1匿名管道的来历

1.1.1什么是管道

  • 管道是Unix中最古老的进程间通信的形式
  • 我们把从一个进程连接到另一个进程的一个数据流称为一个“管道

在这里插入图片描述

1.1.2最简单的管道——匿名管道

  科学家想要设计一种进程通信技术,最初打算怎么做?自然是想要在现有基础上稍改动一下,或者完全不加改动就能实现通信。

在这里插入图片描述

  让不同的进程看到同一块资源。 这块资源不能是任何一个进程提供的(因为进程具有独立性),而是由操作系统提供的。

  操作系统提供的资源,只能是struct file。我们一个一个的文件。 通过文件来进行通信操作。于是我们有了匿名管道。匿名管道比较适合作为父子间通信的工具。

1.2匿名管道的通信原理

1.2.1父子进程看到同一个文件

  创建子进程,由于我们的进程管理和文件管理在设计的时候是一种解耦设计,所以在创建子进程的时候是默认不需要拷贝文件。

于是我们得到一个结论:创建完成的子进程和父进程共享文件

在这里插入图片描述

  这个类似于c++里面的浅拷贝。于是一样适用引用计数。这个引用计数和inode里面的硬链接数是没有关系的。

1.2.2管道文件与系统调用

  如果我们使用普通文件来做管道会遇到刷盘的问题。而管道文件不需要刷新到磁盘,属于纯内存级文件。
  所以这个管道文件时被操作系统单独设计的,同时配上单独的系统调用。

int pipe(int pipefd[2]);
//不需要文件名,不需要路径,纯内存级文件
  • 参数 pipefd 是一个包含两个整数元素的数组,用于输出管道的两端:pipefd[0] 是读端文件描述符,pipefd[1] 是写端文件描述符。
  • 函数执行成功时返回 0;如果执行失败则返回 -1,并且会设置全局变量 errno 以指示具体的错误原因。

必须是0读1写,这是我们的约定,你要是记不住你就这样记忆,0是嘴巴,读;1是笔,写。

1.2.3单项信道的构建

在这里插入图片描述

  1. 父进程创建管道:父进程通过系统调用创建匿名管道,从而在内核中产生一个管道缓冲区,并获取分别指向该管道读端(fd[0])和写端(fd[1])的两个文件描述符。
  2. 父进程fork出子进程:父进程调用fork创建子进程,子进程会复制父进程的文件描述符表,使得父子进程的fd[0]和fd[1]同时指向同一个内核管道的两端。
  3. 关闭不必要的读写端:父子进程根据通信方向各自关闭一个描述符(如父进程关闭读端、子进程关闭写端),最终使管道连接在两个进程之间,构建出一条单向通信的信道。

在这里插入图片描述

  我们如何保证父子进程看到的是同一个管道文件?子进程继承父进程的文件描述符表!
在这里插入图片描述

二、实现一个简单的匿名管道并让父子进程实现通信

2.1代码实现

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstdio>
#include <cstring>

// 子进程写端逻辑:每隔1秒向管道写入一条数据
void child_write(int wfd)
{
    int cnt = 0;
    while (1) {
        char buffer[1024] = {0};
        // 格式化日志信息放入缓冲区
        snprintf(buffer, sizeof(buffer),
                 "我是子进程,我的pid是:%d ,我的计数器目前是:%d", getpid(), cnt++);
        
        // 故意让子进程每秒写一次,用以观察父进程的阻塞等待同步行为
        sleep(1); 
        
        // 向写端文件描述符 wfd 写入数据,写入长度为字符串实际长度
        int realwrite = write(wfd, buffer, strlen(buffer));
        
        // 实际开发中通常需要对 realwrite 的返回值进行校验,这里省略
    }
}

// 父进程读端逻辑:死循环从管道中读取数据并打印
void father_read(int rfd)
{
    char buf[1024 * 2] = {0};
    while (1)
    {
        // 每次读取前清空缓冲区,保持良好的代码习惯
        memset(buf, 0, sizeof(buf));
        
        // 从读端文件描述符 rfd 中读取数据。如果管道中没数据,父进程会在此处阻塞等待
        int realread = read(rfd, buf, sizeof(buf) - 1); // 留一个字节填充 '\0'
        
        if (realread > 0)
        {
            // 读取成功:将读取到的有效字符末尾置为 '\0',使其成为一个合法的C风格字符串
            buf[realread] = 0;
            std::cout << buf << std::endl;
        }
        else if (realread == 0)
        {
            // 管道特性:如果写端关闭,读端读到文件末尾,read 返回 0
            std::cout << "子进程(写端)已关闭,父进程读取完毕,退出循环。" << std::endl;
            break;
        }
        else
        {
            // 读取出错
            perror("read error");
            break;
        }
    }
}

int main()
{
    // 1. 创建匿名管道
    // pipefd[0]: 读端 (嘴巴读)
    // pipefd[1]: 写端 (笔来写)
    int pipefd[2] = {0};
    int ret = pipe(pipefd);
    
    if (ret == 0)
    {
        // 管道创建成功!紧接着 fork 创建子进程
        int id = fork();
        
        if (id == 0)
        {
            // ==================== 子进程分支 ====================
            // 构建单向信道:子进程负责【写】,因此需要关闭不必要的【读端】
            close(pipefd[0]);
            
            // 执行子进程的写入核心逻辑
            child_write(pipefd[1]);
            
            // 写入完毕后,关闭写端(虽然 child_write 里是死循环,但规范的回收必不可少)
            close(pipefd[1]);
            
            // 子进程执行完毕后退出,不进入父进程后续代码
            exit(0); 
        }
        
        // ==================== 父进程分支 ====================
        // 构建单向信道:父进程负责【读】,因此需要关闭不必要的【写端】
        close(pipefd[1]);
        
        // 执行父进程的读取核心逻辑
        father_read(pipefd[0]);
        
        // 读取完毕后,关闭读端
        close(pipefd[0]);
        
        // 进程等待:父进程阻塞等待子进程退出,回收子进程僵尸状态
        int statue = 0;
        int rid = waitpid(id, &statue, 0);
        
        if (rid > 0) {
            std::cout << "成功回收子进程,PID: " << rid << std::endl;
        }
    }
    else
    {
        perror("pipe error");
        return 1;
    }
    
    return 0;
}

2.2细节解析

2.2.1 字符串处理:语言层与文件层的边界

  写入时不建议携带 \0\0 是 C/C++ 语言层面的字符串结束标记,如果你强行将 \0 写入管道,文件系统会将其识别为“乱码”。
  我们敢在写入时使用 strlen 的原因:在子进程中,我们使用 snprintf 格式化数据放入缓冲区,snprintf 就会自动在生成的字符串末尾补上 \0
  读取时必须预留位置手动补 \0:因为从管道中读取出来的只是没有结束符的纯字节流,如果你直接用 std::cout 打印它,C++ 语言层会因为找不到结束符而发生内存越界,打印出乱码。因此,合理的做法是:读取时留出一个字节的空间

ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);
if (n > 0) {
	buffer[n] = '\0'; 
}

2.2.2 waitpid 的位置必须在通信函数之后

  死锁原因waitpid 默认是阻塞等待。如果放在通信函数前面,父进程会永远卡在等待子进程退出的地方,无法向下执行读取逻辑。一旦子进程把管道写满,子进程也会阻塞,最终导致父子进程互相死锁。
  正确逻辑:父进程先调用通信函数读取数据,子进程退出导致写端关闭,父进程的 read 读到文件末尾返回 0 退出循环,最后再去调用 waitpid 顺理成章回收子进程。

2.2.3实际开发中必须手动 close(fds)

   简单例子的例外:此代码中子进程写完马上执行 exit(0),操作系统会自动隐式回收进程占用的所有资源并关闭文件描述符,所以不手动写 close 也不会报错。
   必关的原因:实际开发中子进程通常还要继续执行其他复杂的长周期任务。如果不手动关闭,会引发写端泄露以及文件描述符泄漏

2.2.4 关于系统编程

  EffectiveC++中提到过,C++是一个语言联邦。再系统编程中,也许我们要重 C 弱 STL:Linux 内核提供的系统调用(pipeforkreadwrite)全是原生的 C 语言接口,你用STL有转化问题要解决。

  还有一些小bug,比如父进程一次读取太多(父进程那边的buffer数组你设置的太大了)会挂掉。经过检查原因是栈溢出。还有问题就是,,,字节流。

2.2.5面向字节流

  先简单讲一讲,在线程部分我们会有一个更加深入的理解。什么是面向字节流?简单的讲:

写进去是一行一行的,读出来是一坨一坨的。内核从来不关心你写进去的是什么东西。 (有没有面向比特流?有的,我们不讲。)

  还是刚才的代码,一口气让父进程读取一大段内容吧。看到在读取的末尾都有半个字符串,没有读完。

在这里插入图片描述
  怎么读和曾经怎么写没有必然的联系。于是我们引出两个工程问题——粘包与半包

  • 粘包是指由于字节流没有边界,多个独立的数据包在接收端被合并成了一个大包读取。
  • 半包是指一个完整的数据包太大,在接收端一次只能读取到它的一部分(即不完整的数据包)。

解决办法常见的也蛮多的,我们举例两种:

  • 发送端与接收端约定固定长度的消息(即写单位等于读单位),不足时填充。
  • 消息末尾统一加上特殊符号(如 \n),接收端根据该分隔符切分、拆包。

三、深入理解匿名管道

  如果你完全理解了上面的内容,那么是时候进入更深层次的理解了。

3.1父子进程同步

  子进程写的慢,父进程读的快,但是父进程必须停下来等待子进程写入。于是即使父进程读的快,但是也要停下来等待子进程——这就是我们说的同步。 关于同步更多的内容我们放在线程部分讲解。

void ChildWrite(int wfd)
{
    char buffer[1024];
    int cnt = 0;
    while (true)
    {
        snprintf(buffer, sizeof(buffer), "I am child, pid: %d, cnt: %d", getpid(), cnt++);
        write(wfd, buffer, strlen(buffer));
        sleep(1);
    }
}
void FatherRead(int rfd)
{
    char buffer[1024];
    while (true)
    {
        buffer[0] = 0;
        ssize_t n = read(rfd, buffer, sizeof(buffer)-1);
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "child say: " << buffer << std::endl;
        }
    }
}

  或者说,让子进程写的快,父进程读的慢,子进程写满了管道,但是我们的父进程还在sleep,父进程唤醒,才把管道中的内容读取到父进程的buffer中。——这之间,子进程只能先阻塞着这也是一种同步。

3.2管道的大小

  默认上限:在现代 Linux 内核中(Linux 2.6.11 之后),管道的默认容量通常是 64 KB。可以通过 fcntl(fd, F_SETPIPE_SZ, size) 动态修改该上限。

为什么管道文件和一般文件的容量差距这么大?因为管道文件时纯内存级的,不像一般文件,满了可以刷到磁盘里面。

3.2.1测试管道的大小

char c = 0;
int cnt = 0;
while (true)
{
	//snprintf(buffer, sizeof(buffer), "I am child, pid: %d, cnt: %d", getpid(), cnt++);
	write(wfd, &c, 1);
	printf("child: %d\n", cnt); //我们看看一个管道的大小。
	//用这种我们不断的写入一次写一个,
	//看看计数器会在什么时候停止
	// sleep(2);
	//break;
	// sleep(3);
}

  我测出来是64KB,我的机器是ubantu20.04,但是不是所有的机器管道的大小都是64KB,

3.2.2原子性写大小对比管道总体大小

  我们上面测出来的64KB是管道缓冲区的大小,但是当我们使用ulimit查看当前用户及进程可以使用的系统资源上限时候,512字节?

在这里插入图片描述
  这个512字节是原子性写大小,上面我们测出来的是管道文件的总大小。什么是原子性写大小?

“原子性”意味着“要么不做,要么一口气全部做完,中间绝不容许被分割或打断”。

  对于原子性的理解,Gemini给出了一个不错的例子:

在这里插入图片描述

原子性写缓冲区就是多进程并发写入时,单次写入小于该值能保证数据不被打碎/不交叉。

3.3五种特性与四种通信情况

  • 匿名管道只能用来进行具有血缘关系的进程进行进程间通信(常用在父子,兄弟之间也可以。)
  • 管道文件,自带同步机制。
  • 管道是面向字节流的。
  • 管道是单向通信的。
  • (管道)文件的生命周期,是随进程的

3.3.1全半双工的概念引入

  前三点在前面都讲过了,我们把注意力集中在第四点(我想第五点比较适合放在SystemV那里对比着讲): ”管道单向通信“ 这是属于半双工的一种特殊情况。

  • 任何一个时刻,一个发,一个收 — 半双工
  • 任何一个时刻,两个可以同时发收 — 全双工

  怎么理解好呢?我想到一个极好的例子:你和别人讲话,是半双工,他讲一句你讲一句。反过来你和别人吵架就是全双工。

  • 写慢,读快 ---- 读端就要阻塞(进程)。

  • 写快,读慢 — 满了的时候,写就要阻塞等待读端。

  • 写关,继续读 — read就会读到返回值为0,表示文件结尾。

  • 读关,继续写 — 写端在写入,没有任何意义 OS会杀掉写端进程。

  有必要详细讲解最后一点:OS不会做没有意义的事情,读端已经关闭了,写端继续保留的意义已经没有了不是吗。于是操作系统就会发送异常信号 13) SIGPIPE。


好的本期内容就到这里,如果对你有帮助,还不要忘记点赞三联支持。我是此方,我们下期再见。bye!
Logo

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

更多推荐