对于Linux:进程间通信IPC(命名管道)的解析

开篇介绍:

hello 大家,那么在上一篇博客中,我们详细の解析了IPC中的匿名管道这一种进程间的通信方式,那么我们知道,匿名管道是有很大的局限性的,它只适合于有血缘关系的两个进程,毋庸置疑,这就很受限。

所以毋庸置疑我们肯定要学习能让两个不相关的进程实现通信的工具,那么这种工具是肯定有的,我们本篇博客要学习的就是其中一种------命名管道。

OK,话不多说,我们接下来就开始详细解析它吧,但是在正式讲解之前,我想先说一点,那就是其实这一部分内容很简单,所以,大家懂的。

What And Why:

命名管道(Named Pipe,也称为 FIFO)是一种专门用于进程间通信(IPC)的特殊机制。简单说,它就像一个 "带地址的管道",能让两个完全不相关的进程(比如你电脑里的记事本和浏览器,它们没有任何亲缘关系)通过一个 "特殊文件" 来传递数据 ------ 一个进程往这个文件里写内容,另一个进程从里面读内容,就像通过一个约定好的 "中转站" 交换信息。

核心特征:

1. 存在形式:是 "文件" 但不存数据

命名管道会以一个实实在在的文件形式出现在你的文件系统里。你可以用ls命令在文件夹里看到它,文件名就是你给它起的名字(比如myfifo),文件类型标识是p(也就是 "pipe" 的缩写,代表这是管道文件)。

但它和普通文件不一样:普通文件会把数据存到硬盘上,即使电脑重启数据也还在;而命名管道里的数据只在内存的 "临时缓冲区" 里放着,进程读完就会被清除,不会占用硬盘空间。就像一个 "临时记事本",写完看完就擦掉,只为当下的通信服务。

2. 跨进程性:打破 "亲缘限制"

我们之前听说过 "匿名管道",它只能在有亲缘关系的进程间用(比如爸爸进程和它创建的儿子进程),因为匿名管道没有 "地址",非亲缘进程找不到它。

而命名管道有明确的 "地址"(就是它在文件系统里的路径,比如/home/user/myfifo),不管两个进程有没有关系,只要知道这个路径,就能找到并使用它。比如你打开的微信和 QQ,原本互不相干,但通过同一个命名管道,就能传递数据。

3. 通信方式:单向 "数据流"

命名管道的通信是 "单向" 的,就像一条单行道:必须有一个进程专门负责 "写"(写端),另一个专门负责 "读"(读端)。写端往管道里塞数据,数据会顺着管道流到读端,读端再把数据取出来。

不能让一个进程又读又写,也不能两个进程同时写或同时读 ------ 否则会乱套(比如两个进程同时写,数据会混在一起)。如果需要双向通信(比如 A 给 B 发消息,B 也给 A 发),就得用两个命名管道:一个让 A 写、B 读,另一个让 B 写、A 读。

关键特性:

1. 创建方式:两种方法 "建管道"

创建命名管道很简单,有两种方式:

  • 命令行创建:直接在终端输入mkfifo 路径,比如mkfifo ./myfifo,就能在当前文件夹创建一个叫myfifo的命名管道。
  • 代码创建:在程序里用mkfifo()函数,比如mkfifo("./myfifo", 0666),第一个参数是路径,第二个参数是权限(0666 表示允许读写)。

创建后,这个管道还不能直接用,得用open()函数 "打开" 它:读端用open("./myfifo", O_RDONLY)(表示 "我要读数据"),写端用open("./myfifo", O_WRONLY)(表示 "我要写数据")。

2. 阻塞规则:"等对方准备好了再开始"

默认情况下,命名管道有个 "等待机制":

  • 如果读端先打开管道(调用open),它会 "卡住"(阻塞),一直等到有写端打开管道才会继续工作 ------ 就像你去取快递,快递柜没开门(写端没准备好),你得等着快递员把东西放进去(写端打开)。
  • 反过来,如果写端先打开管道,它也会 "卡住",直到读端打开管道才继续 ------ 就像你去寄快递,快递柜没开门(读端没准备好),你得等着收件人准备好接收(读端打开)。

这种机制能避免 "对着空气读写":如果写端没等读端就开始写,数据可能没人收;读端没等写端就开始读,也读不到东西。有了等待机制,就能保证通信双方 "都准备好" 再开始。

3. 操作接口:用 "操作文件的方法" 操作管道

使用命名管道时,读写数据的方式和操作普通文件完全一样:

  • 写端用write()函数往管道里写数据,比如write(fd, "hello", 5),就是把 "hello" 这 5 个字节写到管道里。
  • 读端用read()函数从管道里读数据,比如read(fd, buf, 1024),就是从管道里最多读 1024 个字节到buf缓冲区里。
  • 用完后,用close()函数关闭管道,就像关闭文件一样。

这意味着你不需要学新的接口,只要会操作文件,就能用命名管道,非常简单。

总的来说,命名管道就是一个 "带地址的内存缓冲区",以文件形式存在,能让任意进程通过它单向传递数据,操作简单且自带等待机制,专门解决 "不相关进程间通信" 的问题。

命名管道的创建、使用、删除:

一、命名管道的创建:给进程建个 "专属通信站"

创建命名管道,本质是在文件系统中 "注册" 一个特殊的 "通信节点"------ 它以文件形式存在,却不存储数据,仅作为进程间传递消息的 "地址标识"。就像在小区里搭建一个 "临时信箱",明确地址(路径)和使用规则(权限),让所有知道地址的进程都能通过它传递信息。

1. 命令行创建:最简单的 "一键建站"

mkfifo命令可直接创建命名管道,适合快速测试或手动操作,格式为:

复制代码
mkfifo [管道的路径]
具体操作与内核行为:

以创建./myfifo为例,执行mkfifo ./myfifo后,内核会完成三件核心工作:

  • 分配 inode :在文件系统中生成一个新的索引节点(inode),类型标记为S_IFIFO(管道类型),与普通文件的S_IFREG(常规文件)区分。
  • 关联目录项:在当前目录的目录项中添加一条记录,将文件名 "myfifo" 与新 inode 绑定,确保进程能通过路径找到它。
  • 设置权限 :根据系统默认的umask(权限掩码)计算实际权限(默认mode为 0666,减去umask后得到最终权限)。

执行后用ls -l查看,会看到类似输出:

复制代码
prw-r--r-- 1 user user 0 11月 20 10:30 myfifo
  • 首字符p表示 "管道文件";rw-r--r--是实际权限;Size: 0说明不占用磁盘空间(数据仅在内存缓冲区临时存储)。
权限设置的细节与注意点:
  • umask的影响 :系统默认umask通常为 0002(普通用户)或 0022(root 用户),会 "屏蔽"mode中的对应权限。例如:

    • mode=0666 + umask=0002 → 实际权限为0666 & ~0002 = 0664(其他用户无写权限)。

    • mode=0666 + umask=0022 → 实际权限为0666 & ~0022 = 0644(组用户和其他用户仅可读)。

    • 若想让mode完全生效(如测试时需要所有人可读写),需先执行umask 0清除掩码:

      复制代码
      umask 0  # 临时清除权限掩码(仅对当前终端有效)
      mkfifo ./myfifo  # 此时权限为rw-rw-rw-(0666)
  • 安全风险 :避免使用0666权限在生产环境中创建管道 ------ 这会允许任何用户读写,可能导致敏感数据泄露或恶意写入。建议根据实际需求设置权限(如0600仅允许所有者访问)。

2. 代码创建:程序自动 "建站" 更灵活

在代码中用mkfifo函数创建管道,适合需要动态管理的场景(如程序启动时自动创建,退出时自动删除)。函数原型:

复制代码
#include <sys/stat.h>  // 必须包含的头文件
int mkfifo(const char *pathname, mode_t mode);
参数详解与注意点:
  • pathname(路径):

    • 支持绝对路径(如"/tmp/myfifo")和相对路径(如"./myfifo")。相对路径以当前进程的工作目录为基准(可通过getcwd函数获取),若进程切换工作目录,相对路径可能失效,建议优先使用绝对路径。
    • 路径中的目录必须已存在(如创建"./dir/myfifo"时,dir文件夹必须存在),否则返回ENOENT错误。可通过mkdir函数提前创建目录(加-p参数递归创建,代码中需用mkdir函数配合循环实现)。
  • mode(权限):

    • 用八进制数表示,如0666(所有人可读写)、0600(仅所有者可读写)。注意:代码中必须加前缀0(表示八进制),否则会被解析为十进制(如666十进制≠0666八进制)。
带完整错误处理的代码示例(含注意点注释):
复制代码
#include <stdio.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>  // 包含access、unlink函数

int main() {
    // 注意:优先使用绝对路径,避免工作目录变化导致路径失效
    const char *fifo_path = "/tmp/myfifo";  // 绝对路径,更可靠

    // 步骤1:检查管道是否已存在(避免EEXIST错误)
    if (access(fifo_path, F_OK) == 0) {  // F_OK:检查文件是否存在
        printf("注意:管道已存在,尝试删除旧管道...\n");
        if (unlink(fifo_path) == -1) {  // 删除旧管道
            perror("删除旧管道失败(可能被其他进程占用)");
            exit(1);
        }
    }

    // 步骤2:清除权限掩码,确保mode设置生效
    // 注意:umask仅影响当前进程,不会改变系统全局设置
    umask(0);

    // 步骤3:创建管道,权限0600(仅所有者可读写,更安全)
    int ret = mkfifo(fifo_path, 0600);
    if (ret == -1) {
        // 详细处理不同错误,便于调试
        switch (errno) {
            case ENOENT:
                fprintf(stderr, "创建失败:路径中的目录不存在(%s)\n", fifo_path);
                fprintf(stderr, "解决方案:先创建目录(如mkdir -p /tmp)\n");
                break;
            case EACCES:
                fprintf(stderr, "创建失败:对目录无写权限(%s)\n", fifo_path);
                fprintf(stderr, "解决方案:切换到有权限的目录,或提升权限(如sudo)\n");
                break;
            case EROFS:
                fprintf(stderr, "创建失败:路径位于只读文件系统(%s)\n", fifo_path);
                break;
            default:
                fprintf(stderr, "创建失败:%s(错误码:%d)\n", strerror(errno), errno);
        }
        exit(1);
    }

    printf("管道创建成功:%s(权限:rw-------)\n", fifo_path);
    printf("注意:该管道仅当前用户可读写,其他用户无权限访问\n");
    return 0;
}
创建时的常见陷阱与解决方案:
  • 管道已存在且被占用 :若其他进程正在使用该管道,unlink会成功删除目录项,但 inode 会在所有进程关闭管道后才释放。此时重新创建同名管道会失败(因为 inode 仍存在),需等待所有进程退出后再尝试。
  • 路径包含特殊字符 :若路径含空格或特殊字符(如./my fifo),命令行创建时需用引号包裹(mkfifo "./my fifo"),代码中直接传入字符串即可("./my fifo")。
二、命名管道的使用:"发消息" 和 "收消息" 的全流程

创建管道后,需通过 "打开→读写→关闭" 三步实现通信。命名管道的核心特性是 "双向等待"------ 读端和写端必须配对,否则会阻塞,这是保证通信可靠性的关键。

1. 打开管道:必须 "双方就位" 才能开始

管道创建后处于 "关闭状态",需用open函数打开。open的行为由打开模式(O_RDONLY/O_WRONLY)和是否加O_NONBLOCK(非阻塞)决定,规则严格且易出错。

读端打开(O_RDONLY):"我准备好收消息了"

读端用O_RDONLY模式打开,示例代码:

复制代码
#include <fcntl.h>

int main() {
    const char *fifo_path = "/tmp/myfifo";
    // 以只读模式打开(默认阻塞,等待写端)
    int rfd = open(fifo_path, O_RDONLY);
    if (rfd == -1) {
        perror("读端打开失败");
        exit(1);
    }
    printf("读端已打开(文件描述符:%d),等待消息...\n", rfd);
    // ... 后续读数据 ...
}
读端打开的核心规则与注意点:
  • 阻塞模式(无O_NONBLOCK :内核会检查管道的 "写进程计数"(当前有多少个写端打开)。若计数为 0(无写端),读端进程会被加入管道的 "读等待队列",进入休眠状态(CPU 不再调度),直到有写端用O_WRONLY打开(写进程计数≥1),内核才会唤醒读端进程,open返回文件描述符(非负整数,如 3、4 等)。

    注意:阻塞期间,进程可被信号(如Ctrl+C产生的SIGINT)唤醒,此时open会返回 - 1,errno=EINTR("被中断"),需在代码中处理(如重试):

    复制代码
    int rfd;
    while ((rfd = open(fifo_path, O_RDONLY)) == -1) {
        if (errno == EINTR) {
            printf("读端打开被信号中断,重试...\n");
            continue;  // 被信号中断,重试open
        }
        perror("读端打开失败");
        exit(1);
    }
  • 非阻塞模式(加O_NONBLOCK :无论写进程计数是否为 0,open都会立即返回:

    • 若已有写端(计数≥1):返回文件描述符(成功)。
    • 若无写端(计数 = 0):仍返回文件描述符(成功),但后续read会立即返回 0(无数据),而非阻塞等待。

    注意:非阻塞模式适合 "轮询检查" 场景(如定期查看是否有数据),但会消耗更多 CPU 资源(进程不会休眠)。

写端打开(O_WRONLY):"我准备好发消息了"

写端用O_WRONLY模式打开,示例代码:

复制代码
#include <fcntl.h>

int main() {
    const char *fifo_path = "/tmp/myfifo";
    // 以只写模式打开(默认阻塞,等待读端)
    int wfd = open(fifo_path, O_WRONLY);
    if (wfd == -1) {
        perror("写端打开失败");
        exit(1);
    }
    printf("写端已打开(文件描述符:%d),可以发消息了...\n", wfd);
    // ... 后续写数据 ...
}
写端打开的核心规则与注意点:
  • 阻塞模式(无O_NONBLOCK :内核检查管道的 "读进程计数"(当前有多少个读端打开)。若计数为 0(无读端),写端进程会被加入 "写等待队列",进入休眠状态,直到有读端打开(读进程计数≥1),内核唤醒写端进程,open返回文件描述符。

    注意:与读端类似,阻塞期间若被信号中断,open返回 - 1,errno=EINTR,需重试。

  • 非阻塞模式(加O_NONBLOCK :若读进程计数为 0(无读端):open立即返回 - 1,errno=ENXIO("无接收方")。若已有读端(计数≥1):返回文件描述符(成功)。

    注意:非阻塞模式下,写端需先确认读端已打开,否则会打开失败,适合 "快速失败" 场景(如不希望长时间阻塞)。

2. 读写数据:"消息" 传递的规则与限制

打开管道后,用write(写端)和read(读端)函数传递数据。数据在 kernel 的 "管道缓冲区" 中临时存储,遵循严格的传递规则,直接影响通信的正确性。

内核缓冲区:数据的 "临时仓库"

每个命名管道都有一个内核维护的缓冲区,默认大小为 4KB(可通过ulimit -a查看pipe size),不同系统可能不同(如某些 Linux 发行版默认 8KB)。缓冲区的作用是:

  • 写端write的数据先存入缓冲区,读端read的数据从缓冲区取出。
  • 缓冲区满时,写端write会阻塞(等待读端取走数据腾出空间)。
  • 缓冲区空时,读端read会阻塞(等待写端写入数据)。

可通过fcntl函数查看 / 调整缓冲区大小(需内核支持,最大通常不超过 1MB):

复制代码
// 获取当前缓冲区大小
int pipe_size = fcntl(rfd, F_GETPIPE_SZ);  // rfd为读端/写端的文件描述符
printf("当前管道缓冲区大小:%d 字节\n", pipe_size);

// 调整缓冲区大小为8KB(8192字节)
int new_size = 8192;
if (fcntl(rfd, F_SETPIPE_SZ, new_size) == -1) {
    perror("调整缓冲区大小失败(可能超过内核限制)");
} else {
    printf("缓冲区大小已调整为:%d 字节\n", new_size);
}
写数据(write函数):"发消息" 的规则与注意点

函数原型:

复制代码
ssize_t write(int fd, const void *buf, size_t count);
核心行为与注意点:
  • 部分写入 :若缓冲区剩余空间小于countwrite会写入部分数据(返回实际写入的字节数),并阻塞等待空间(阻塞模式)。例如:缓冲区剩 200 字节,写 300 字节,write会先写 200 字节,返回 200,然后阻塞,直到读端取走至少 100 字节后,再写入剩余 100 字节。

    注意:需循环处理部分写入,确保所有数据发送完成:

    复制代码
    const char *msg = "这是一条超过缓冲区大小的长消息...";
    size_t total_len = strlen(msg);
    size_t written_len = 0;
    
    while (written_len < total_len) {
        ssize_t ret = write(wfd, msg + written_len, total_len - written_len);
        if (ret == -1) {
            perror("写入失败");
            exit(1);
        }
        written_len += ret;
        printf("已写入 %zd 字节,累计 %zd/%zd 字节\n", ret, written_len, total_len);
    }
  • 原子性保证 :若count ≤ PIPE_BUF(通常为 512 字节,可通过pathconf(fifo_path, _PC_PIPE_BUF)获取),write会 "原子执行"------ 要么全部写入,要么不写入(阻塞等待),不会被其他写进程的操作打断。这是多进程写管道时保证数据不混乱的关键(如多个进程写日志,每条日志≤512 字节时,不会出现两条日志混在一起的情况)。

    注意:若count > PIPE_BUF,原子性无法保证,多个写进程的数据可能交错(如进程 A 写 "abc",进程 B 写 "123",可能出现 "a1b2c3"),因此大文件不适合用管道传输。

  • SIGPIPE信号风险 :若写端write时,所有读端已关闭(读进程计数 = 0),内核会向写端进程发送SIGPIPE信号(默认行为是终止进程),同时write返回 - 1,errno=EPIPE

    解决办法:在写端忽略SIGPIPE信号,或捕获信号做优雅退出处理:

    复制代码
    #include <signal.h>
    
    // 忽略SIGPIPE信号(避免进程被终止)
    signal(SIGPIPE, SIG_IGN);
    
    // 或捕获信号,执行清理操作后退出
    void handle_sigpipe(int sig) {
        printf("收到SIGPIPE信号,读端已关闭,准备退出...\n");
        // 执行清理操作(如关闭文件描述符)
        exit(0);
    }
    signal(SIGPIPE, handle_sigpipe);
读数据(read函数):"收消息" 的规则与注意点

函数原型:

复制代码
ssize_t read(int fd, void *buf, size_t count);
核心行为与注意点:
  • 部分读取 :若缓冲区数据量小于countread会读取所有数据(返回实际字节数),不会等待 "凑满count"。例如:缓冲区有 200 字节,count=500read返回 200,仅读取 200 字节。

    注意:需循环读取,直到获取完整数据(若已知数据长度):

    复制代码
    char buf[1024];
    size_t expected_len = 500;  // 预期读取500字节
    size_t total_read = 0;
    
    while (total_read < expected_len) {
        ssize_t ret = read(rfd, buf + total_read, expected_len - total_read);
        if (ret == -1) {
            if (errno == EINTR) continue;  // 被信号中断,重试
            perror("读取失败");
            exit(1);
        } else if (ret == 0) {
            // 写端已关闭,且数据未读完(异常情况)
            fprintf(stderr, "写端提前关闭,仅读取 %zd/%zd 字节\n", total_read, expected_len);
            break;
        }
        total_read += ret;
    }
  • 返回 0 的含义 :当所有写端都已关闭(写进程计数 = 0),且缓冲区数据已读完,read返回 0------ 这是 "通信正常结束" 的标志,读端可据此退出循环。

    注意:若写端未关闭但暂时无数据,read会阻塞(阻塞模式),而非返回 0,需区分 "暂时无数据" 和 "通信结束"。

  • 数据不保留 :读端read后,缓冲区中的对应数据会被删除,其他读端(若存在)无法再读取。例如:两个读端同时打开管道,第一个读端取走数据后,第二个读端再读会返回 0(若写端已关闭)或阻塞(若写端未关闭)。

3. 关闭管道:"用完请关门"

通信结束后,必须用close函数关闭管道的文件描述符,否则会导致资源泄漏(文件描述符是有限资源,默认每个进程最多打开 1024 个)。

复制代码
// 写端关闭
close(wfd);
printf("写端已关闭(文件描述符:%d)\n", wfd);

// 读端关闭
close(rfd);
printf("读端已关闭(文件描述符:%d)\n", rfd);
关闭的影响与注意点:
  • 写端关闭后 :读端的read会在读完缓冲区剩余数据后返回 0("通信结束"),读端应据此退出,避免无限阻塞。
  • 读端关闭后 :写端的write会返回 - 1,errno=EPIPE(若已忽略SIGPIPE),写端应据此退出,避免无效写入。
  • 未关闭的风险 :若进程异常退出(如崩溃)未调用close,内核会自动关闭文件描述符(避免资源泄漏),但建议在代码中显式关闭(如用atexit注册清理函数)。
三、命名管道的删除:"通信站不用了,拆掉它"

命名管道是文件系统中的实体文件,即使所有进程都关闭了它,文件的目录项和 inode 仍会保留(就像信箱不用了但还占着位置)。若不删除,会导致后续创建失败或资源浪费。

1. 命令行删除:"手动拆站"

rm命令直接删除,与删除普通文件一致:

复制代码
rm /tmp/myfifo  # 删除/tmp目录下的myfifo管道
注意点:
  • rm本质是调用unlink函数删除目录项,若管道仍被进程打开(文件描述符未关闭),inode 不会立即释放(直到所有进程关闭),但目录项已删除,其他进程无法再通过路径打开该管道。
  • 若删除时提示 "设备或资源忙"(Device or resource busy),说明管道仍被进程占用,需先终止相关进程(用lsof /tmp/myfifo查看占用进程,再用kill终止)。
2. 代码删除:"程序自动拆站"

在代码中用unlink函数删除,适合程序退出时自动清理(避免残留)。函数原型:

复制代码
#include <unistd.h>
int unlink(const char *pathname);  // 参数:管道路径

那么如果unlink函数的返回值小于0,就代表删除失败。

代码示例(退出时自动删除,含注意点):
复制代码
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

// 注册清理函数,程序退出时自动删除管道
void cleanup(const char *fifo_path) {
    if (unlink(fifo_path) == -1) {
        perror("删除管道失败(可能已被其他进程删除)");
    } else {
        printf("管道已删除:%s\n", fifo_path);
    }
}

int main() {
    const char *fifo_path = "/tmp/myfifo";

    // 注册清理函数,确保程序正常/异常退出时都能执行
    atexit(() { cleanup(fifo_path); });  // C99支持匿名函数,或用函数指针

    // ... 中间使用管道的代码 ...

    return 0;  // 程序结束时,自动调用cleanup删除管道
}
删除的注意点:
  • 删除后已打开的进程仍可使用 :若进程已打开管道(持有文件描述符),即使管道被unlink删除,进程仍可继续读写(inode 未释放),直到所有进程关闭文件描述符,inode 才会被内核回收。
  • 避免误删正在使用的管道 :删除前需确认管道不再被使用(如通过lsof检查),否则可能导致依赖该管道的进程通信失败。
总结:命名管道的 "一生" 与核心注意点

命名管道的生命周期可概括为:创建→打开→读写→关闭→删除,每一步都有严格的规则和易踩的陷阱:

  • 创建时 :注意路径合法性、权限设置(避免0666的安全风险)、umask的影响,以及管道已存在的处理。
  • 打开时:理解 "双向阻塞" 机制,处理信号中断导致的打开失败,根据场景选择阻塞 / 非阻塞模式。
  • 读写时 :处理部分读写、保证小数据的原子性、忽略或捕获SIGPIPE信号,区分 "暂时无数据" 和 "通信结束"。
  • 关闭与删除时 :显式关闭文件描述符避免资源泄漏,删除前确认管道不再被使用,用atexit确保程序退出时自动清理。

示例代码:

接下来我给大家创建命名管道实现两个进程进行联系的完整代码:

Fifo.hpp:

cpp 复制代码
#include <cstdio>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstdlib>
#include <cstring>
#include <string>
#include <iostream>
#include <functional>

// 创建命名管道并设置对于命名管道的操作(open,write,read,close)的头文件
// 我们直接封装为类就完事了

// 定义一个宏负责处理一些打开失败的情况
#define ERR_EXIT(m)                         \
    do                                      \
    {                                       \
        std::cerr << m << std::endl;        \
        exit(2);                            \
    } while (0)

#define PATH "."
#define FIFONAME "namedfifo"

//创建命名管道的类
class NamedFifo
{
public:
    //构造函数
    NamedFifo(const char* path=PATH,const char* fifoname=FIFONAME)//给缺省值,避免不传入参数报错
    :_path(path)
    ,_fifoname(fifoname)//字符串可以直接转化为string
    {
        _name=_path + "/" + _fifoname;//路径加命名管道名字
        //接下来就得创建命名管道
        umask(0);
        int ret_mkfifo=mkfifo(_name.c_str(),0666);
        if(ret_mkfifo<0)
        {
            ERR_EXIT("mkfifo failed: ");
        }
        //创建命名管道成功
        std::cout<<"mkfifo successed!"<<std::endl;
    }
    //析构函数
    ~NamedFifo()
    {
        //这里我们就要负责命名管道的关闭
        //关闭命名管道就是直接使用unlink函数,里面传入要关闭文件的路径即可
        int ret_unlink=unlink(_name.c_str());
        if(ret_unlink<0)
        {
            ERR_EXIT("unlink failed: ");
        }
        //删除命名管道文件成功
        std::cout<<"unlink fifo successed!"<<std::endl;
    }
private:
    std::string _path;//路径
    std::string _fifoname;//命名管道名字
    std::string _name;//路径加命名管道名字
};

//设置对于命名管道的操作(open,write,read,close)的类
class FifoOperation
{
public:
    //构造函数
    FifoOperation(const char* path=PATH,const char* fifoname=FIFONAME)
    :_path(path)
    ,_fifoname(fifoname)//字符串可以直接转化为string
    {
        _name=_path + "/" + _fifoname;//路径加命名管道名字
    }
    //析构函数
    ~FifoOperation()
    {}
    //只读的打开命名管道
    int OpenForRead()
    {
        int ret_open=open(_name.c_str(),O_RDONLY);
        if(ret_open<0)
        {
            ERR_EXIT("open failed:");
        }
        //打开文件成功
        return ret_open;//返回文件描述符
    }
    //只写的打开命名管道
    int OpenForWrite()
    {
        int ret_open=open(_name.c_str(),O_WRONLY);
        if(ret_open<0)
        {
            ERR_EXIT("open failed:");
        }
        //打开文件成功
        return ret_open;//返回文件描述符
    }
    //给命名管道写数据
    void WriteToFifo(int wfd)
    {
        //我们想要一直给命名管道写数据怎么办呢?
        //那就直接动用死循环
        while(true)
        {
            std::string buf;
            std::getline(std::cin,buf);//使用getline函数去不断获取用户输入的字符串
            //将获取到的字符串写入命名管道中
            int ret_write=write(wfd,buf.c_str(),sizeof(buf.c_str()));//c_str自动末尾加'\0'
            if(ret_write<0)
            {
                ERR_EXIT("write failed: ");
            }
            //写入管道成功
            std::cout<<"write to namedfifo successed!"<<std::endl;

            buf.clear();//清空内容,为下一次输入做准备

        }
    }
    //读命名管道传过来的数据
    void ReadFromFifo(int rfd)
    {
        //同样的,我们读数据,肯定也得去死循环读取,直到读取出错或者读取结束(一方不再写入数据)
        while(true)
        {
            char buf[1024];
            //将从命名管道中获取到的字符串传入buf中
            int ret_read=read(rfd,buf,sizeof(buf)-1);//留最后一个空间放置字符串终止符
            if(ret_read<0)
            {
                ERR_EXIT("read failed: ");
            } 
            else if(ret_read==0)
            {
                //读取over
                std::cout<<"read over!"<<std::endl;
            }
            else//没等到数据就会一直阻塞哦
            {
                //写入管道成功
                std::cout<<"read from namedfifo successed!"<<std::endl;
                buf[ret_read]='\0';//最后手动添加字符串终止符
                std::cout << "Client Say# " << buf << std::endl;//输出数据
                //重置字符串buf,虽然就算不重置也会占据位置
                memset(buf,0,sizeof(buf));
            }
        }
    }
    void Close(int fd)
    {
        if(fd>=0)
        {
            close(fd);
        }
    }
private:
    std::string _path;
    std::string _fifoname;
    std::string _name;
};

Client.cc:

cpp 复制代码
// 客户端进程
#include "Fifo.hpp"

// 这个客户端就是负责给另一个进程,一般是服务端发送信息
// 那么通过什么发送呢?
// 其实就是依靠命名管道,它给命名管道写信息(write)函数
// 值得注意的是,命名管道不用管什么写端读端
// 写就是用write函数给命名管道文件写
// 读就是用read函数给命名管道文件读

int main()
{
    FifoOperation fo(".", "fifo");
    int fd = fo.OpenForWrite();
    fo.WriteToFifo(fd);
    fo.Close(fd);
    return 0;
}

Server.cc:

cpp 复制代码
// 服务端进程
#include "Fifo.hpp"

// 这个服务端就是负责接收另一个进程,一般是客户端的信息
// 那么通过什么接收(读取)呢?
// 其实就是依靠命名管道,它读命名管道的信息,利用read函数去读
// 值得注意的是,命名管道不用管什么写端读端
// 写就是用write函数给命名管道文件写
// 读就是用read函数给命名管道文件读

int main()
{
    NamedFifo f(".", "fifo");
    FifoOperation fo(".", "fifo");
    int fd = fo.OpenForRead();
    fo.ReadFromFifo(fd);
    fo.Close(fd);
    return 0;
}

Makefile:

bash 复制代码
.PHONY:all
all:Client Server
Client:Client.cc
	g++ $^ -o $@ -std=c++11

Server:Server.cc
	g++ $^ -o $@ -std=c++11

.PHONY:clean
clean:
	rm -f Client Server fifo

其实大家能把匿名管道掌握,而且能熟练使用之前讲的文件IO操作,那么大家对于命名管道的一系列操作,还是手到擒来的哦。

结语:在代码与实践中,解锁 IPC 通信的 "连接之力"

当你敲下make clean,看着终端里 "rm -f Client Server fifo" 的执行结果,看着那个陪伴你完成通信测试的命名管道文件被干净利落地删除时,我相信你心里一定藏着一份踏实的成就感 ------ 这份成就感,源于你从 "知道匿名管道的局限" 到 "精通命名管道的全流程",源于你从 "对着概念困惑" 到 "亲手让两个独立进程完成对话",更源于你在无数个 "为什么阻塞""权限怎么不对" 的疑问中,一步步拆解问题、找到答案的坚持。

其实学习编程的过程,从来都不是 "记住语法" 那么简单,而是在 "理解本质 - 掌握流程 - 规避陷阱 - 灵活运用" 的循环中,慢慢搭建起自己的知识体系。命名管道的学习,正是这个循环的绝佳体现。从开篇意识到匿名管道 "亲缘关系限制" 的痛点,到好奇 "如何让两个不相关进程通信",再到深入理解它 "带地址的内存缓冲区" 的本质,最后通过代码封装、实操测试,让 Client 和 Server 顺畅传递消息 ------ 你走过的每一步,都是在锻炼 "发现问题 - 解决问题" 的核心能力,而这种能力,远比记住某个函数原型更有价值。

回顾整个学习过程,命名管道的核心逻辑其实可以浓缩为 "一个本质 + 一套流程 + 一堆细节"。一个本质,是它 "以文件为标识、以内存为载体" 的特殊属性 ------ 它像一个 "临时信箱",却不占用磁盘空间;像普通文件一样支持open/read/write/close,却有着 "双向阻塞" 的特殊规则。这套流程,是 "创建 - 打开 - 读写 - 关闭 - 删除" 的完整生命周期 ------ 从mkfifo命令或函数创建管道,到读端O_RDONLY、写端O_WRONLY的配对打开,再到write的原子性保障、read的返回值判断,最后close释放文件描述符、unlink清理残留文件,每一步都环环相扣,缺一不可。而那些容易被忽略的细节,比如umask对权限的影响、SIGPIPE信号的处理、部分读写的循环处理、绝对路径的优先使用 ------ 正是这些细节,决定了你的代码是 "能运行的 demo" 还是 "能落地的工程代码"。

我知道,你在实操中可能遇到过不少 "小插曲":比如忘记清除umask导致管道权限不足,写端打开后因为没启动读端而一直阻塞,write时因为读端意外关闭导致进程崩溃,或者删除管道时提示 "设备或资源忙"------ 这些问题看似棘手,却恰恰是理解命名管道的 "钥匙"。当你通过ls -l确认管道类型为p,通过lsof查看占用进程,通过strerror(errno)定位错误原因时,你其实已经在运用 "调试思维" 解决问题了。而这种思维,会让你在未来面对更复杂的技术(比如共享内存、消息队列、socket)时,更加从容不迫。

命名管道作为 Linux IPC 家族的 "基础成员",它的价值远不止 "让两个进程通信" 那么简单。在实际开发中,它常常扮演着 "轻量级数据中转站" 的角色:比如多个业务进程将日志写入命名管道,由一个日志收集进程统一处理并写入文件;比如命令行工具之间的协作,通过命名管道传递中间结果;甚至在嵌入式开发中,资源受限的设备上,命名管道因其简单、高效的特性,成为进程间通信的首选。当你掌握了命名管道,你就掌握了 "跨进程数据传递" 的核心思路 ------ 无论未来遇到多么复杂的通信场景,"找到一个公共载体、定义清晰的读写规则、处理好边界情况" 的逻辑,永远适用。

再看我们最后给出的完整代码,你会发现一个很重要的思维转变:从 "写一次性 demo" 到 "封装可复用类"。NamedFifo类负责管道的创建与删除,FifoOperation类负责打开、读写、关闭的操作,这种 "职责分离" 的封装思想,是工程化开发的基础。它不仅让代码更简洁、更易维护,更能避免重复编写相同逻辑导致的错误。比如ERR_EXIT宏的定义,让错误处理更统一;atexit注册清理函数,确保程序异常退出时也能删除管道 ------ 这些细节,都是在培养你 "编写健壮代码" 的意识。我希望你能记住这种思维:编程不仅是 "实现功能",更是 "让功能在各种场景下都能稳定运行"。

学习 Linux IPC,就像在搭建一座 "进程通信的桥梁",而命名管道是这座桥梁的 "第一块基石"。接下来,你还会遇到共享内存(更快但需同步)、消息队列(支持结构化数据)、信号量(负责同步互斥)等更多 IPC 机制,但你会发现,它们的学习逻辑与命名管道一脉相承 ------ 都是 "理解本质 - 掌握流程 - 处理细节"。比如共享内存,它没有管道的缓冲区限制,却需要自己处理进程间的同步问题;消息队列支持按类型接收消息,却在效率上略逊于管道。而命名管道作为 "入门砖",已经帮你建立了 "进程隔离 - 公共载体 - 数据传递" 的核心认知,未来学习这些更复杂的机制时,你会事半功倍。

我常常觉得,编程学习中最珍贵的,不是 "一次就成功",而是 "在失败中成长"。可能你第一次运行 Server 和 Client 时,因为管道路径不匹配导致通信失败;可能你第一次处理SIGPIPE信号时,不知道如何捕获或忽略;可能你第一次封装类时,因为成员变量的作用域问题导致编译错误 ------ 但这些失败都不可怕,可怕的是因为害怕失败而不敢动手。就像命名管道的 "阻塞规则",只有亲自体验过 "读端等写端、写端等读端" 的阻塞过程,才能真正理解它的设计初衷;只有亲自踩过 "权限不足""路径错误" 的坑,才能在未来的开发中下意识地规避这些问题。

最后,我想对你说:命名管道的学习告一段落,但你的 Linux 编程之旅才刚刚开始。IPC 作为后端开发、嵌入式开发的核心知识点,会在你未来的工作和学习中频繁出现。希望你能带着这次学习的收获 ------ 不仅是命名管道的知识,更是 "拆解问题、动手实践、调试排错" 的能力 ------ 继续探索更广阔的技术世界。当你以后使用共享内存实现高频数据传输,或者用 socket 实现网络通信时,不妨回头看看这次的学习经历:正是这些看似基础的知识点,一点点搭建起你编程能力的大厦。

请记住,编程从来不是 "天赋决定一切",而是 "坚持 + 实践" 的结果。可能你现在觉得命名管道的细节很繁琐,可能你在实操中会遇到各种意外,但请相信,每一次动手编写代码,每一次排查错误,每一次理解一个晦涩的概念,都是在向 "更优秀的程序员" 靠近。就像命名管道的通信过程,数据从写端出发,经过缓冲区,最终到达读端 ------ 你的学习之路,也会从 "困惑" 出发,经过 "实践",最终到达 "精通"。

愿你在未来的编程学习中,始终保持这份 "好奇与坚持",敢于动手,勇于探索,在拆解问题、解决问题的过程中,不断解锁新的技能,不断突破自己的边界。当你某天回头看时,会发现那些曾经让你头疼的 "阻塞规则""权限问题",都已经成为你能力的一部分,而命名管道这颗 "小小的种子",早已在你心中生根发芽,长成支撑你面对更复杂技术挑战的 "大树"。

加油吧!编程的世界里,没有白走的路,每一步都算数。期待你在 Linux IPC 的世界里,探索出更多精彩,写出更健壮、更优雅的代码,用技术的力量,连接起一个个独立的进程,也连接起你对编程的热爱与追求!

相关推荐
比昨天多敲两行2 小时前
Linux权限管理
linux·运维·服务器
runningshark2 小时前
【Linux】VirtualBox ↔ Ubuntu+WinSCP 文件传输
linux·运维·ubuntu
aidream12392 小时前
Linux文件操作-文件打包和压缩(tar/gzip/bzip2/xz/zip)
linux·运维·服务器
念恒123063 小时前
进程--进程状态(上)
linux·c语言
前端摸鱼匠3 小时前
【AI大模型春招面试题25】掩码自注意力(Masked Self-Attention)与普通自注意力的区别?适用场景?
人工智能·ai·面试·大模型·求职招聘
️是783 小时前
信息奥赛一本通—编程启蒙(3371:【例64.2】 生日相同)
开发语言·c++·算法
捞的不谈~3 小时前
宇树机器狗通过笔记本共享上网操作指南(临时版)
linux·经验分享·tcp/ip·ubuntu
feng_you_ying_li3 小时前
linux之进程优先与切换调度
linux·运维·服务器
光电笑映3 小时前
Linux C/C++ 开发工具(上):包管理器、Vim、gcc/g++ 与动静态库
linux·运维·服务器