【Linux进程】进程间的通信

目录

[1. 进程间通信](#1. 进程间通信)

[1.1 进程间通信的目的](#1.1 进程间通信的目的)

[2. 管道](#2. 管道)

[2.1 什么是管道](#2.1 什么是管道)

[2.2. 匿名管道](#2.2. 匿名管道)

匿名管道的特性

管道的4种情况

联系shell中的管道

[2.3. 命名管道](#2.3. 命名管道)

代码级建立命名管道

[2.4. 小结](#2.4. 小结)

总结


1. 进程间通信

进程间通信(Inter-Process Communication,IPC)是指在操作系统中,多个进程之间进行数据交换和信息传递的一种机制。由于进程在内存中有各自的地址空间,它们不能直接访问对方的内存,因此需要通过一些特定的方法来实现通信;

1.1 进程间通信的目的

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

进程间通信的本质:让不同的进程先看到同一份资源,资源通常是由操作系统提供

进程间通信的方式也有很多:匿名管道、命名管道、共享内存、消息队列...;

本文主要介绍匿名管道、命名管道这两种进程通信;

2. 管道

2.1 什么是管道

管道是Unix中最古老的进程间通信的形式;

联系一下日常生活中的管道,水在管道中流到一般都是单向的,这里的管道也是如此,进程之间通过管道通信也是单向的;

管道(Pipes)是一种进程间通信(IPC)机制,用于在一个或多个进程之间传递数据。它通过创建一个临时的通道,允许一个进程的输出直接作为另一个进程的输入。管道主要分为两种类型:匿名管道和命名管道;

管道的通信特点就是单向的,单个管道只能进行单向通信,想要双向通信怎么办?--创建两个管道;

2.2. 匿名管道

匿名管道主要用于有亲缘关系的进程(如父子进程)之间进行通信;

最常见的就是父子进程体系:

父进程创建子进程,子进程会拷贝父进程的 struct file_struct(这里可以认为是浅拷贝);files_struct 中的内容完全和父进程相同,这样父进程和子进程就同时指向了同一块资源;

files_struct:是进程用于管理打开的文件,所描述出来的数据结构;

管道可以视为一种特殊的文件,只存在于内存中的文件;

怎么理解?一个被打开的文件通常包含三个结构:inode、方法集(虚拟文件系统)、文件缓冲区;管道也是如此;只不过他不会把缓冲区的数据刷新到磁盘中;

如上图的结构,父子进程执行的是相同的文件描述对象,如果同时对文件进行写入,是无法辨识数据是谁写入的;为了避免这样的情况,管道在设计之初,它的写入就是单向的;

比如:父进程向子进程发送数据,父进程只能往管道写入数据,子进程只能从管道读取数据;数据传输的过程和管道类似,又因为它内部所使用的文件并不会在磁盘中存在,只会在内存中使用,所以也被称为匿名管道;

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

示例:

cpp 复制代码
#include <iostream>  
#include <cassert>  
#include <unistd.h>  
#include <cstring>  
#include <sys/wait.h>  
#include <sys/types.h>  

using namespace std;  

int main()  
{  
    // 第一步:建立管道  
    int pipefd[2] = { 0 };  
    int n = pipe(pipefd);  
    assert(n == 0);  

    cout << "pipefd[0]:" << pipefd[0] << ", pipefd[1]:" << pipefd[1] << endl;  

    // 第二步:创建子进程  
    pid_t id = fork();  
    if (id < 0)  
    {  
        perror("fork");  
        return 1;  
    }  
    // 父进程写入,子进程读取  
    // 第三步:关闭不需要的fd,形成单向通信的管道  
    else if (id != 0) // 父进程  
    {  
        close(pipefd[0]); // 关闭读端  
        int cnt = 0;  
        while (cnt < 10)  // 发送10次  
        {  
            char msg[1024];  
            snprintf(msg, sizeof(msg), "hello child! I am father, pid: %d, cnt: %d", getpid(), cnt);  
            cnt++;  
            if (write(pipefd[1], msg, strlen(msg)) < 0) {  
                perror("write");  
                break; // 处理写入错误  
            }  
            sleep(1);  
        }  
        cout << "father close write point" << endl;  
        close(pipefd[1]); // 关闭管道写端  

        pid_t rid = waitpid(id, nullptr, 0);  
        if (rid == id)  
        {  
            cout << "wait success !" << endl;  
        }  
    }  
    else // 子进程  
    {  
        close(pipefd[1]); // 关闭写端  
        char buffer[1024];  
        while (true)  
        {  
            ssize_t n = read(pipefd[0], buffer, sizeof(buffer) - 1); // 预留一个位置给字符串结尾添加'\0'  
            if (n > 0)  
            {  
                buffer[n] = '\0'; // 添加字符串结束符  
                cout << getpid() << ", father says: " << buffer << " to me!" << endl;  
            }  
            else if (n == 0)  
            {  
                cout << "write quit, me too !" << endl;  
                break; // 读到EOF  
            }  
            else  
            {  
                perror("read");  
                break; // 处理读取错误  
            }  
            sleep(1);  
        }  
        cout << "read point close" << endl;  
        close(pipefd[0]);  
        sleep(5); 
        exit(0);  
    }  
    return 0;  
}

在文件描述符的视角:

  1. 父进程创建管道

开始父进程创建管道,以读方式打开一次以写方式打开一次,打开两次;

  1. 父进程创建子进程

父进程创建子进程,子进程继承父进程的属性,也可以对管道也可以进行读和写;

  1. 父进程关闭读端、子进程关闭写端

实现单向时,比如父进程向子进程传输数据;父进程关闭读方式打开的文件,子进程关闭写方式打开的文件这样就形成了单向信道;

实际情况:

事实上,父进程以写方式打开管道文件(写端),然后再以读方式打开同一个管道文件(读端),实际上会创建两个不同的文件描述符对象,分别指向管道的写端和读端;

在Linux环境下,两个文件描述符最终会指向同一个inode,共享相同的方法集和缓冲区;

问题来了,父进程以写的方式打开文件,又以读的方式打开文件,单向通信时,父进程需要关闭读方式打开的文件,子进程关闭以写方式打开的文件,父进程关一个,子进程关一个,这样这两个文件描述对象不就没了吗?这还怎么通信?

这样的类似的问题我们之前也遇到过,如何解决呢?--引用计数的方法所以在struct file中有一个类似于引用计数的概念;一个进程关闭一个文件时,引用计数就减一;当一个文件描述对象它的引用计数为0时,它就会自动把文件关掉;

fork创建子进程时,子进程可以读取到父进程的数据,那为什么还要大费周章的这样传输数据?

子进程确实可以通过fork进程父进程的数据,但那也只是在创建时读取到,它无法读取到变化的数据如果任意一方写入修改数据,就会发生写时拷贝;

匿名管道的特性

管道的五种特性:

  1. 匿名管道,可以允许具有血缘关系的的进程之间进行进程间通信,常用于父与子;
  2. 匿名管道默认给读写端提供同步机制;
  3. 面向字节流;
  4. 管道的生命周期是随进程的;
  5. 管道是单向通信的,半双工通信的一种特殊情况;

如何去理解?

比如:在以前,在shell中执行父子进程,父子进程各自执行自己的,互不干涉;而匿名管道这里,默认存在读写同步机制;

情况一:子进程写,父进程读,但子进程写的慢;会出现管道中没有数据,此时读端必须等待,直到有数据为止;

情况二:子进程写的快,父进程读的慢;子进程也会等待父进程,子进程会写一部分,然后等父进程读取,读取之后再写;

怎么写和怎么读之间没有什么关系,并不是写一条,读一条;子进程写的快,父进程读的慢,父进程可能一次就读子进程写的几十次或者上百次的数据都读出来,"可以一次只读一个字节,也可以定义一个缓冲区一次就把缓冲区全打满;

管道的4种情况
  1. 正常情况,如果管道没有数据了,读端必须等待,直到有数据为止
  2. 正常情况,如果管道被写满了(管道大小约为64kB),写端必须等待,直到有空间为止
  3. 写端关闭,读端一直读取,当read返回值为0,表示读到文件结尾
  4. 读端关闭,写端一直写入,OS会直接杀掉写端进程,通过想目标进程发送SIGPIPE(13)信号,终止目标进程;
联系shell中的管道

shell中经常使用的管道 " | ";

比如:

bash 复制代码
sleep 1000 | sleep 2000 | sleep 3000

通过上述的测试命令,可以看到sleep之间是兄弟进程,那么它们的原理是什么?

上述测试命令:3个兄弟进程,使用两个管道;父进程先创建管道,因此两个管道对于这个三个进程都是可见的,然后再使用fork创建子进程;

对于进程1,关闭其他的文件,只保留pipefda1(写);

进程2,只保留对pipefda0(读)以及对pipefdb1(写);

进程3,只保留对pipefdb0(读);

其次就是重定向,在使用管道时,原本要输出在显示器的数据没有显示,而是作为输入,传输给了第二个进程,把进程1的 stdout 重定向为 pipefda1;(dup2(pipefda1,1]))

让进程2的输入从管道中读取,所以我们也需要对进程2进行重定向,后续的读写也是需要进行重定向,以此类推;

2.3. 命名管道

上述介绍的匿名管道通信,是用于亲缘关系之间的进程进行通信,如果没有任何关联的进程如何通信呢? --命名管道,都是在内存中作用;

命名管道简单示例:

两个终端实现通信:

终端一:

终端二:

命名管道可以从命令行上创建

命令:mkfifo filename

命名管道是一种特殊类型的文件,它在文件系统中有对应的文件节点,但实际上并不占用磁盘空间存储数据;

两进程之间要想实现通信,那么就需要看到同一份资源;

命名管道(Named Pipe)在文件系统中有对应的文件节点,因此它是存在磁盘中的;

路径是唯一的,所以可以使用路径+文件名访问,让不同的进程来访问相同的资源;

当进程访问命名管道时,操作系统会为该进程创建文件描述符,并为管道在内存中创建缓冲区。数据通过管道在进程间传输时,会经过内存缓冲区进行交换,而不会把数据加载到磁盘;

通信原理:

由此我们也可以发现命名管道的文件节点存放在磁盘中的意义:为了让没有血缘关系的进程能够找到命名管道,进而使得两进程可以访问同一个管道;

代码级建立命名管道

接口:

cpp 复制代码
int mkfifo(const char *filename,mode_t mode);

创建成功返回0,失败返回-1;

有了这些可以模拟的写一个使用命名管道通信的服务端和客户端:

服务端:

cpp 复制代码
#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <cstring>
#include <cerrno>
#include <fcntl.h>
#include <unistd.h>
#include "comm.h"
// 服务端
//using namespace std;

#define FILENAME "fifo"

int Makefifo()
{
    int n = mkfifo(FILENAME, 0666);
    if (n < 0)
    {
        std::cerr << "error: " << errno << " errorstring: " << strerror(errno) << std::endl;
        return false;
    }
    std::cout << "mkfifo success... " << std::endl;
    return true;
}
int main()
{
Start:
    // 打开管道文件
    int rfd = open(FILENAME, O_RDONLY);
    if (rfd < 0)
    {
        std::cerr << "error: " << errno << " errorstring: " << strerror(errno) << std::endl;
        if (Makefifo()) goto Start;
        else return 1;
        //return 2;
    }
    std::cout << "open fifo success...read " << std::endl;
    char buffer[1024];
    while (true)
    {
        //读取管道数据
        ssize_t s = read(rfd, buffer, sizeof(buffer) - 1); // 预留一个空间给\0
        if (s > 0)
        {
            buffer[s] = 0; //把最后设置为\0表示字符串的结尾
            std::cout << "Client say: " << buffer << std::endl;
        }
        else if (s == 0)
        {
            std::cout << "Client quit,server quit too " << std::endl;
            break;
        }
    }
    // 关闭文件
    close(rfd);
    std::cout << "close fifo success... " << std::endl;
    return 0;
}

这里需要特别注意,创建命名管道时,最好先以读方式打开;

先让读端创建好,此时读端会阻塞等待;直到写端打开管道为止;

这种行为保证了在读端尝试读取数据之前,有写端可以向管道写入数据。读端会等待写端打开,以确保有效的通信;

如果是写端先创建好,就可能出现这种情况:

写端先创建好,向管道写数据,但此时读端还未创建好,就会导致程序被OS杀死;

客户端:

cpp 复制代码
#include <iostream>
#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <cstring>
#include <cerrno>
#include <fcntl.h>
#include <unistd.h>
#include "comm.h"
// 客户端

int main()
{
    int wfd = open(FILENAME, O_WRONLY);
    if (wfd < 0)
    {
        std::cerr << "error: " << errno << " errorstring: " << strerror(errno) << std::endl;
        return 1;
    }
    std::cout << "open fifo success...write " << std::endl;
    std::string msg;
    while (true)
    {
        std::cout << "Please Enter#";
        std::getline(std::cin, msg);//读取客户端一行的输入信息

        ssize_t s = write(wfd, msg.c_str(), msg.size());//把数据写入到管道中
        if (s < 0)
        {
            std::cerr << "error: " << errno << " errorstring: " << strerror(errno) << std::endl;
            break;
        }

    }

    close(wfd);
    std::cout << "close fifo success... " << std::endl;
    return 0;
}

2.4. 小结

命名管道与匿名管道的区别:

  • 匿名管道由pipe函数创建并打开。
  • 命名管道由mkfifo函数创建,打开用open
  • FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完 成之后,它们具有相同的语义;
  • 命名管道用于没有任何关系的进程之间进行通信;
  • 匿名管道用于有亲缘关系的进程之间进行通信
  • 命名管道的文件节点会存储在磁盘中,但管道数据并不会存在磁盘中;

管道的四种情况:

  1. 正常情况,如果管道没有数据了,读端必须等待,直到有数据为止
  2. 正常情况,如果管道被写满了(管道大小约为64kB),写端必须等待,直到有空间为止
  3. 写端关闭,读端一直读取,当read返回值为0,表示读到文件结尾
  4. 读端关闭,写端一直写入,OS会直接杀掉写端进程,通过想目标进程发送SIGPIPE(13)信号,终止目标进程;

总结

以上便是本文的全部内容,希望对你有所帮助或启发,后续也将会继续介绍其他进程间通信的方式,感谢阅读!

相关推荐
FoldWinCard2 小时前
D5 Linux 网络及端口命令
linux·运维·服务器
零涂毕业设计4 小时前
毕业设计模块开发-OLED显示屏(IIC协议0.96寸)STM32 ESP32 FPGA Linux驱动免费分享
linux·stm32·单片机·嵌入式·esp32·fpga·oled
tedcloud1234 小时前
OmniRoute怎么部署?开源AI模型路由平台Linux部署教程
linux·服务器·人工智能·开源·音视频
霸道流氓气质4 小时前
KMS 密钥管理服务(Key Management Service)原理与实践
linux·服务器·数据库
SakitamaX4 小时前
LVS(Linux Virtual Server)概念详解
linux·运维·lvs
小张同学a.4 小时前
Linux系统管理
linux·运维
阿龙的工作记录5 小时前
深入理解Linux设备驱动模型
linux·嵌入式硬件·工作记录
代码村新手5 小时前
Linux的基本指令
linux·运维·服务器
hkj88085 小时前
Ubuntu 切换java版本
java·linux·ubuntu
撩得Android一次心动7 小时前
Linux编程笔记3【个人用】
linux·笔记