MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Linux C语言命名管道(FIFO)通信原理

2022-10-226.0k 阅读

一、Linux 中的管道概念

在 Linux 操作系统中,管道是一种基本的进程间通信(IPC, Inter - Process Communication)机制。它允许在不同进程之间传递数据,就像数据通过一根管道从一个进程流向另一个进程。管道分为两种类型:无名管道(匿名管道)和命名管道(FIFO)。

无名管道是一种临时的通信机制,它只能在具有亲缘关系(如父子进程)的进程之间使用。当创建无名管道时,系统在内核中创建一个管道缓冲区,该缓冲区有固定的大小(通常为 4096 字节)。管道有读端和写端,数据从写端写入,从读端读出。例如,在父子进程场景中,父进程可以创建一个无名管道,然后 fork 出子进程。父子进程通过管道的不同端进行数据传递,父进程可以关闭读端,子进程关闭写端,这样就建立了单向的数据流动。

命名管道(FIFO)则克服了无名管道只能在亲缘进程间通信的限制。FIFO 有一个对应的文件名存在于文件系统中,任何进程只要有合适的权限,都可以通过这个文件名来访问该命名管道,从而实现进程间通信。FIFO 同样具有读端和写端,数据的读写操作与无名管道类似,但它的生命周期独立于进程,只要不被删除,它就一直存在于文件系统中。

二、命名管道(FIFO)的原理

  1. 文件系统中的表示
    • FIFO 在文件系统中以一种特殊文件的形式存在。当使用 mkfifo 命令或者 mkfifo() 系统调用创建一个 FIFO 时,会在文件系统中创建一个对应的节点。这个节点的类型与普通文件、目录等不同,它是一种管道文件,其文件类型标识在文件系统的元数据中有所体现。例如,在 Linux 系统中,可以使用 ls -l 命令查看文件属性,FIFO 文件的类型标识为 p(代表管道)。例如:
$ mkfifo myfifo
$ ls -l myfifo
prw - r - w - r - w - 1 user user 0 Jun  1 14:00 myfifo

这里的 prw - r - w - r - w - 中,第一个字符 p 就表明 myfifo 是一个 FIFO 文件。 2. 内核中的实现

  • 在内核中,FIFO 是基于内存缓冲区实现的。当一个进程向 FIFO 写数据时,数据首先被拷贝到内核的缓冲区中。如果缓冲区已满,写操作可能会阻塞(取决于是否为非阻塞模式)。当一个进程从 FIFO 读数据时,数据从内核缓冲区中被拷贝到用户空间。
  • 内核通过维护一些数据结构来管理 FIFO 的读写操作。例如,它会记录当前 FIFO 缓冲区中的数据量、读指针和写指针的位置等信息。读指针指示下一个要读取的数据位置,写指针指示下一个要写入数据的位置。当读取数据时,读指针向前移动;当写入数据时,写指针向前移动。如果写指针追上读指针,说明缓冲区已满;如果读指针追上写指针,说明缓冲区为空。
  1. 同步与阻塞机制
    • 阻塞写操作:当一个进程以阻塞方式向 FIFO 写数据时,如果 FIFO 缓冲区已满,写操作会被挂起,即进程进入睡眠状态,直到有足够的空间写入数据。例如,假设 FIFO 缓冲区大小为 4096 字节,当已经写入 4096 字节数据时,后续的写操作会阻塞,直到有其他进程从 FIFO 中读取数据,释放出空间。
    • 非阻塞写操作:进程也可以以非阻塞方式打开 FIFO 进行写操作。在这种情况下,如果 FIFO 缓冲区已满,写操作不会阻塞,而是立即返回一个错误(如 EAGAINEWOULDBLOCK),进程可以继续执行其他任务,而不是等待缓冲区有空间。
    • 阻塞读操作:以阻塞方式从 FIFO 读数据时,如果 FIFO 缓冲区为空,读操作会阻塞,进程进入睡眠状态,直到有数据写入 FIFO。比如,在一个进程启动后立即从 FIFO 读数据,而此时还没有其他进程向 FIFO 写入数据,该读操作会一直阻塞,直到有数据到来。
    • 非阻塞读操作:非阻塞读操作时,如果 FIFO 缓冲区为空,读操作不会阻塞,而是立即返回 0(表示读到了文件末尾,因为 FIFO 没有真正的文件末尾概念,这里只是一种约定)或者返回一个错误(同样如 EAGAINEWOULDBLOCK)。

三、C 语言中使用命名管道(FIFO)的系统调用

  1. 创建命名管道:mkfifo()
    • mkfifo() 系统调用用于创建一个 FIFO 文件。其函数原型如下:
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
  • 参数说明
    • pathname:指定要创建的 FIFO 文件的路径名。这可以是一个绝对路径,如 /tmp/myfifo,也可以是相对路径,如 myfifo(相对当前工作目录)。
    • mode:指定 FIFO 文件的权限,与 open() 系统调用中的权限设置类似。例如,0666 表示所有者、组和其他用户都有读写权限,0755 表示所有者有读、写、执行权限,组和其他用户有读和执行权限。
  • 返回值:成功时返回 0,失败时返回 -1,并设置 errno 以指示错误原因。常见的错误原因包括 EEXIST(文件已存在)、ENOENT(路径中的目录不存在)等。
  1. 打开命名管道:open()
    • 创建好 FIFO 后,需要使用 open() 系统调用打开它进行读写操作。open() 函数原型为:
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
  • 参数说明
    • pathname:要打开的 FIFO 文件的路径名。
    • flags:指定打开文件的方式,常见的有 O_RDONLY(只读)、O_WRONLY(只写)、O_RDWR(读写)。还可以与其他标志位进行按位或操作,如 O_NONBLOCK 用于设置非阻塞模式。例如,O_WRONLY | O_NONBLOCK 表示以只写且非阻塞方式打开 FIFO。
    • mode:当以创建新文件的方式打开(如 O_CREAT 标志被设置)时,用于指定文件的权限。
  • 返回值:成功时返回一个文件描述符,该描述符可用于后续的读写操作;失败时返回 -1,并设置 errno 指示错误。
  1. 读写命名管道:read() 和 write()
    • read():用于从打开的 FIFO 中读取数据。函数原型为:
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
  • 参数说明
    • fd:FIFO 文件的文件描述符,由 open() 系统调用返回。
    • buf:指向用于存储读取数据的缓冲区的指针。
    • count:指定要读取的最大字节数。
  • 返回值:成功时返回实际读取的字节数;如果读到文件末尾(在 FIFO 中表示没有更多数据)返回 0;失败时返回 -1,并设置 errno
  • write():用于向打开的 FIFO 中写入数据。函数原型为:
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
  • 参数说明
    • fd:FIFO 文件的文件描述符。
    • buf:指向要写入数据的缓冲区的指针。
    • count:指定要写入的字节数。
  • 返回值:成功时返回实际写入的字节数;失败时返回 -1,并设置 errno
  1. 关闭命名管道:close()
    • 当完成对 FIFO 的读写操作后,需要使用 close() 系统调用关闭文件描述符,释放相关资源。函数原型为:
#include <unistd.h>
int close(int fd);
  • 参数说明fd 是要关闭的 FIFO 文件的文件描述符。
  • 返回值:成功时返回 0,失败时返回 -1,并设置 errno

四、C 语言实现命名管道(FIFO)通信的代码示例

  1. 简单的单向通信示例
    • 以下代码展示了如何使用命名管道实现一个简单的单向通信,即一个进程向 FIFO 写入数据,另一个进程从 FIFO 读取数据。
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <string.h>

#define FIFO_NAME "myfifo"

int main() {
    int fd;
    char buf[100];
    // 创建命名管道
    if (mkfifo(FIFO_NAME, 0666) == -1) {
        perror("mkfifo");
        exit(1);
    }
    // 以只读方式打开命名管道
    fd = open(FIFO_NAME, O_RDONLY);
    if (fd == -1) {
        perror("open");
        exit(1);
    }
    // 从命名管道读取数据
    ssize_t bytes_read = read(fd, buf, sizeof(buf) - 1);
    if (bytes_read == -1) {
        perror("read");
        close(fd);
        exit(1);
    }
    buf[bytes_read] = '\0';
    printf("Read from FIFO: %s\n", buf);
    // 关闭命名管道
    close(fd);
    // 删除命名管道文件
    unlink(FIFO_NAME);
    return 0;
}
  • 这是读取端代码。我们首先使用 mkfifo() 创建一个名为 myfifo 的命名管道,权限设置为 0666。然后以只读方式打开该 FIFO,使用 read() 从 FIFO 读取数据,并将读取到的数据打印出来。最后关闭文件描述符并删除 FIFO 文件。
  • 对应的写入端代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <string.h>

#define FIFO_NAME "myfifo"

int main() {
    int fd;
    const char *msg = "Hello, FIFO!";
    // 以只写方式打开命名管道
    fd = open(FIFO_NAME, O_WRONLY);
    if (fd == -1) {
        perror("open");
        exit(1);
    }
    // 向命名管道写入数据
    ssize_t bytes_written = write(fd, msg, strlen(msg));
    if (bytes_written == -1) {
        perror("write");
        close(fd);
        exit(1);
    }
    // 关闭命名管道
    close(fd);
    return 0;
}
  • 在写入端,我们以只写方式打开名为 myfifo 的 FIFO,然后使用 write() 将字符串 "Hello, FIFO!" 写入 FIFO,最后关闭文件描述符。
  1. 双向通信示例
    • 双向通信需要两个进程分别作为读端和写端,并且能够同时进行读写操作。以下代码展示了如何实现双向通信:
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <string.h>
#include <pthread.h>

#define FIFO_NAME "myfifo"

void *write_to_fifo(void *arg) {
    int fd = *((int *)arg);
    const char *msg = "Message from writer";
    ssize_t bytes_written = write(fd, msg, strlen(msg));
    if (bytes_written == -1) {
        perror("write");
    }
    return NULL;
}

void *read_from_fifo(void *arg) {
    int fd = *((int *)arg);
    char buf[100];
    ssize_t bytes_read = read(fd, buf, sizeof(buf) - 1);
    if (bytes_read == -1) {
        perror("read");
    } else {
        buf[bytes_read] = '\0';
        printf("Read from FIFO: %s\n", buf);
    }
    return NULL;
}

int main() {
    int fd;
    pthread_t write_thread, read_thread;
    // 创建命名管道
    if (mkfifo(FIFO_NAME, 0666) == -1) {
        perror("mkfifo");
        exit(1);
    }
    // 以读写方式打开命名管道
    fd = open(FIFO_NAME, O_RDWR);
    if (fd == -1) {
        perror("open");
        exit(1);
    }
    // 创建写线程
    if (pthread_create(&write_thread, NULL, write_to_fifo, &fd) != 0) {
        perror("pthread_create (write)");
        close(fd);
        exit(1);
    }
    // 创建读线程
    if (pthread_create(&read_thread, NULL, read_from_fifo, &fd) != 0) {
        perror("pthread_create (read)");
        pthread_cancel(write_thread);
        close(fd);
        exit(1);
    }
    // 等待线程结束
    pthread_join(write_thread, NULL);
    pthread_join(read_thread, NULL);
    // 关闭命名管道
    close(fd);
    // 删除命名管道文件
    unlink(FIFO_NAME);
    return 0;
}
  • 在这个示例中,我们使用 POSIX 线程(pthread)来实现双向通信。write_to_fifo 函数负责向 FIFO 写入数据,read_from_fifo 函数负责从 FIFO 读取数据。在 main 函数中,我们创建一个命名管道并以读写方式打开它。然后创建写线程和读线程,分别执行写入和读取操作。最后等待两个线程结束,关闭文件描述符并删除 FIFO 文件。

五、命名管道(FIFO)通信的应用场景

  1. 日志系统
    • 在一个大型软件系统中,可能有多个模块会产生日志信息。可以使用命名管道将各个模块的日志信息收集到一个日志处理进程中。每个产生日志的模块作为写端,将日志信息写入命名管道,而日志处理进程作为读端,从命名管道中读取日志并进行统一的处理,如存储到文件、进行分析等。这样可以实现日志的集中管理,并且各个模块与日志处理进程之间解耦,便于系统的维护和扩展。
  2. 实时数据传输
    • 在一些实时应用场景中,如传感器数据采集。传感器设备驱动程序可以将采集到的数据通过命名管道实时传输给数据处理应用程序。传感器驱动程序作为写端,不断将新采集的数据写入命名管道,而数据处理应用程序作为读端,从命名管道中读取数据并进行实时分析、显示等操作。由于命名管道的特性,数据可以及时地从传感器传输到处理程序,满足实时性要求。
  3. 进程间控制信号传递
    • 不同进程之间可能需要传递控制信号,例如一个主进程需要通知子进程进行某些操作(如重启、停止等)。可以使用命名管道来传递这些控制信号。主进程作为写端,向命名管道写入特定的控制指令(如特定的字符串或字节序列),子进程作为读端,从命名管道中读取指令并根据指令执行相应的操作。这种方式相比于其他信号机制,具有更好的灵活性和扩展性,可以传递更复杂的控制信息。

六、命名管道(FIFO)通信的注意事项

  1. 权限问题
    • 由于 FIFO 是文件系统中的一种特殊文件,文件权限设置非常重要。如果权限设置不当,可能导致进程无法打开 FIFO 进行读写操作。例如,如果 FIFO 文件的权限设置为 0600,只有文件所有者有读写权限,其他用户进程将无法访问。在创建 FIFO 时,要根据实际需求合理设置权限,并且在运行相关进程时,确保进程具有合适的权限来访问 FIFO。
  2. 缓冲区大小限制
    • 虽然内核中的 FIFO 缓冲区大小通常是 4096 字节,但不同的系统可能会有所不同。在进行大量数据传输时,需要考虑缓冲区大小的限制。如果写入的数据量超过缓冲区大小,写操作可能会阻塞(在阻塞模式下)。为了避免这种情况,可以采用分块写入的方式,或者调整系统参数来增大 FIFO 缓冲区大小(但这可能需要管理员权限并且对系统有一定影响)。
  3. 读写同步
    • 在进行双向通信或者多个进程同时读写 FIFO 时,需要注意读写同步问题。例如,如果多个写进程同时向 FIFO 写入数据,可能会导致数据混乱。可以通过加锁机制(如使用文件锁)来保证同一时间只有一个进程进行写操作。同样,在读取数据时,也要确保数据的完整性,避免在数据还未完全写入时就开始读取。
  4. 错误处理
    • 在使用命名管道的各个系统调用(如 mkfifo()open()read()write() 等)时,都可能发生错误。必须对这些系统调用的返回值进行检查,并根据错误类型进行适当的处理。例如,如果 mkfifo() 返回 -1,通过 errno 判断错误原因,如果是 EEXIST,可以选择直接打开已存在的 FIFO 而不是再次尝试创建;如果是其他错误,如 ENOENT,则需要检查路径是否正确等。

七、与其他 IPC 机制的比较

  1. 与无名管道的比较
    • 通信范围:无名管道只能在具有亲缘关系的进程(如父子进程)之间通信,而命名管道可以在任意进程之间通信。这使得命名管道的应用场景更加广泛,能够满足不同进程间的通信需求,而无名管道主要适用于进程创建过程中的特定场景,如父进程与子进程协作。
    • 生命周期:无名管道的生命周期与创建它的进程相关,当创建无名管道的进程终止时,无名管道也随之消失。而命名管道以文件的形式存在于文件系统中,只要不被删除,它就一直存在,其生命周期独立于进程。这意味着命名管道可以被不同时间启动的进程使用,而无名管道在进程结束后就无法再使用。
  2. 与消息队列的比较
    • 数据格式:消息队列允许发送和接收具有特定格式的消息,每个消息有一个消息类型,接收方可以根据消息类型有选择地接收消息。而命名管道以字节流的形式传输数据,没有消息类型的概念,接收方只能按顺序读取数据。因此,如果应用场景需要根据消息类型进行筛选接收,消息队列更合适;如果只是简单的数据传输,命名管道更简洁。
    • 同步机制:命名管道的同步机制相对简单,主要基于阻塞和非阻塞模式。而消息队列在消息的发送和接收上有更复杂的同步机制,例如可以设置消息队列的最大消息数、最大消息长度等,并且可以通过信号量等机制来实现更精细的同步控制。但这也使得消息队列的使用相对复杂,而命名管道在简单的数据传输场景中更容易实现。
  3. 与共享内存的比较
    • 数据传输方式:共享内存是通过在多个进程之间共享同一块内存区域来实现数据通信,数据直接在共享内存中读写,不需要像命名管道那样进行内核空间与用户空间的数据拷贝。因此,共享内存的通信效率非常高,适合大量数据的快速传输。而命名管道每次读写都需要进行数据拷贝,在性能上相对共享内存较低。
    • 同步难度:共享内存本身没有提供同步机制,需要进程自己实现同步,如使用信号量、互斥锁等,同步实现难度较大。而命名管道基于其阻塞和非阻塞模式以及内核的缓冲区管理,在一定程度上提供了简单的同步机制,相对容易实现同步控制,但在复杂的同步场景下可能不如共享内存灵活。

通过对命名管道(FIFO)通信原理、系统调用、代码示例、应用场景、注意事项以及与其他 IPC 机制的比较,我们对 Linux C 语言中命名管道的使用有了较为全面深入的了解。在实际应用开发中,可以根据具体需求选择合适的进程间通信机制,充分发挥它们的优势,实现高效、稳定的系统设计。