【Linux进程】进程间的通信

发布于:2024-12-21 ⋅ 阅读:(31) ⋅ 点赞:(0)

目录

1. 进程间通信

1.1 进程间通信的目的

2. 管道

 2.1 什么是管道

 2.2. 匿名管道

匿名管道的特性

 管道的4种情况

 联系shell中的管道

2.3. 命名管道

 代码级建立命名管道

 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、方法集(虚拟文件系统)、文件缓冲区;管道也是如此;只不过他不会把缓冲区的数据刷新到磁盘中;

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

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

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

 示例:

#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. 父进程创建管道

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

 2. 父进程创建子进程

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

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

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

实际情况:

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

        在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中经常使用的管道 ” | “;

比如:

sleep 1000 | sleep 2000 | sleep 3000

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

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

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

进程2,只保留对pipefda[0](读)以及对pipefdb[1](写);

进程3,只保留对pipefdb[0](读);

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

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

2.3. 命名管道

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

命名管道简单示例:

两个终端实现通信:

终端一:

 终端二:

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

命令:mkfifo filename 

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

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

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

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

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

 通信原理:

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

 代码级建立命名管道

 接口:

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

 

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

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

服务端:

#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杀死;

客户端:

#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)信号,终止目标进程;

总结

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