Bootstrap

Linux 进程间通信——管道

目录

0.前言

1. 进程间通信简介

1.1 进程间通信目的

1.2 进程间通信分类

2.匿名管道

2.1什么是管道

2.2一段匿名管道的示例代码

2.3代码解读

2.4 匿名管道运行时的四种情况

2.5 匿名管道的特性

2.6 从文件描述符和内核角度理解管道

3.命名管道

3.1命名管道的原理

3.2命名管道的应用

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

4.小结


(图像由AI生成) 

0.前言

在Linux中,进程间通信(IPC, Inter-Process Communication)是一项非常重要的技术,允许不同的进程共享数据、协调工作。IPC方式多种多样,包括管道、消息队列、共享内存、信号等。本篇博客将从基础的管道(Pipe)入手,介绍进程间通信中的重要概念和实现方式。本文主要涵盖匿名管道和命名管道的基本原理、实现及特性。

1. 进程间通信简介

1.1 进程间通信目的

在操作系统中,每个进程通常拥有独立的内存空间,相互之间无法直接访问。然而在实际开发中,多个进程之间经常需要交换数据或协调工作。进程间通信正是为了实现这种数据共享和同步而存在的,典型的应用场景包括:

  • 数据传输:在不同进程间传输数据,例如客户端与服务器之间的通信。
  • 资源共享:让多个进程访问同一资源,如共享内存。
  • 事件通知:通知其他进程发生了某种事件,例如文件变化。
  • 进程控制:允许一个进程影响另一个进程的执行。

1.2 进程间通信分类

Linux中常见的进程间通信方式主要包括:

  • 管道(Pipe):分为匿名管道和命名管道,用于单向或双向的进程间通信。
  • 消息队列(Message Queue):允许进程之间通过消息队列交换数据。
  • 共享内存(Shared Memory):多个进程可以共享一个内存段,实现高效的数据共享。
  • 信号(Signal):用于通知进程某个事件的发生。
  • 套接字(Socket):支持网络间或本地进程间通信,适用于分布式系统。

本篇主要介绍管道通信,包括匿名管道和命名管道。

2.匿名管道

2.1什么是管道

管道(Pipe)是一种经典的进程间通信(IPC)方式,在Linux系统中尤其常用。管道可以在父子进程之间建立单向的数据流通道,实现数据从一端流向另一端。匿名管道属于无名管道(Unamed Pipe),只存在于内存中,没有在文件系统中创建名字,因此只能用于具有亲缘关系的进程之间,比如父子进程或兄弟进程。

在匿名管道中,数据只能单向传输:一端写入数据(写入端),另一端读取数据(读取端)。匿名管道的创建和操作依赖于系统调用,例如 pipe()fork()read()write() 等。

2.2一段匿名管道的示例代码

下面是一段使用匿名管道的示例代码,演示了父进程和子进程之间的数据传输。

#include <iostream>
#include <unistd.h>      // 包含 pipe, fork, read, write, close 等系统调用
#include <sys/types.h>   // 包含数据类型 pid_t
#include <sys/wait.h>    // 包含 wait 函数
#include <cstring>       // 包含 strlen 函数
#include <string>

using namespace std;

int main()
{
    int pipefd[2]; // 创建一个数组,用于存储管道的两个文件描述符
                   // pipefd[0] 是读端,pipefd[1] 是写端

    // 创建管道,成功返回 0,失败返回 -1
    if (pipe(pipefd) == -1)
    {
        perror("pipe");  // 输出错误信息
        return 1;        // 返回非零值表示错误
    }

    // 使用 fork 创建子进程
    pid_t pid = fork();
    if (pid == -1)
    {
        perror("fork");  // 输出错误信息
        return 2;        // 返回非零值表示错误
    }
    else if (pid == 0) // 子进程
    {
        // 关闭子进程中的写端,因为子进程只需要从管道读取数据
        close(pipefd[1]);

        // 读取数据
        char buffer[128]; // 用于存储从管道读取的数据
        ssize_t s = read(pipefd[0], buffer, sizeof(buffer) - 1); // 从管道读取数据,返回读取的字节数
        if (s > 0)
        {
            buffer[s] = '\0'; // 确保字符串以 '\0' 结尾
            cout << "child process read data: " << buffer << endl; // 输出从父进程读取到的数据
        }
        else
        {
            perror("read"); // 如果读取失败,输出错误信息
        }

        // 关闭读端,因为子进程的读取操作已经完成
        close(pipefd[0]);
    }
    else // 父进程
    {
        // 关闭父进程中的读端,因为父进程只需要向管道写入数据
        close(pipefd[0]);

        // 写入数据
        const char* str = "i am father"; // 要写入的数据
        write(pipefd[1], str, strlen(str) + 1); // 向管道的写端写入数据,包含字符串结束符 '\0'

        // 关闭写端,因为父进程的写入操作已经完成
        close(pipefd[1]);

        // 等待子进程结束,防止产生僵尸进程
        wait(nullptr);
    }

    return 0; // 程序结束
}

代码的运行结果如下:

2.3代码解读

这段代码中,父进程通过匿名管道向子进程发送字符串数据。以下是代码中关于管道的主要接口和执行流程的详细介绍:

  1. pipe():用于创建匿名管道。pipe() 函数接收一个包含两个整数的数组 pipefd,用于存储管道的两个文件描述符:

    • pipefd[0]:管道的读取端(read end)。
    • pipefd[1]:管道的写入端(write end)。 如果 pipe() 调用成功,返回值为0;如果失败,返回 -1 并设置 errno
    int pipefd[2];
    if (pipe(pipefd) == -1) {
        perror("pipe");
        return 1;
    }
    
  2. fork():用于创建子进程。fork() 函数在调用时会创建一个新的进程(子进程)。父进程会接收到子进程的PID,而子进程则会接收到0。根据 fork() 的返回值,我们可以判断当前进程是父进程还是子进程:

    • 返回 -1:表示 fork 失败。
    • 返回 0:表示当前为子进程。
    • 返回其他值(子进程的PID):表示当前为父进程。
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 2;
    }
    
  3. close():用于关闭文件描述符。在匿名管道的使用中,通常需要关闭未使用的管道端口,以避免不必要的资源占用。在子进程中关闭写入端(pipefd[1]),在父进程中关闭读取端(pipefd[0])。

    // 子进程中关闭写入端
    close(pipefd[1]);
    
    // 父进程中关闭读取端
    close(pipefd[0]);
    
  4. write():用于向管道的写入端写入数据。在父进程中,通过 write(pipefd[1], str, strlen(str) + 1); 向管道写入字符串。write() 函数会将数据写入到 pipefd[1] 的缓冲区中,等待子进程读取。

    const char* str = "i am father";
    write(pipefd[1], str, strlen(str) + 1);
    
  5. read():用于从管道的读取端读取数据。在子进程中,通过 read(pipefd[0], buffer, sizeof(buffer) - 1); 从管道读取数据。读取的数据会存储在 buffer 缓冲区中,并加上字符串结束符 \0,以便后续输出。

    char buffer[128];
    ssize_t s = read(pipefd[0], buffer, sizeof(buffer) - 1);
    if (s > 0) {
        buffer[s] = '\0';
        cout << "child process read data: " << buffer << endl;
    }
    
  6. wait():用于等待子进程结束。在父进程中,通过调用 wait(nullptr);,父进程会等待子进程的结束,以避免僵尸进程的产生。

    wait(nullptr);
    

2.4 匿名管道运行时的四种情况

在匿名管道的使用过程中,进程的读写操作会受到管道状态的影响,可能出现以下四种典型的运行情况:

  1. 写端正常,读端正常,写端没有写入数据read 阻塞):

    • 当读端尝试读取数据时,如果管道中没有可读的数据且写端仍然正常打开,则 read 操作会阻塞(在默认阻塞模式下)。
    • 这种阻塞状态会持续到写端写入数据,读端才能读取数据并继续执行。
    • 阻塞机制确保了数据的一致性,避免读端在没有数据时返回无效内容。
  2. 写端正常,读端正常,但管道已满write 阻塞):

    • 当写端尝试写入数据时,如果管道缓冲区已满且读端仍然正常打开,write 操作会阻塞。
    • 这种阻塞状态会持续到读端从缓冲区中读取数据,释放空间后,写端才能继续写入。
    • 这种机制避免了数据覆盖,确保管道的数据完整性。
  3. 写端关闭,读端正常

    • 当写端关闭后,读端继续尝试读取数据。
    • 如果管道中还有未读取的数据,read 会继续读取剩余数据。
    • 当所有数据都被读取完时,read 会返回 0,表示写端已关闭且没有更多数据。这种返回值可以让读端知道写端已结束写入。
  4. 写端正常,读端关闭

    • 当读端关闭后,写端尝试向管道中写入数据。
    • 因为读端已关闭,写操作会触发 SIGPIPE 信号,导致写进程异常终止(默认行为)。
    • 为了避免这种情况,程序可以捕获 SIGPIPE 信号,或在写操作返回错误时检查并处理,避免直接写入数据。

2.5 匿名管道的特性

匿名管道具备以下特性:

  1. 单向通信

    • 匿名管道是单向的,即数据只能从写端流向读端。
    • 如果需要双向通信,通常需要创建两个管道(一个用于发送,另一个用于接收)。
  2. 只能在亲缘关系进程间通信

    • 匿名管道的生命周期受限于创建它的进程,通常只能在父子进程或兄弟进程之间通信。
    • 这是因为匿名管道不在文件系统中存在,不能被不相关的进程访问。
  3. 自动阻塞机制

    • 当写端未写入数据时,read() 会阻塞等待数据写入。
    • 当管道已满,write() 操作也会阻塞,直到读端读取数据为止。
    • 阻塞机制使得管道的读写操作可以更安全地进行同步。
  4. 缓冲区限制

    • 管道的缓冲区由操作系统内核管理,通常有一定的大小限制(典型为4KB或64KB,视系统配置而定)。
    • 如果缓冲区满了,write() 操作会阻塞,直到有空间释放。
  5. 生命周期管理

    • 管道的文件描述符由操作系统内核管理。
    • 当所有指向管道的文件描述符都被关闭后,管道会被自动销毁,系统释放其所占用的资源。

这些特性使得匿名管道在父子进程间的简单数据传输中非常高效,但在进程无关、双向通信或大数据传输时,匿名管道的局限性会显现。

2.6 从文件描述符和内核角度理解管道

在Linux系统中,管道实际上是由操作系统内核创建和管理的缓冲区,读端和写端分别通过文件描述符访问这个缓冲区。理解文件描述符和内核的作用有助于深入了解管道的工作机制:

  1. 文件描述符

    • 每个管道创建后会生成两个文件描述符:一个用于读(pipefd[0]),一个用于写(pipefd[1])。
    • 文件描述符是对内核对象的引用,进程通过文件描述符来访问管道缓冲区。
    • 关闭某一端的文件描述符会影响管道的行为,比如关闭写端会导致读端 read() 返回0,表示写端已关闭。
  2. 内核缓冲区

    • 管道的实际数据存储在内核缓冲区中,缓冲区的大小有限(通常是4KB或64KB)。
    • 当数据写入管道时,它会被写入到内核缓冲区;读端从缓冲区读取数据。
    • 当缓冲区满时,写操作会阻塞,直到缓冲区有空间释放;当缓冲区为空时,读操作会阻塞,直到有数据写入。
  3. 引用计数

    • 内核通过引用计数管理管道的生命周期。当管道的文件描述符被打开时,引用计数增加;当文件描述符被关闭时,引用计数减少。
    • 当所有指向管道的文件描述符都被关闭后(即引用计数为0),内核会自动释放管道的缓冲区和相关资源。
  4. 阻塞与非阻塞

    • 默认情况下,管道操作是阻塞的。如果一个进程试图读取空管道,它会被阻塞,直到有数据写入。
    • 进程也可以将文件描述符设置为非阻塞模式。在非阻塞模式下,如果读操作遇到空管道或写操作遇到满管道,操作会立即返回错误,而不会阻塞进程。
  5. 内核级同步

    • 管道的读写操作是原子的,这意味着当多个进程对同一个管道进行读写时,内核会保证数据的一致性。
    • 内核提供的同步机制保证了在并发访问时,不会出现数据混乱的情况。

3.命名管道

3.1命名管道的原理

命名管道(Named Pipe),又称为FIFO(First In First Out),是一种支持进程间通信的特殊文件类型。与匿名管道不同的是,命名管道有一个在文件系统中可见的名字,可以在没有亲缘关系的进程之间使用。

命名管道使用文件系统中的一个特殊文件来实现进程间的通信。该文件可以通过 mkfifo() 系统调用或 mknod 命令创建。由于命名管道是文件系统中的实体文件,所以具有以下特点:

  • 持久性:命名管道在文件系统中存在,文件一旦创建便可被多个进程访问,即使创建管道的进程终止,命名管道文件也依然存在,直到显式删除。
  • 进程间通信的灵活性:命名管道允许没有亲缘关系的进程之间进行通信。只要进程能够访问文件系统中指定的管道文件路径,就可以通过这个文件进行读写操作。
  • 单向通信或双向通信:默认情况下,命名管道仍然是单向通信的。要实现双向通信,可以创建两个命名管道(一个用于发送,一个用于接收)。

3.2命名管道的应用

命名管道通常用于在没有亲缘关系的进程之间传输数据。以下是命名管道的一个应用示例,其中一个进程向命名管道写入数据,另一个进程从命名管道读取数据。

示例代码

Step 1:创建命名管道并写入数据(writer.cpp

#include <iostream>
#include <fcntl.h>    // 包含 O_WRONLY
#include <sys/stat.h> // 包含 mkfifo
#include <unistd.h>   // 包含 write, close
#include <cstring>    // 包含 strlen
using namespace std;

int main() {
    const char* fifoPath = "./myfifo"; // 命名管道文件路径

    // 创建命名管道,权限为 0666 (rw-rw-rw-)
    if (mkfifo(fifoPath, 0666) == -1) {
        perror("mkfifo");
        return 1;
    }

    // 打开命名管道的写端
    int fd = open(fifoPath, O_WRONLY);
    if (fd == -1) {
        perror("open");
        return 2;
    }

    // 写入数据
    const char* message = "Hello from writer!";
    write(fd, message, strlen(message) + 1); // 写入带字符串结束符的数据
    cout << "Writer: Sent message to reader." << endl;

    // 关闭写端
    close(fd);
    return 0;
}

Step 2:读取命名管道中的数据(reader.cpp

#include <iostream>
#include <fcntl.h>    // 包含 O_RDONLY
#include <unistd.h>   // 包含 read, close
using namespace std;

int main() {
    const char* fifoPath = "./myfifo"; // 命名管道文件路径

    // 打开命名管道的读端
    int fd = open(fifoPath, O_RDONLY);
    if (fd == -1) {
        perror("open");
        return 1;
    }

    // 读取数据
    char buffer[128];
    ssize_t bytesRead = read(fd, buffer, sizeof(buffer) - 1);
    if (bytesRead > 0) {
        buffer[bytesRead] = '\0'; // 确保字符串以 '\0' 结尾
        cout << "Reader: Received message - " << buffer << endl;
    } else {
        perror("read");
    }

    // 关闭读端
    close(fd);

    // 删除命名管道文件
    unlink(fifoPath);
    return 0;
}

代码分析

  1. 创建命名管道mkfifo(fifoPath, 0666); 创建一个命名管道,权限为 0666(所有用户可读写)。管道文件路径指定为 ./myfifo,这是一个临时文件路径。如果命名管道文件已存在,mkfifo() 会返回错误。

  2. 打开命名管道open(fifoPath, O_WRONLY); 用于以只写模式打开命名管道的写端(在 writer.cpp 中),而 open(fifoPath, O_RDONLY); 用于以只读模式打开命名管道的读端(在 reader.cpp 中)。打开文件后会返回一个文件描述符 fd,用于后续的 writeread 操作。

  3. 写入数据write(fd, message, strlen(message) + 1); 将消息写入命名管道的写端。注意,这里写入了字符串结束符,以便读取端能够识别字符串的结尾。

  4. 读取数据read(fd, buffer, sizeof(buffer) - 1); 从命名管道的读端读取数据。读取到的数据会存储在 buffer 中,并加上字符串结束符 \0 以确保输出时数据完整。

  5. 删除命名管道文件unlink(fifoPath); 用于删除命名管道文件。通常由读进程执行,以确保管道文件在通信完成后被清理。

在此示例中,writer.cpp 会向 myfifo 写入一条消息,reader.cpp 则会从 myfifo 中读取该消息并输出。输出如下:

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

特性匿名管道命名管道
是否命名无命名,仅存在于内存中有名称,存储在文件系统中
适用进程仅限于父子进程或兄弟进程之间任何进程之间(只需访问管道路径)
创建方式pipe() 系统调用mkfifo() 系统调用或 mknod 命令
通信方向默认单向(可通过双管道实现双向)默认单向(可通过双管道实现双向)
文件系统中不可见可见,文件存在于文件系统中
生命周期进程结束或管道关闭后自动销毁文件存在,需显式删除
阻塞机制默认阻塞,满足管道条件时解除默认阻塞,满足管道条件时解除

      总而言之:

  • 命名:匿名管道无名称,存在于内存中,需进程关系;命名管道有路径名,存在于文件系统中。
  • 适用进程:匿名管道仅适合父子进程间通信,命名管道则能用于任意进程之间的通信。
  • 生命周期:匿名管道的生命周期与进程一致,命名管道在文件系统中需手动删除。
  • 阻塞机制:匿名和命名管道在通信时均遵循阻塞机制,确保数据的完整性和通信的同步性。

4.小结

本篇博客主要介绍了Linux中进程间通信的一种基础方式——管道,包括匿名管道和命名管道的基本原理、特性及代码实现。匿名管道适用于父子进程间的单向通信,而命名管道则可以用于任何没有亲缘关系的进程间通信。理解管道的特性和使用方法,是掌握Linux进程间通信的关键步骤。在接下来的内容中,我们将继续深入其他进程间通信方式的实现与应用。

;