【Linux】两个毫无关系的进程怎么通信?命名管道 FIFO 从原理到 Server/Client 实战

🔥个人主页:爱和冰阔乐

📚专栏传送门:《数据结构与算法》C++

🐶学习方向:C++方向学习爱好者

⭐人生格言:得知坦然 ,失之淡然


🏠博主简介

文章目录

  • 前言
  • 一、匿名管道缺的不是"管道",而是一个双方都认识的入口
  • [二、先用命令把 FIFO 的行为跑出来](#二、先用命令把 FIFO 的行为跑出来)
    • [2.1 `mkfifo`](#2.1 mkfifo)
    • [2.2 为什么 `echo > fifo` 会卡住](#2.2 为什么 echo > fifo 会卡住)
  • [三、FIFO 的 `open()` 规则比 `read()` 更早开始同步](#三、FIFO 的 open() 规则比 read() 更早开始同步)
  • [四、系统调用 `mkfifo()`](#四、系统调用 mkfifo())
  • [五、用命名管道写一个最小 Server / Client](#五、用命名管道写一个最小 Server / Client)
    • [5.1 Server](#5.1 Server)
    • [5.2 Client](#5.2 Client)
  • [六、Client 一退出,Server 为什么会读到 `0`](#六、Client 一退出,Server 为什么会读到 0)
  • 七、匿名管道和命名管道到底差在哪
  • 八、几个容易理解错的地方
      • [1. "命名管道是普通磁盘文件"](#1. “命名管道是普通磁盘文件”)
      • [2. "只要文件路径相同,就一定是同一个打开文件对象"](#2. “只要文件路径相同,就一定是同一个打开文件对象”)
      • [3. Server 卡住一定是 `read()`](#3. Server 卡住一定是 read())
      • [4. `read()` 读出来就是字符串](#4. read() 读出来就是字符串)
  • 总结

前言

前面学匿名管道时有一个很明显的限制:

父进程先 pipe()

fork()

子进程继承父进程的 fd

父子进程看到同一条管道

这套方法很好用,但前提是两个进程之间能通过 fork() 建立文件描述符的继承关系。

如果现在有两个完全独立启动的程序:

text 复制代码
./server
./client

它们没有父子关系,也没有谁能把自己已经打开的匿名管道 fd "继承"给另一方。

那怎么让它们找到同一份内核通信资源?

命名管道解决的正是"两个无亲缘关系进程怎样找到同一份通信资源"这个问题。


一、匿名管道缺的不是"管道",而是一个双方都认识的入口

匿名管道没有可供无关进程重新打开的路径。

cpp 复制代码
int pipefd[2];
pipe(pipefd);

pipe() 返回的 pipefd[0]pipefd[1] 只对当前进程以及后来继承这些 fd 的进程有意义。

两个毫无关系的进程不可能提前知道对方进程里的某个 fd 是多少。

命名管道换了一种思路:

不再靠 fd 继承找到同一条管道,而是让两个进程约定同一个 pathname。

例如:

text 复制代码
/tmp/myfifo

Server 和 Client 都去 open("/tmp/myfifo", ...),内核就能把它们连接到同一个 FIFO 对象上。

这里有个地方很容易理解偏。

命名管道确实在文件系统中拥有路径、目录项和文件类型 ,执行 ls -l 能看到它;但真正传输的数据并不会像普通文件一样写进磁盘文件内容。

这里记住:

text 复制代码
路径存在于文件系统中
        ↓
用于让不同进程找到同一个 FIFO
        ↓
通信数据仍然在内核管道机制中流动

所以"有文件名"不等于"通信数据会落盘"。


二、先用命令把 FIFO 的行为跑出来

2.1 mkfifo

创建:

bash 复制代码
mkfifo fifo

查看:

bash 复制代码
ls -l fifo

命名管道的文件类型通常会显示为:

text 复制代码
p

删除时直接:

bash 复制代码
rm fifo

或者:

bash 复制代码
unlink fifo

没必要写:

bash 复制代码
rm -rf fifo

fifo 不是目录,-r 在这里没有意义。

2.2 为什么 echo > fifo 会卡住

先执行:

bash 复制代码
echo "hello fifo" > fifo

如果此时没有任何进程以读方式打开 FIFO,Shell 会停在那里。

另开一个终端:

bash 复制代码
cat fifo

此时两边都会继续运行,cat 能读到:

text 复制代码
hello fifo

这一步最值得观察的是:

text 复制代码
写端 open(O_WRONLY)
        │
        │ 没有读端时等待
        ▼
读端 open(O_RDONLY)
        │
        ▼
双方建立通信关系

三、FIFO 的 open() 规则比 read() 更早开始同步

默认阻塞模式下:

打开方式 另一端不存在时
open(..., O_RDONLY) 等待某个写端打开
open(..., O_WRONLY) 等待某个读端打开

也就是说,阻塞甚至可能发生在 open(),代码还没有走到 read()write()

如果使用 O_NONBLOCK,规则会变化:

text 复制代码
O_RDONLY | O_NONBLOCK

可以立即打开。

而:

text 复制代码
O_WRONLY | O_NONBLOCK

如果一个读端都不存在,会失败并得到 ENXIO

排查 FIFO "程序一启动就卡住"时,先看 open() 有没有返回。

很多时候不是 read() 卡住,而是:

cpp 复制代码
open(FIFO_FILE, O_RDONLY);

根本还没有返回。


四、系统调用 mkfifo()

程序中创建 FIFO:

cpp 复制代码
#include <sys/types.h>
#include <sys/stat.h>

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

成功返回:

text 复制代码
0

失败返回:

text 复制代码
-1

例如:

cpp 复制代码
#include <cerrno>
#include <cstdio>
#include <sys/stat.h>

const char* FIFO_FILE = "./myfifo";

int main()
{
    umask(0);

    if (mkfifo(FIFO_FILE, 0666) < 0)
    {
        if (errno != EEXIST)
        {
            perror("mkfifo");
            return 1;
        }
    }

    return 0;
}

Server 可能被重复启动,所以遇到 EEXIST 时不要先认定程序出错。

如果路径已经存在,还应该确认它是不是我们期望的 FIFO,而不是同名普通文件。


五、用命名管道写一个最小 Server / Client

text 复制代码
Server:读端
Client:写端

5.1 Server

cpp 复制代码
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <fcntl.h>
#include <iostream>
#include <sys/stat.h>
#include <unistd.h>

const char* FIFO_FILE = "./myfifo";

int main()
{
    umask(0);

    if (mkfifo(FIFO_FILE, 0666) < 0 && errno != EEXIST)
    {
        perror("mkfifo");
        return 1;
    }

    std::cout << "等待 client 打开 FIFO..." << std::endl;

    int fd = open(FIFO_FILE, O_RDONLY);
    if (fd < 0)
    {
        perror("open");
        unlink(FIFO_FILE);
        return 2;
    }

    char buffer[1024];

    while (true)
    {
        ssize_t n = read(fd, buffer, sizeof(buffer) - 1);

        if (n > 0)
        {
            buffer[n] = '\0';
            std::cout << "client say# " << buffer << std::endl;
        }
        else if (n == 0)
        {
            std::cout << "所有写端已经关闭,server 结束读取" << std::endl;
            break;
        }
        else
        {
            perror("read");
            break;
        }
    }

    close(fd);
    unlink(FIFO_FILE);

    return 0;
}

这里顺手修正原稿中的一个细节。原来写的是:

cpp 复制代码
buffer[n - 1] = 0;

改成:

cpp 复制代码
buffer[n] = '\0';

read() 返回的 n 就是本次实际读到的有效字节数。

如果你的目的只是给 C 字符串补结束符,应该放在有效数据后一个位置

只有你明确知道最后一个字节一定是换行符,并且想把换行删掉时,才考虑改 buffer[n - 1]

5.2 Client

cpp 复制代码
#include <cstdio>
#include <fcntl.h>
#include <iostream>
#include <string>
#include <unistd.h>

const char* FIFO_FILE = "./myfifo";

int main()
{
    int fd = open(FIFO_FILE, O_WRONLY);
    if (fd < 0)
    {
        perror("open");
        return 1;
    }

    int count = 1;
    pid_t pid = getpid();

    while (true)
    {
        std::cout << "Please Enter# ";

        std::string message;
        if (!std::getline(std::cin, message))
        {
            break;
        }

        message += ", message number " +
                   std::to_string(count++) +
                   " [pid=" +
                   std::to_string(pid) +
                   "]";

        ssize_t n = write(fd, message.c_str(), message.size());

        if (n < 0)
        {
            perror("write");
            break;
        }
    }

    close(fd);
    return 0;
}

运行:

bash 复制代码
./server

再打开另一个终端:

bash 复制代码
./client

六、Client 一退出,Server 为什么会读到 0

假设 Client 正常退出:

cpp 复制代码
close(fd);

如果系统中已经不存在任何写端,那么 Server 继续:

cpp 复制代码
read(fd, buffer, sizeof(buffer));

当 FIFO 中剩余数据已经全部读完后,read() 返回:

text 复制代码
0

这就是 EOF。

这里和匿名管道是一套规则:

text 复制代码
有没有路径,只决定双方怎么"找到"管道
读写端、EOF、SIGPIPE、字节流规则,仍然属于管道语义

七、匿名管道和命名管道到底差在哪

匿名管道和命名管道的区别不用背很多条,抓住"双方怎样找到同一条管道"最重要。

对比 匿名管道 命名管道 FIFO
创建方式 pipe() mkfifo()
是否有路径 没有
常见使用关系 父子等有亲缘关系进程 无亲缘关系进程也可以
数据是否落盘
数据形式 字节流 字节流
阻塞、EOF、SIGPIPE

核心区别:

匿名管道通常靠 fork() 继承 fd;

命名管道靠双方打开同一个 pathname 建立联系。


八、几个容易理解错的地方

1. "命名管道是普通磁盘文件"

不对。

它在文件系统中有名字和类型,但 FIFO 中传递的字节并不是普通文件内容。

2. "只要文件路径相同,就一定是同一个打开文件对象"

也不能这么说。

多个 open() 会形成各自的打开实例和 fd,底层文件系统对象存在共享关系,但不要简单写成"两个进程的 struct file 完全是同一个"。

3. Server 卡住一定是 read()

不一定。

读端:

cpp 复制代码
open(path, O_RDONLY);

在没有写端时就可能已经阻塞。

4. read() 读出来就是字符串

不是。

read() 只认识字节,不认识 C 字符串结尾的 '\0'

所以:

cpp 复制代码
buffer[n] = '\0';

是用户代码自己补的。


总结

命名管道没有改变管道传输数据的本质,它只是解决了匿名管道最麻烦的那个问题:

text 复制代码
两个没有亲缘关系的进程
到底怎样找到同一条管道?

答案就是 pathname。

Server 和 Client 约定:

text 复制代码
./myfifo

各自调用 open(),就能连接到同一条 FIFO。

最后把这篇真正要记住的内容收一下:

  • mkfifo() 创建的是 FIFO 特殊文件;
  • FIFO 数据不会作为普通文件内容刷到磁盘;
  • 阻塞可能从 open() 就开始;
  • read() 返回 0 表示所有写端关闭并且剩余数据已经读完;
  • 命名管道和匿名管道的主要区别,是"双方怎样找到同一资源"。

下一篇继续沿着"让不同进程看到同一份资源"往下走,不过不再借助文件描述符,而是直接把同一批物理页映射进多个进程的地址空间------System V 共享内存。

资源分享:

【Linux】从匿名管道到进程池:任务派发、fd 继承 Bug 与完整实现

【Linux】进程为什么不能直接通信?匿名管道原理、fd 继承与读写规则

【Linux】没有公网 IP 也能 SSH 回内网:反向 SSH、ProxyJump 与 systemd 保活实战

相关推荐
ltl1 小时前
Windows 内核与 Linux 的关键差异
linux
ltl1 小时前
CentOS 停服与生态重组:Rocky、Alma、openEuler、龙蜥
linux
码匠许师傅9 小时前
【C++ 面试真题】C++ 的 const 和 constexpr 有什么区别?
c++·面试
ltl9 小时前
eBPF 可观测性全景:bcc、bpftrace、libbpf 的工程路径
linux
2401_8685347810 小时前
数仓开发落地手册
linux·运维
年小个大11 小时前
受 go-zero 启发,我给 Flutter 写了一套 CLI 脚手架
android·flutter·架构
范什么特西11 小时前
回答知识总结04(redis)
数据库·redis·缓存
正在走向自律11 小时前
使用atop工具监控Linux系统指标
linux·运维·php·实时监控·atop分析内存
码农颜11 小时前
5.4.1 锁分类
java·数据库·mysql