Linux进程间通信(一):匿名管道的原理和使用

一、前言

(在阅读本文前,需要具备Linux基础IO的基本知识)
 在某些特定情况下,多进程需要协同处理任务,此时就需要进程交互。然而我们知道进程之间是具有的独立性的,因此数据交互的成本较高。在这种矛盾的推动下,进程间通信就应运而生了,它主要有以下四种目的:

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

​ 两个进程之间是没有交集的,因此进程间通信的本质在于如何让两个进程看到同一份资源,而不在于如何通信,资源的不同决定了通信方式的不同。理解这点非常重要!!

​ 我们可以分别使用 cat()echo() 指令打印同一份磁盘文件内容时,这本质上也属于一种非常原始的通信—— cat 进程和 echo 进程看到了同一份磁盘资源。但是磁盘的读写速度是非常慢的,我们期待实现内存级别的进程间通信,因此必须要以内存作为数据的缓冲区。

二、什么是匿名管道?

 匿名管道想必大家都接触过,其实就是命令行窗口中的 | ,例如下图这个例子:匿名管道 |yum list 指令的输出结果作为 grep ncurses 指令的输入数据。为什么要叫它管道呢?其实也不难理解,谈到管道大家可能会联想到石油,而数据就是计算机世界中的石油,传输数据的媒介自然叫管道。

 一个指令其实就对应着一个进程,因此,通过匿名管道,我们实现了 yum 进程和 grep 进程间的数据交互
在这里插入图片描述
从上面的例子中,我们也能初步总结出管道的特点(对命名管道同样适用):

  • 管道是用于传输数据的
  • 管道传递数据都是单向

三、匿名管道的原理

 当我们使用 fork() 函数创建子进程的时候,子进程的内核数据结构基本拷贝自父进程,自然而然的子进程也会继承父进程的 文件描述符表Linux基础IO(二):深入理解Linux文件描述符),这就意味着子进程所打开的文件和父进程打开的是完全一样的。父子进程向同一个文件中读取或写入,由此实现进程间通信。

 然而上文中我们也谈到,磁盘的访问速度太慢,我们需要实现内存级别的进程间通信。因此父进程所打开的管道文件是一种特殊的文件,它与磁盘去关联,写入时直接向缓冲区中写入,读取时也直接从缓冲区中读取,由此实现内存级别的通信。

[问题一]:如何区分普通文件和管道文件?

// 在inode结构体下可以看到如下的联合体(adree_space包含有struct inode)
struct inode
{
     
     
  union {
     
     
		struct pipe_inode_info	*i_pipe;  // 管道设备
		struct block_device	*i_bdev;      // 磁盘设备
		struct cdev		*i_cdev;          // 字符设备
	};
}

底层是通过一个联合体来区分文件属性的,当创建一个管道的时候,对应的管道字段就会生效

扫描二维码关注公众号,回复: 14821548 查看本文章

站在文件描述符角度 - 深度理解管道:
image-20221113195145304

[问题二]:为什么父进程要先后以读写的方式打开同一个管道文件?
 答:子进程拷贝父进程后,就不需要再以读或者写的方式打开管道文件了。


[问题三]:为什么父子进程要分别关闭读端和写端
 答:确保管道通信的单向性,例如上图中数据只能从父进程流向子进程

[问题四]:父进程究竟是关闭读端还是关闭写端是由什么决定的?
 答:由用户需求决定。如果希望数据从父进程流向子进程,就关闭父进程的读端,子进程的写端;如果希望数据从子进程流向父进程,就关闭父进程的写端,子进程的读端

三、匿名管道的创建

image-20221113222841865
[作用]:创建匿名管道
[参数说明]: pipefd[2]是一个返回型参数

  • pipefd[0]存储以的方式打开管道文件所返回的文件描述符
  • pipefd[1]存储以的方式打开管道文件所返回的文件描述符
    (记忆方式:0 → 嘴巴 → 读     1 → 笔 → 写)

[返回值]:创建管道成功返回0;失败返回-1
[函数说明]:

  • pipe() 函数自动以读写的方式打开同一个管道文件并将文件描述符返回给 pipefd[2]
  • pipe函数属于系统调用,因此操作系统可以直接在内核中将文件类型设置为管道

[管道读写规则]:

  • 当没有数据可读时
    1. O_NONBLOCK disable:read调用阻塞,一直等到有数据来到为止
    2. O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN
      (使用 fcntl 函数设置非阻塞选项)
  • 当管道满的时候
    1. O_NONBLOCK disable: write调用阻塞,直到有进程读走数据
    2. O_NONBLOCK enable:调用返回-1,errno值为EAGAIN
  • 如果所有管道写端对应的文件描述符都被关闭,则read返回0表示读到文件结尾
  • 如果所有管道读端对应的文件描述符都被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出
  • 当要写入的数据量不大于PIPE_BUF(Linux下为 4096 字节)时,linux将保证写入的原子性。否则将不保证

四、匿名管道实现数据传输

// 使用案例:数据从父进程传递给子进程
int main()
{
    
    
    int pipefd[2] = {
    
    0};
    if(pipe(pipefd) != 0)         // 创建管道失败
    {
    
    
        cerr << "pipe" << endl;
        return 1;
    }

    pid_t pid = fork();

    if(pid == 0) // 子进程关闭写端
    {
    
    
        close(pipefd[1]);
        char buff[20];
        while(true)
        {
    
    
            ssize_t ss = read(pipefd[0], buff, sizeof(buff));
            if(ss == 0)
            {
    
    
                cout << "父进程不写入了,我也不读取了"  << endl;
                break;
            }
            buff[ss] = '\0';
            cout << "子进程收到消息:" << buff << " 时间:" << time(nullptr) << endl;
        }
    }
    else        // 父进程关闭读端
    {
    
    
        close(pipefd[0]);   
        char msg[] = "hello world!";
        for(int i = 0; i < 5; i++)
        {
    
    
        	// 不要写入字符串末尾的'\0'
        	// ‘\0’结尾是C语言的标准,文件可不吃这一套
            write(pipefd[1], msg, sizeof(msg) - 1);   
            sleep(1);
        }
        cout << "父进程写入完毕" << endl;   
        close(pipefd[1]);
        waitpid(pid, nullptr, 0);   
    }
    
    return 0;
}

在这里插入图片描述

[问题五]:为什么子进程没有sleep,但是会随着父进程休眠呢?(观察时间戳)
 答:pipe函数自带访问控制机制,父子读写时具有一定的顺序性:

  • 当一个进程尝试从一个空的管道中读取时,read接口会被阻塞直到管道内有数据为止
  • 当一个进程尝试向一个满的管道中写入时,write接口会被阻塞直到足量的数据从管道中被读取走为止


[问题六]:子进程如何感知父进程关闭管道了呢?
 答:每当一个进程打开一个文件的时候,该文件的引用计数会加一;每当一个进程关闭一个文件的时候,该文件的引用计数会减一。当一个文件的引用计数减为0时,表明没有进程打开这个文件,那么这个文件才会被真正被关闭。

 当管道文件的引用计数为1时,表明父进程已经关闭管道文件,子进程读完当前消息就可以作为文件的结尾而退出了。因此子进程是可以感知父进程是否关闭写端的。

五、匿名管道实现进程控制

  1. 创建多个管道文件(其实一个也可以)
  2. 创建多个子进程
  3. 父进程随机分配任务(指定哪个进程,指定什么任务)
  4. 子进程处理任务

在这里插入图片描述

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <ctime>
#include <cassert>
#include <vector>

using namespace std;

typedef void(*function)();

vector<function> task;   

void func1()
{
    
    
    cout << "正在执行网络任务……" << "  时间" << time(nullptr) << endl;
}

void func2()
{
    
    
    cout << "正在执行磁盘任务……" << "  时间" << time(nullptr) << endl;
}

void func3()
{
    
    
    cout << "正在执行驱动任务……" << "  时间" << time(nullptr) << endl;
}

void LoadFunc()           // 加载任务到vector中
{
    
    
    task.push_back(func1);
    task.push_back(func2);
    task.push_back(func3);
}

int main()
{
    
    
    LoadFunc();
    srand((unsigned int)time(nullptr) ^ getpid());
    int pipefd[2] = {
    
    0};
    if(pipe(pipefd) != 0)
    {
    
    
        cerr << "pipe" << endl;
        return 1;
    }

    pid_t pid = fork();
    
    if(pid == 0)
    {
    
    
        close(pipefd[1]);
        while(true)
        {
    
    
            uint32_t n = 0;
            ssize_t ss = read(pipefd[0], &n, sizeof(uint32_t));
            if(ss == 0)
            {
    
    
                cout << "我是打工人,老板走了,我也下班了" << endl;
                break;
            }

            assert(ss == sizeof(uint32_t));

            task[n]();
        }
    }
    else 
    {
    
    
        close(pipefd[0]);
        for(int i = 0; i < 10; i++)
        {
    
    
            uint32_t ss = rand() % 3;
            write(pipefd[1], &ss, sizeof(uint32_t));
            sleep(1);
        }
        cout << "任务全部处理完毕" << endl;
        close(pipefd[1]);
        waitpid(pid, nullptr, 0);
    }

    return 0;
}

在这里插入图片描述

六、匿名管道特点总结

  1. 匿名管道只能用于具有血缘关系的进程之间。通常用于父子进程之间通信(兄弟之间也可以:父进程打开管道后创建两次子进程,再将父进程的管道的读写端关闭,就可以实现两个兄弟进程间的通信)
  2. 管道必须是单向的,Linux内核在设计的时候就是当管道为单向的
  3. 管道自带访问控制机制
  4. 管道是面向字节流的。先写的字符一定是先被读取的、读取时没有格式边界需要用户来限制内容的边界(例如在使用read函数时我们要指定读取多少个字节)
  5. 管道也是文件,当进程退出管道的引用计数被减为0时管道的生命周期才会结束
  6. 一般而言,内核会对管道操作进行同步与互斥

猜你喜欢

转载自blog.csdn.net/whc18858/article/details/128380792