Bootstrap

Linux系统:进程间通信【管道】

写在前面

在我们的日常生活中,通信的本质是信息的传递;然而,从程序员的角度来看,通信的核心实际上是数据的传输。

进程间通信(IPC)指的是不同进程之间传递数据。但进程是否能直接互相传递数据呢?答案是否定的。因为进程是相互独立的,任何数据操作都会触发写时拷贝(Copy-On-Write)机制。即使是父子进程之间,也不能直接传递数据,更不用说完全没有关联的两个进程了。

因此,要实现进程间通信,必须借助中介机制。具体来说,我们需要确保不同的进程能够访问到同一份共享资源。而这种“共享资源”通常是指操作系统通过某种方式提供的内存空间。操作系统为不同的进程提供了能够共享的内存区域,进程间的通信正是在这种内存空间中进行的。一个进程将数据写入共享内存后,另一个进程就能够读取这份数据,进而实现通信。

进程间通信的核心任务是如何通过操作系统的机制,让不同的进程能够访问到相同的资源。这些资源通常是操作系统提供的内存,而操作系统提供的资源共享方式多种多样,最典型的方式包括管道消息队列共享内存信号量等。本文将重点讨论管道

一、进程间通信

💦 进程间通信的目的

  • 数据传输:一个进程需要将其数据发送给另一个进程。
  • 资源共享:多个进程需要共享同一资源。
  • 通知事件:一个进程需要通知另一个或一组进程某个事件的发生(例如,进程结束时通知父进程)。
  • 进程控制:某些进程可能希望完全控制另一个进程的执行(例如调试进程)。此时,控制进程需要能够拦截目标进程的所有陷阱和异常,并及时了解其状态变化。

💦 进程间通信的发展

简而言之,进程间通信的发展主要有两种流派:一种是在主机内进行通信(System V),另一种则支持跨主机的进程通信POSIX(属于网络部分)。而管道是操作系统自带的通信机制。

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

💦 进程间通信的分类

  • 管道
    1. 匿名管道
    2. 命名管道
  • System V IPC
    1. 消息队列
    2. 共享内存
    3. 信号量
  • POSIX IPC
    1. 消息队列
    2. 共享内存
    3. 信号量
    4. 互斥量
    5. 条件变量
    6. 读写锁

二、管道

现实中,我们经常能见到管道,它们通常有一个入口和一个出口,通过管道传输的是水资源等物质。而在计算机中,管道用于传输的是数据资源。在这个类比中,发送数据的进程可以看作是“管道的源头”,接收数据的进程则是“管道的终点”。数据资源的传输需要发送进程和接收进程共同参与。

现实中的管道通常由钢铁等材料构成,而计算机中的管道是缓冲区,它由系统内存构建的。这块内存区域允许不同的进程访问,从而实现数据的传递。这个概念可以看作是对管道的感性理解,后续我们将更深入地探讨它在操作系统中的实现。

管道是 Unix 系统中最早的进程间通信方式之一。我们可以把两个进程之间的数据流视为管道的传输内容。管道的实现有两种主要形式:匿名管道命名管道。尽管它们的底层原理基本相同,但侧重点不同。

在这里插入图片描述
以上是一个进程,它打开了一些文件,于是在进程PCB中的struct file_struct成员中,就会指向当前进程已经打开的文件的结构体struct file
那么假设该进程创建了一个子进程:

在这里插入图片描述

由于子进程会继承父进程的PCB,同时也会继承父进程的struct files_struct,相当于同时打开了一份文件。那么现在父子进程就都可以看到同一份文件了!

以上就是管道通信的基本原理:让进程之间发生PCB的继承,从而通过PCB打开同一份文件

现在来看看管道是如何实现的吧。

💦 匿名管道 pipe

匿名管道主要用于具有亲缘关系的进程之间的通信,通常见于父子进程之间。需要注意的是,尽管父子进程之间通过管道进行通信,它们的数据并不是共享的,而是各自私有的。数据的共享仅在双方都不进行写入时发生。对于任何进程间通信,关键是保证不同进程能够访问同一份资源,而匿名管道通过继承父进程的文件描述符来实现这一点。

那么,如何确保父子进程能够访问同一份资源呢?

我们都知道文件描述符与管道密切相关。在 Unix 系统中,文件描述符是进程与文件或设备进行交互的接口。对于匿名管道来说,它通过父子进程的文件描述符共享管道的缓冲区,从而确保父子进程能够通过管道传输数据。这种机制使得管道成为进程间通信的有效工具。

在这里插入图片描述

管道的原理是首先让父进程以读写方式打开同一个文件。可以将其理解为先以读方式打开一次,再以写方式打开一次(为了易于理解,本文做了这种简化表述,实际上管道的创建有其独立的接口)。具体来说,父进程打开了一个 pipe_file 文件,分别以读和写的方式打开它。这相当于父进程通过不同的文件描述符(例如默认的 1 和 2)打开同一个文件,尽管通常只会以读方式或写方式中的一种打开文件,但在这里,我们并不按文件的使用方式来理解,而是从管道创建的原理来讲解。

这个过程就是管道的创建过程。

有了管道,接下来就可以进行进程间的通信了。父进程通过 fork() 创建子进程。这里需要强调的是,子进程是一个独立的进程,拥有自己的地址空间、页表、文件描述符表。子进程与父进程共享代码段,但数据段是私有的。尽管如此,子进程的文件描述符表会与父进程相同,最重要的是,父进程打开的 pipe_file 文件在子进程中对应的文件描述符(例如 3 和 4)也指向同一份文件。

这就是进程间通信的第一步:确保父子进程可以访问同一份资源。在管道的情况下,这份资源就是操作系统提供的内存区域。父进程通过 3 号或 4 号文件描述符向管道缓冲区写入数据,子进程同样可以通过相同的文件描述符读取或写入数据。

最后要说明的是,尽管父子进程的地址空间、文件描述符表等数据结构是独立的,它们的文件描述符内容却是相同的。这意味着父子进程可以共享同一份文件,而管道本质上也是一种文件,只不过它并不会在磁盘上持久保存。
在这里插入图片描述

  • 管道只能进行单向数据通信

这就意味着,要么是父进程写,子进程读,要么是子进程写,父进程读。总之一个管道只能进行单身数据通信,若要双向通信,就只能建立多个管道。

如果想让父进程写,子进程读,就关闭父进程的读,子进程的写;如果想让子进程写,父进程读,就关闭子进程的读,父进程的写;父子进程关闭不需要的文件描述符,这样就可以达到构建单向通信信道的目的。

在这里插入图片描述

构建单向信道时,父子进程为什么最终要关闭一个文件描述符?为什么曾经还要打开?

根本原因在于,如果父进程只以读或只以写的方式打开文件,那么在 fork() 后,子进程只会拥有相应的读或写文件描述符。这样,父子进程要么都只能读,要么都只能写,这就无法实现管道的单向通信。

另外,父子进程之间需要灵活地控制谁负责读,谁负责写。因此,无论是父进程写,子进程读,还是子进程写,父进程读,完全取决于具体的应用场景。为了确保进程间的单向通信,父子进程通常需要关闭其中一个文件描述符。

对应的一组写和读文件描述符可以不关闭吗?

理论上也没问题,父子进程可以同时保持对应的读写文件描述符不关闭,从而继续维持单向通信。但为了遵循管道的设计原则,通常建议关闭不必要的文件描述符。一方面,这样能明确地体现管道的单向通信特性;另一方面,它可以防止因为文件描述符泄露或误操作带来的潜在问题。当然,不同的操作系统对管道的支持和实现可能存在差异,因此最好遵循标准规范。

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

管道的设计与文件系统的实现密切相关。如果管道能够支持双向通信,操作系统的设计者早就会这么做。然而,设计双向通信管道存在一定的技术难题。根本问题在于文件的读写位置:一个文件的读写位置只能是一个。如果想要实现双向通信,双方必须都能够进行读写操作,这就意味着需要两个独立的读写位置。因此,实现双向通信管道需要对文件系统进行改动,而这种设计本身并不必要。为了解决这个问题,通常的做法是创建两个独立的管道:一个用于数据的单向传输。

为什么并非所有文件都可以当作管道使用?

管道虽然本质上是一种文件,但并非所有类型的文件都能用作管道。例如,当我们通过 touch log.txt 创建一个文件时,它是一个普通的文件。如果两个没有关联的进程分别以写和读的方式打开该文件,它们的通信就会非常复杂。尽管它们可以看到同一个文件,写进程需要将数据刷新到磁盘,读进程再从磁盘读取,这种方式显然不高效,也不符合管道设计的初衷。管道通信的目标是提供高效、稳定且成熟的通信方案,而这正是操作系统通过管道设计来实现的。

  • pipe
    在这里插入图片描述

pipe 是我们要认识的一个创建匿名管道的系统调用接口。
pipe 的参数是一个具有 2 个参数的数组,大家都知道数组传参会降维成指针。这里 pipe 的参数是一个输出型参数,这种参数说白了就是我不想给你传入什么,而是想调用你然后再拿回什么。我们可以通过这个参数拿到打开的管道文件的 fd,这个数组有两个参数,这意味着它会拿到 2 个 fd —— readwrite。不妨思考一下它在底层无非就是让父进程以读方式和以写方式分别打开一个文件,然后得到两个文件描述符。据经验判断,我们默认会拿到的 fd 是 3 和 4。

1.父进程创建管道
在这里插入图片描述

#include <stdio.h>
#include <unistd.h>

int main()
{
    int pipe_fd[2] = {0};

    if(pipe(pipe_fd) < 0)
    {
        perror("pipe");
        return 1;
    }

    printf("%d, %d\n", pipe_fd[0], pipe_fd[1]);

    return 0;
}

结果:

gcc mypipe.c -o mypipe
./mypipe
3, 4
  1. 父进程fork子进程
    在这里插入图片描述
pid_t id = fork();
if(id < 0)
{
    perror("fork");
    return 2;
}
else if(id == 0)
{
    // child
}
else
{
    // father
}

3.子进程写,父进程读,通常 fd[0] 对应 read,fd[1] 对应 write,子进程关闭 fd[0],父进程关闭 fd[1],再让父进程等待子进程

在这里插入图片描述

4.父子进程之间实现通信

pid_t id = fork();
if(id < 0)
{
    perror("fork");
    return 2;
}
else if(id == 0)
{
    // child - write
    close(pipe_fd[0]);
    const char* msg = "Hello father, I am child";
    int count = 5;
    while(count)
    {
        write(pipe_fd[1], msg, strlen(msg));
        sleep(1);
        count--;
    }
    close(pipe_fd[1]);
    exit(0);
}
else
{
    // father - read
    close(pipe_fd[1]);
    char buffer[64];
    while(1)
    {
        buffer[0] = 0;
        ssize_t size = read(pipe_fd[0], buffer, sizeof(buffer) - 1);
        if(size > 0)
        {
            buffer[size] = 0;
            printf("father get message from child# %s\n", buffer);
        }
        else if(size == 0)
        {
            printf("pipe file close, child quit!\n");
            break;
        }
        else
        {
            // TODO - err
            break;
        }
    }
}

int status = 0;
if(waitpid(id, &status, 0) > 0)
{
    printf("child quit, wait success!\n");
}

close(pipe_fd[0]);

管道的读写机制

当管道内没有数据时,读端会一直处于阻塞等待状态,直到管道内出现数据。

例如我们封装一个reader函数和一个writer函数如下:

void reader(int rfd)
{
    char buffer[1024];
    while (true)
    {
        read(rfd, buffer, sizeof(buffer));
        cout << "get massage: " << buffer << endl;
    }
}
void writer(int wfd)
{
    char buffer[128];
    int count = 0;
    while(true)
    {
        snprintf(buffer, sizeof(buffer), "pid = %d, count = %d", getpid(), count++);
        write(wfd, buffer, strlen(buffer));

        sleep(1);
    }
}

主函数

int main()
{
    int pipefd[2];
    pipe(pipefd);

    int rfd = pipefd[0];
    int wfd = pipefd[1];

    pid_t id = fork();

    if(id == 0)
    {
        //子进程 -写
        close(rfd);
        writer(wfd);

        exit(0);
    }

    close(wfd);
    reader(rfd);
	//父进程 - 读并等待
    waitpid(id, NULL, 0);

    return 0;
}

在这里插入图片描述

你会发现,其实我们的读端reader函数,并没有像sleep这样的函数,但是在输出语句时,仍然是一秒输出一句,这是因为read在读取管道数据时候,如果管道没有数据,就会进行阻塞等待,而管道中的数据来源于写端,每隔一秒写端才会写一次,所以读端也就一秒才能读一次了。

同理,写端一直写,当管道被写满的时候,写端就会阻塞等待

;