mthoutai

  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

前言:

在 Linux 系统中,进程是资源分配的基本单位,每个进程都拥有独立的地址空间,这意味着进程间无法直接访问彼此的数据。为了解决进程间数据传输、资源共享等需求,进程间通信(IPC)技术应运而生。本文将聚焦 IPC 中的 “管道” 技术,从基础概念到代码实现,详细拆解匿名管道与命名管道的原理、操作及应用场景。

一、进程间通信(IPC)基础认知

在学习管道之前,我们需要先明确 “为什么需要 IPC” 以及 “IPC 有哪些分类”,这能帮助我们建立整体认知框架。

1.1 为什么需要进程间通信?

进程间通信的核心目的可归纳为 4 类,覆盖了绝大多数场景:

  • 数据传输:一个进程将数据发送给另一个进程(如:客户端进程向服务器进程发送请求数据)。
  • 资源共享:多个进程共享同一份资源(如:多个进程读写同一个配置文件)。
  • 通知事件:一个进程向其他进程发送 “事件通知”(如:子进程退出时通知父进程回收资源)。
  • 进程控制:一个进程完全控制另一个进程的执行(如:调试器(Debug)进程监控目标进程的异常和状态)。

1.2怎么通信?

进程间通信的本质:是先让不同的进程先看到同一份资源[“内存”]。(然后才有通信的条件)

这里的同一份资源,不是任何一个进程提供的,它是操作系统OS提供的。

而操作系统要提供的话,就必须进行系统调用。然后系统调用的话,就涉及OS的接口,那么os的编写者就要设计统一的通信接口。那么就要有设计方案出来。

1.3什么是进程间通信?

进程间通信是指两个或多个进程之间进行数据交换和协调操作的机制。这里的“进程”可以运行在:

  1. 同一台计算机上
  2. 通过网络连接的不同计算机上

1.4 IPC 的发展与分类

Linux 的 IPC 技术并非一蹴而就,而是经历了三个主要发展阶段,对应的分类如下:

发展阶段包含的IPC技术
早期Unix管道(匿名管道、命名管道)
System V IPC消息队列、共享内存、信号量
POSIX IPC消息队列、共享内存、信号量、互斥量、条件变量、读写锁

本文重点讲解早期 Unix 阶段的管道技术,包括匿名管道和命名管道。

二、管道的本质:进程间的 “数据流桥梁”

管道是 Unix 系统中最古老的 IPC 形式,其本质是内核维护的一个临时缓冲区,相当于连接两个进程的 “数据流桥梁”。

举个直观的例子:我们在终端执行who | wc -l命令时,who进程的标准输出(stdout)会通过管道传递给wc -l进程的标准输入(stdin),最终wc -l统计who输出的行数。这个过程中,管道就是 “中间桥梁”,由内核负责数据的暂存和传递。

在这里插入图片描述

管道的核心特点可总结为:

  1. 基于文件抽象:Linux 遵循 “一切皆文件” 思想,管道的操作(读 / 写)与文件操作完全一致(用read/write系统调用)。
  2. 半双工通信:数据只能单向流动(如:A→B,若要双向通信需创建两个管道)。
  3. 同步与互斥:内核会自动处理管道的同步(如:读端等待写端数据)和互斥(如:多个进程写时避免数据混乱)。

三、匿名管道(pipe):亲缘进程的 “秘密通道”

匿名管道(简称 pipe)是最基础的管道类型,仅支持有亲缘关系的进程(如父进程与子进程、兄弟进程)通信,因为它没有全局可见的名称,只能通过进程的文件描述符传递。

3.1 匿名管道的核心函数:pipe ()

要使用匿名管道,首先需要通过pipe()函数创建管道,该函数会生成两个文件描述符,分别对应管道的 “读端” 和 “写端”。

函数原型与参数解析
#include <unistd.h>
  // 功能:创建匿名管道
  // 参数:fd[2] 是输出型参数,存储管道的读端和写端
  //      fd[0]:读端(只能读,不能写)
  //      fd[1]:写端(只能写,不能读)
  // 返回值:成功返回0,失败返回-1(并设置errno)
  int pipe(int fd[2]);
关键注意点
  • 调用pipe()的进程(如父进程)会同时持有读端和写端,但实际通信时需要关闭其中一个(如父进程关读端、子进程关写端,实现单向通信)。
  • 管道的生命周期与进程绑定:所有持有管道文件描述符的进程退出后,内核会自动释放管道缓冲区。

在这里插入图片描述

3.2 匿名管道的核心原理:fork 共享文件描述符

为什么匿名管道只能用于亲缘进程?核心原因是管道的文件描述符需要通过fork()继承。具体过程分为 3 步,我们结合文件描述符表和内核缓冲区来理解:

步骤 1:父进程创建管道

父进程调用pipe()后,内核会创建一个管道缓冲区,并为父进程的文件描述符表分配两个项:fd[0](读端)和fd[1](写端),指向该缓冲区。

在这里插入图片描述

步骤 2:父进程 fork 子进程

fork()会复制父进程的文件描述符表,因此子进程也会持有fd[0]fd[1],且与父进程指向同一个内核管道缓冲区。

在这里插入图片描述

步骤 3:关闭无用的文件描述符

为了实现 “父进程写、子进程读” 的单向通信,需要:

  • 父进程关闭读端(close(fd[0])):避免父进程误读数据。
  • 子进程关闭写端(close(fd[1])):避免子进程误写数据。

最终状态如下:

在这里插入图片描述

3.3 匿名管道实战 1:基础读写示例

下面的代码实现了 “父进程从键盘读数据→写入管道→子进程从管道读数据→输出到屏幕” 的完整流程,我们逐行添加注释并讲解设计思路。

代码实现与详细注释
#include <stdio.h>
  #include <stdlib.h>
    #include <string.h>
      #include <unistd.h>
        #include <sys/wait.h>  // 用于waitpid()
          int main(void) {
          int fds[2];          // 存储管道的读端(fds[0])和写端(fds[1])
          char buf[100] = {0}; // 数据缓冲区,存储读写的数据
          int len = 0;         // 记录实际读写的字节数
          pid_t pid;           // 存储fork()返回的子进程ID
          // 1. 创建匿名管道:若失败则打印错误并退出
          if (pipe(fds) == -1) {
          perror("pipe failed"); // perror():打印系统错误信息(如"pipe failed: No such file or directory")
          exit(1);               // 退出进程,状态码1表示异常
          }
          // 2. 创建子进程:通过fork()继承管道的文件描述符
          pid = fork();
          if (pid == -1) {
          perror("fork failed");
          exit(1);
          }
          // 3. 子进程逻辑:读管道数据,输出到屏幕
          if (pid == 0) {
          close(fds[1]); // 子进程不需要写,关闭写端(避免误写+确保父进程关闭写端后读端能收到EOF)
          // 从管道读数据:read(fd, 缓冲区, 缓冲区大小)
          len = read(fds[0], buf, sizeof(buf));
          if (len == -1) { // 读失败(如管道被意外关闭)
          perror("read from pipe failed");
          exit(1);
          } else if (len == 0) { // 读返回0:表示所有写端已关闭(EOF)
          printf("child: all write ends closed\n");
          exit(0);
          }
          // 将读取到的数据输出到屏幕(stdout的文件描述符是1)
          write(1, buf, len);
          close(fds[0]); // 子进程读完后,关闭读端
          exit(0);       // 子进程正常退出
          }
          // 4. 父进程逻辑:从键盘读数据,写入管道
          else {
          close(fds[0]); // 父进程不需要读,关闭读端
          // 从键盘读数据:fgets(缓冲区, 最大长度, 输入流),这里输入流是stdin(键盘)
          printf("parent: please enter data (max 99 bytes): ");
          if (fgets(buf, sizeof(buf), stdin) == NULL) {
          perror("fgets failed");
          exit(1);
          }
          // 去掉fgets读取的换行符(因为键盘输入会以\n结束,避免写入管道时携带多余换行)
          len = strlen(buf);
          if (buf[len-1] == '\n') {
          buf[len-1] = '\0';
          len--;
          }
          // 向管道写数据:write(fd, 数据, 数据长度)
          if (write(fds[1], buf, len) != len) { // 若实际写入字节数≠预期,说明写失败
          perror("write to pipe failed");
          exit(1);
          }
          close(fds[1]); // 父进程写完后,关闭写端(子进程会收到EOF)
          waitpid(pid, NULL, 0); // 等待子进程退出,避免子进程成为僵尸进程
          printf("parent: child exited\n");
          return 0;
          }
          }
代码设计思路讲解
  1. 管道创建时机:必须在fork()前创建管道,否则子进程无法继承管道的文件描述符。
  2. 文件描述符关闭:父子进程必须关闭无用的端(父关读、子关写),否则会出现 “读端一直阻塞等待数据” 的问题(因为写端未关闭,内核认为可能还有数据要写)。
  3. 异常处理:
    • pipe()/fork()失败时用perror()打印错误,便于调试。
    • read()返回 0 时处理 EOF(所有写端关闭),避免子进程无限阻塞。
    • 父进程用waitpid()等待子进程,避免僵尸进程。

3.4 匿名管道实战 2:进程池的实现

在实际开发中,匿名管道常用来实现 “进程池”—— 由一个主进程(Master)管理多个子进程(Worker),主进程派发任务,子进程执行任务。下面的代码基于 C++ 实现了一个简单的进程池,包含任务管理、任务派发、进程回收等核心功能。

核心组件拆分

进程池的实现依赖 3 个核心类 / 函数:

  1. Channel 类:封装子进程的 “通信通道”(管道写端 + 子进程 ID),用于主进程向子进程发送任务。
  2. TaskManger 类:管理任务列表,支持随机选择任务。
  3. ProcessPool 类:创建子进程、初始化管道、派发任务、回收子进程。
  4. Worker 函数:子进程的任务执行逻辑(从管道读任务指令,执行对应任务)。
完整代码与注释
1. Channel.hpp(通信通道封装)
#ifndef __CHANNEL_HPP__
#define __CHANNEL_HPP__
#include <iostream>
  #include <string>
    #include <unistd.h> // 用于close()
      // Channel类:封装主进程与子进程的通信通道(管道写端+子进程ID)
      class Channel {
      public:
      // 构造函数:初始化管道写端和子进程ID,并生成通道名称(便于调试)
      Channel(int wfd, pid_t who) : _wfd(wfd), _who(who) {
      // 通道名称格式:Channel-写端fd-子进程ID(如Channel-5-1234)
      _name = "Channel-" + std::to_string(wfd) + "-" + std::to_string(who);
      }
      // 获取通道名称(调试用)
      std::string Name() { return _name; }
      // 发送任务指令:向子进程写任务ID(int类型,4字节)
      void Send(int cmd) {
      // write(管道写端, 任务指令, 指令大小)
      write(_wfd, &cmd, sizeof(cmd));
      }
      // 关闭管道写端(主进程回收时调用)
      void Close() { close(_wfd); }
      // 获取子进程ID(主进程等待子进程时用)
      pid_t Id() { return _who; }
      // 获取管道写端(调试用)
      int wFd() { return _wfd; }
      private:
      int _wfd;         // 管道写端(主进程用它向子进程发任务)
      std::string _name;// 通道名称(调试用)
      pid_t _who;       // 对应的子进程ID
      };
      #endif
2. Task.hpp(任务管理)
#ifndef __TASK_HPP__
#define __TASK_HPP__
#include <iostream>
  #include <vector>
    #include <functional> // 用于std::function(函数对象)
      #include <ctime>      // 用于srand()
        #include <unistd.h>   // 用于getpid()
          // 定义任务类型:无参数、无返回值的函数对象
          using task_t = std::function<void()>;
            // TaskManger类:管理任务列表,支持选择和执行任务
            class TaskManger {
            public:
            // 构造函数:初始化任务列表(添加4种模拟任务)
            TaskManger() {
            srand(time(nullptr)); // 初始化随机数种子(用于随机选择任务)
            // 任务1:模拟访问数据库
            tasks.push_back([]() {
            std::cout << "sub process[" << getpid() << "] 执行访问数据库的任务\n" << std::endl;
            });
            // 任务2:模拟URL解析
            tasks.push_back([]() {
            std::cout << "sub process[" << getpid() << "] 执行URL解析任务\n" << std::endl;
            });
            // 任务3:模拟加密任务
            tasks.push_back([]() {
            std::cout << "sub process[" << getpid() << "] 执行加密任务\n" << std::endl;
            });
            // 任务4:模拟数据持久化
            tasks.push_back([]() {
            std::cout << "sub process[" << getpid() << "] 执行数据持久化任务\n" << std::endl;
            });
            }
            // 随机选择一个任务:返回任务在列表中的索引(0~3)
            int SelectTask() {
            return rand() % tasks.size(); // rand()生成0~RAND_MAX的随机数,取模后范围0~3
            }
            // 执行指定任务:根据任务索引执行对应的任务
            void Execute(unsigned long number) {
            // 检查任务索引是否合法(避免越界)
            if (number >= tasks.size() || number < 0) {
            std::cerr << "invalid task number: " << number << std::endl;
            return;
            }
            tasks[number](); // 执行任务(函数对象调用)
            }
            private:
            std::vector<task_t> tasks; // 存储所有任务的列表
              };
              // 全局任务管理器对象:主进程和子进程都可访问
              TaskManger tm;
              // Worker函数:子进程的核心逻辑(循环读任务、执行任务)
              void Worker() {
              while (true) {
              int cmd = 0; // 存储从管道读取的任务指令(任务索引)
              // 从管道读任务指令:子进程的标准输入(0)已重定向为管道读端(见ProcessPool::InitProcessPool)
              int n = ::read(0, &cmd, sizeof(cmd));
              if (n == sizeof(cmd)) { // 成功读取到完整的任务指令(4字节)
              tm.Execute(cmd);    // 执行任务
              } else if (n == 0) {    // 读取到EOF(主进程关闭了写端)
              std::cout << "pid: " << getpid() << " 收到退出信号,退出..." << std::endl;
              break;              // 退出循环,子进程结束
              } else {                // 读失败(如管道错误)
              std::cerr << "pid: " << getpid() << " read task failed" << std::endl;
              break;
              }
              }
              }
              #endif
3. ProcessPool.hpp(进程池核心)
#ifndef __PROCESS_POOL_HPP__
#define __PROCESS_POOL_HPP__
#include <iostream>
  #include <string>
    #include <vector>
      #include <cstdlib>
        #include <unistd.h>
          #include <sys/types.h>
            #include <sys/wait.h>
              #include <functional>
                #include "Task.hpp"
                #include "Channel.hpp"
                // 定义任务执行函数类型:与Worker函数签名一致
                using work_t = std::function<void()>;
                  // 定义错误码:便于主进程判断初始化失败原因
                  enum ErrorCode {
                  OK = 0,         // 成功
                  UsageError = 1, // 参数错误
                  PipeError = 2,  // 管道创建失败
                  ForkError = 3   // 子进程创建失败
                  };
                  // ProcessPool类:管理子进程、管道和任务派发
                  class ProcessPool {
                  public:
                  // 构造函数:初始化进程池大小和任务执行函数
                  ProcessPool(int n, work_t w) : processnum(n), work(w) {}
                  // 初始化进程池:创建子进程和管道,建立通信通道
                  int InitProcessPool() {
                  // 循环创建processnum个子进程
                  for (int i = 0; i < processnum; i++) {
                  int pipefd[2] = {0}; // 存储当前子进程的管道读端(pipefd[0])和写端(pipefd[1])
                  // 1. 创建管道:为当前子进程单独创建一个管道
                  if (pipe(pipefd) < 0) {
                  perror("pipe failed");
                  return PipeError; // 返回管道创建失败错误码
                  }
                  // 2. 创建子进程
                  pid_t id = fork();
                  if (id < 0) {
                  perror("fork failed");
                  return ForkError; // 返回子进程创建失败错误码
                  }
                  // 3. 子进程逻辑:初始化管道读端,执行Worker函数
                  if (id == 0) {
                  // 关闭子进程继承的所有历史管道写端(避免资源泄漏)
                  std::cout << getpid() << ", child close history fd: ";
                  for (auto &c : channels) {
                  std::cout << c.wFd() << " ";
                  c.Close(); // 关闭历史通道的写端
                  }
                  std::cout << "over" << std::endl;
                  close(pipefd[1]); // 子进程不需要写,关闭当前管道的写端
                  // 将管道读端重定向到标准输入(0):Worker函数直接从0读任务
                  dup2(pipefd[0], 0);
                  std::cout << "debug: child " << getpid() << " pipe read fd: " << pipefd[0] << std::endl;
                  work(); // 执行Worker函数(子进程的核心逻辑)
                  exit(0); // 子进程执行完任务后退出
                  }
                  // 4. 父进程逻辑:保存当前管道的写端和子进程ID到Channel
                  close(pipefd[0]); // 父进程不需要读,关闭当前管道的读端
                  // 向通道列表添加新通道(管道写端+子进程ID)
                  channels.emplace_back(pipefd[1], id);
                  }
                  return OK; // 初始化成功
                  }
                  // 派发任务:向子进程循环发送20个任务,采用轮询(Round-Robin)策略
                  void DispatchTask() {
                  int who = 0;          // 当前选中的子进程索引(用于轮询)
                  int total_tasks = 20; // 总任务数
                  while (total_tasks--) {
                  // 1. 随机选择一个任务(获取任务索引)
                  int task_idx = tm.SelectTask();
                  // 2. 轮询选择子进程:who从0到channels.size()-1循环
                  Channel &curr_channel = channels[who++];
                  who %= channels.size(); // 取模实现循环
                  // 3. 打印任务派发信息(调试用)
                  std::cout << "######################" << std::endl;
                  std::cout << "send task " << task_idx << " to " << curr_channel.Name()
                  << ", 剩余任务数: " << total_tasks << std::endl;
                  std::cout << "######################" << std::endl;
                  // 4. 向子进程发送任务指令
                  curr_channel.Send(task_idx);
                  sleep(1); // 休眠1秒,模拟任务间隔(便于观察输出)
                  }
                  }
                  // 清理进程池:关闭所有管道写端,等待子进程退出
                  void CleanProcessPool() {
                  // 1. 关闭所有管道写端:子进程会收到EOF,触发退出
                  for (auto &c : channels) {
                  c.Close();
                  }
                  // 2. 等待所有子进程退出:避免僵尸进程
                  for (auto &c : channels) {
                  pid_t rid = ::waitpid(c.Id(), nullptr, 0); // 等待指定子进程
                  if (rid > 0) {
                  std::cout << "child process " << rid << " exited successfully" << std::endl;
                  }
                  }
                  }
                  // 调试用:打印所有通道信息
                  void DebugPrint() {
                  std::cout << "all channels: " << std::endl;
                  for (auto &c : channels) {
                  std::cout << c.Name() << std::endl;
                  }
                  }
                  private:
                  std::vector<Channel> channels; // 存储所有主-子通信通道
                    int processnum;                // 子进程数量(进程池大小)
                    work_t work;                   // 子进程执行的任务函数(Worker)
                    };
                    #endif
4. Main.cc(主程序入口)
#include "ProcessPool.hpp"
#include "Task.hpp"
// 打印用法:若用户参数错误,提示正确的命令格式
void Usage(std::string proc) {
std::cout << "Usage: " << proc << " <process-number>" << std::endl;
  std::cout << "Example: " << proc << " 3 (create 3 worker processes)" << std::endl;
  }
  int main(int argc, char *argv[]) {
  // 检查参数数量:主程序需要1个参数(子进程数量)
  if (argc != 2) {
  Usage(argv[0]);
  return UsageError; // 参数错误,退出
  }
  // 将参数转换为整数(子进程数量)
  int worker_num = std::stoi(argv[1]);
  // 检查子进程数量是否合法(至少1个)
  if (worker_num <= 0) {
  std::cerr << "error: process-number must be > 0" << std::endl;
    return UsageError;
    }
    // 1. 创建进程池:指定子进程数量和任务函数(Worker)
    ProcessPool *pp = new ProcessPool(worker_num, Worker);
    // 2. 初始化进程池:创建子进程和管道
    int init_ret = pp->InitProcessPool();
    if (init_ret != OK) {
    std::cerr << "process pool init failed, error code: " << init_ret << std::endl;
    delete pp;
    return init_ret;
    }
    // 3. 派发任务:向子进程发送任务
    pp->DispatchTask();
    // 4. 清理进程池:关闭管道,等待子进程退出
    pp->CleanProcessPool();
    // 释放进程池内存
    delete pp;
    return 0;
    }
5. Makefile(编译脚本)
# 可执行文件名称
BIN=processpool
# 编译器
CC=g++
# 编译选项:-c(生成目标文件)、-Wall(显示所有警告)、-std=c++11(支持C++11标准)
FLAGS=-c -Wall -std=c++11
# 链接选项:生成可执行文件
LDFLAGS=-o
# 查找所有.cc源文件
SRC=$(wildcard *.cc)
# 将.cc文件转换为.o目标文件(如Main.cc → Main.o)
OBJ=$(SRC:.cc=.o)
# 生成可执行文件:依赖所有.o文件
$(BIN):$(OBJ)
	$(CC) $(LDFLAGS) $@ $^
# 生成目标文件:每个.cc文件对应一个.o文件
%.o:%.cc
	$(CC) $(FLAGS) $< -o $@
# 伪目标:清理编译产物(可执行文件和目标文件)
.PHONY:clean
clean:
	rm -f $(BIN) $(OBJ)
# 伪目标:测试(打印源文件和目标文件列表)
.PHONY:test
test:
	@echo "source files: " $(SRC)
	@echo "object files: " $(OBJ)
进程池工作流程讲解
  1. 初始化阶段
    • 主进程创建ProcessPool对象,指定子进程数量(如 3)和任务函数(Worker)。
    • 调用InitProcessPool():循环创建子进程,每个子进程对应一个管道,主进程保存管道写端到Channel列表。
    • 子进程将管道读端重定向到标准输入(0),便于Worker函数统一读取任务。
  2. 任务派发阶段
    • 主进程调用DispatchTask(),循环 20 次派发任务。
    • 每次随机选择一个任务(通过TaskManger::SelectTask()),轮询选择子进程(避免单个子进程过载)。
    • 主进程通过Channel::Send()向子进程发送任务索引,子进程从标准输入读取后执行任务。
  3. 清理阶段
    • 主进程调用CleanProcessPool(),关闭所有管道写端(子进程收到 EOF 后退出)。
    • 主进程用waitpid()等待所有子进程退出,避免僵尸进程。

3.5 匿名管道的读写规则

管道的读写行为受 “是否阻塞”(O_NONBLOCK标志)和 “管道状态”(空 / 满、读写端是否关闭)影响,核心规则如下:

管道状态O_NONBLOCK=0(阻塞模式)O_NONBLOCK=1(非阻塞模式)
管道无数据(读端)read()阻塞,直到有数据写入read()返回 - 1,errno=EAGAIN(重试)
管道满(写端)write()阻塞,直到有数据被读取write()返回 - 1,errno=EAGAIN(重试)
所有写端关闭(读端)read()返回 0(EOF)read()返回 0(EOF)
所有读端关闭(写端)write()触发 SIGPIPE 信号,进程默认退出write()返回 - 1,errno=EPIPE
写入数据≤PIPE_BUF保证原子性(数据不会被拆分)保证原子性
写入数据 > PIPE_BUF不保证原子性(数据可能被拆分)不保证原子性

关键概念PIPE_BUF是管道的原子写入最大字节数,可通过fcntl(fd, F_GETPIPE_SZ)获取,默认通常为 4096 字节(与内存页大小一致)。

3.6 匿名管道的特点总结

  1. 通信范围限制仅支持父 / 子、兄弟等有亲缘关系的进程通信,需通过fork()继承管道的文件描述符实现;
  2. 通信方向特性半双工单向通信,数据仅能单方向流动,若需双向通信需创建两个独立管道;
  3. 数据传输形式面向字节流传输,无固定数据边界,通信双方需自行约定数据解析规则;
  4. 生命周期规则生命周期与持有管道文件描述符的进程绑定,所有相关进程退出后,内核自动释放管道资源;
  5. 同步互斥机制内核自带同步(如读端等待写端数据、写端等待读端取数)与互斥(数据量≤PIPE_BUF时保证写入原子性)能力,无需手动实现。

在这里插入图片描述

四、命名管道(FIFO):无亲缘进程的 “公共通道”

匿名管道的最大限制是 “仅支持亲缘进程”,而命名管道(FIFO,First-In-First-Out)通过文件系统中的路径名标识,突破了这一限制,支持任意两个进程(甚至无亲缘关系)通信。

4.1 命名管道的核心特点

  • 存在于文件系统:命名管道会在文件系统中创建一个 “特殊文件”(类型为p),可用ls -l查看,如:prw-r--r-- 1 root root 0 Oct 1 10:00 myfifo
  • 本质仍是内核缓冲区:虽然在文件系统中可见,但命名管道的数据仍存储在内核缓冲区,不占用磁盘空间(关闭后数据消失)。
  • 通信无亲缘限制:任意进程只要知道 FIFO 的路径名,即可通过open()打开并通信。

4.2 命名管道的创建方式

命名管道有两种创建方式:命令行创建和代码创建。

方式 1:命令行创建(mkfifo 命令)
# 格式:mkfifo [选项] 路径名
mkfifo /tmp/myfifo  # 在/tmp目录下创建名为myfifo的FIFO
ls -l /tmp/myfifo   # 查看FIFO文件:类型为p(pipe)
方式 2:代码创建(mkfifo 函数)
#include <sys/types.h>
  #include <sys/stat.h>
    // 功能:创建命名管道
    // 参数:pathname:FIFO的路径名(如"/tmp/myfifo")
    //      mode:权限位(如0644,表示所有者读/写,其他读)
    // 返回值:成功返回0,失败返回-1(设置errno)
    int mkfifo(const char *pathname, mode_t mode);

注意mode参数会受umask影响,实际权限为mode & ~umask。若要确保权限正确,可先调用umask(0)清除掩码。

4.3 命名管道的打开规则

与普通文件不同,FIFO 的open()行为有特殊规则,核心取决于 “打开模式”(读 / 写)和 “是否阻塞”(O_NONBLOCK):

打开模式O_NONBLOCK=0(阻塞模式)O_NONBLOCK=1(非阻塞模式)
读模式(O_RDONLY)阻塞,直到有进程以写模式打开该 FIFO立即返回成功(即使无写进程)
写模式(O_WRONLY)阻塞,直到有进程以读模式打开该 FIFO立即返回失败,errno=ENXIO(无读进程)
读写模式(O_RDWR)立即返回成功(不阻塞,因为自身可读写)立即返回成功

关键结论:在阻塞模式下,“读进程” 会等待 “写进程” 打开 FIFO,反之亦然,这确保了通信双方都准备就绪后才开始传输数据。

4.4 命名管道实战 1:实现文件拷贝

下面的代码用两个无亲缘关系的进程(读文件进程和写文件进程)通过 FIFO 实现文件拷贝,流程如下:

  1. 进程 A:读取源文件(abc)→ 写入 FIFO(tp)。
  2. 进程 B:从 FIFO(tp)读数据 → 写入目标文件(abc.bak)。
进程 A:读文件→写 FIFO(fifo_write.cc)
#include <unistd.h>
  #include <stdlib.h>
    #include <stdio.h>
      #include <errno.h>
        #include <string.h>
          #include <sys/stat.h>
            #include <sys/types.h>
              #include <fcntl.h>
                // 错误处理宏:打印错误信息并退出
                #define ERR_EXIT(m) \
                do { \
                perror(m); \
                exit(EXIT_FAILURE); \
                } while(0)
                int main() {
                // 1. 创建FIFO(若已存在,mkfifo会返回EEXIST,可忽略)
                if (mkfifo("tp", 0644) < 0 && errno != EEXIST) {
                ERR_EXIT("mkfifo failed");
                }
                // 2. 打开源文件(abc):只读模式
                int infd = open("abc", O_RDONLY);
                if (infd == -1) {
                ERR_EXIT("open source file failed");
                }
                // 3. 打开FIFO(tp):只写模式(阻塞,直到有读进程打开)
                int outfd = open("tp", O_WRONLY);
                if (outfd == -1) {
                ERR_EXIT("open fifo failed");
                }
                char buf[1024] = {0}; // 数据缓冲区(1KB)
                int n;                // 实际读取的字节数
                // 4. 循环读源文件→写FIFO
                while ((n = read(infd, buf, sizeof(buf))) > 0) {
                // 确保所有数据都写入FIFO(避免部分写入)
                if (write(outfd, buf, n) != n) {
                ERR_EXIT("write to fifo failed");
                }
                memset(buf, 0, sizeof(buf)); // 清空缓冲区
                }
                // 5. 关闭文件描述符
                close(infd);
                close(outfd);
                printf("write process: file copy to fifo done\n");
                return 0;
                }
进程 B:读 FIFO→写文件(fifo_read.cc)
#include <unistd.h>
  #include <stdlib.h>
    #include <stdio.h>
      #include <errno.h>
        #include <string.h>
          #include <sys/stat.h>
            #include <sys/types.h>
              #include <fcntl.h>
                #define ERR_EXIT(m) \
                do { \
                perror(m); \
                exit(EXIT_FAILURE); \
                } while(0)
                int main() {
                // 1. 打开目标文件(abc.bak):只写+创建+截断(若已存在则清空)
                int outfd = open("abc.bak", O_WRONLY | O_CREAT | O_TRUNC, 0644);
                if (outfd == -1) {
                ERR_EXIT("open target file failed");
                }
                // 2. 打开FIFO(tp):只读模式(阻塞,直到有写进程打开)
                int infd = open("tp", O_RDONLY);
                if (infd == -1) {
                ERR_EXIT("open fifo failed");
                }
                char buf[1024] = {0};
                int n;
                // 3. 循环读FIFO→写目标文件
                while ((n = read(infd, buf, sizeof(buf))) > 0) {
                if (write(outfd, buf, n) != n) {
                ERR_EXIT("write to target file failed");
                }
                memset(buf, 0, sizeof(buf));
                }
                // 4. 关闭文件描述符,删除FIFO(避免残留)
                close(infd);
                close(outfd);
                unlink("tp"); // 删除FIFO文件(从文件系统中移除)
                printf("read process: file copy from fifo done\n");
                return 0;
                }
编译与运行
# 编译两个进程
g++ fifo_write.cc -o fifo_write
g++ fifo_read.cc -o fifo_read
# 准备源文件(如创建一个abc文件)
echo "this is a test file for fifo copy" > abc
# 打开两个终端,分别运行
# 终端1:运行读进程(会阻塞,等待写进程)
./fifo_read
# 终端2:运行写进程(开始拷贝)
./fifo_write
# 查看结果
cat abc.bak # 应与abc内容一致

4.5 命名管道实战 2:实现 Server-Client 通信

下面的代码用 FIFO 实现简单的客户端 - 服务器通信:

  • Server:创建 FIFO,阻塞等待 Client 连接,接收 Client 发送的消息并打印。
  • Client:打开 Server 创建的 FIFO,向 Server 发送消息(从键盘输入)。
Server 代码(fifo_server.c)
#include <stdio.h>
  #include <sys/types.h>
    #include <sys/stat.h>
      #include <fcntl.h>
        #include <unistd.h>
          #include <stdlib.h>
            #include <string.h>
              #define ERR_EXIT(m) \
              do { \
              perror(m); \
              exit(EXIT_FAILURE); \
              } while(0)
              #define FIFO_NAME "mypipe" // FIFO路径名(与Client一致)
              int main() {
              umask(0); // 清除umask,确保FIFO权限正确(0644)
              // 1. 创建FIFO(若已存在,忽略EEXIST错误)
              if (mkfifo(FIFO_NAME, 0644) < 0 && errno != EEXIST) {
              ERR_EXIT("mkfifo failed");
              }
              // 2. 打开FIFO:只读模式(阻塞,等待Client连接)
              int rfd = open(FIFO_NAME, O_RDONLY);
              if (rfd < 0) {
              ERR_EXIT("open fifo failed");
              }
              printf("server: waiting for client message...\n");
              char buf[1024] = {0};
              ssize_t s; // 存储read()返回值(ssize_t支持负数值表示错误)
              // 3. 循环读Client消息
              while (1) {
              memset(buf, 0, sizeof(buf)); // 清空缓冲区
              // 读FIFO:阻塞,直到有数据或Client关闭写端
              s = read(rfd, buf, sizeof(buf) - 1); // 留1字节存'\0'
              if (s > 0) {
              buf[s] = '\0'; // 手动添加字符串结束符
              printf("client say: %s\n", buf);
              } else if (s == 0) {
              // 读返回0:Client关闭了写端,Server退出
              printf("client quit, server exit now!\n");
              break;
              } else {
              ERR_EXIT("read from client failed");
              }
              }
              // 4. 关闭FIFO,删除文件
              close(rfd);
              unlink(FIFO_NAME);
              return 0;
              }
Client 代码(fifo_client.c)
#include <stdio.h>
  #include <sys/types.h>
    #include <sys/stat.h>
      #include <fcntl.h>
        #include <unistd.h>
          #include <stdlib.h>
            #include <string.h>
              #define ERR_EXIT(m) \
              do { \
              perror(m); \
              exit(EXIT_FAILURE); \
              } while(0)
              #define FIFO_NAME "mypipe" // 必须与Server的FIFO路径一致
              int main() {
              // 1. 打开FIFO:只写模式(阻塞,等待Server打开读端)
              int wfd = open(FIFO_NAME, O_WRONLY);
              if (wfd < 0) {
              ERR_EXIT("open fifo failed");
              }
              char buf[1024] = {0};
              ssize_t s;
              // 2. 循环从键盘读数据→发送给Server
              while (1) {
              memset(buf, 0, sizeof(buf));
              printf("please enter message (enter 'quit' to exit): ");
              fflush(stdout); // 刷新stdout,确保提示语立即显示
              // 从键盘读数据(stdin的文件描述符是0)
              s = read(0, buf, sizeof(buf) - 1);
              if (s > 0) {
              // 去掉换行符(键盘输入以\n结束)
              if (buf[s-1] == '\n') {
              buf[s-1] = '\0';
              }
              // 若输入"quit",退出循环
              if (strcmp(buf, "quit") == 0) {
              break;
              }
              // 向Server发送消息
              write(wfd, buf, strlen(buf));
              } else if (s <= 0) {
              ERR_EXIT("read from keyboard failed");
              }
              }
              // 3. 关闭FIFO
              close(wfd);
              printf("client: exit successfully\n");
              return 0;
              }
编译与运行
# 编译Server和Client
gcc fifo_server.c -o fifo_server
gcc fifo_client.c -o fifo_client
# 打开两个终端
# 终端1:运行Server(阻塞等待Client)
./fifo_server
# 终端2:运行Client(输入消息发送给Server)
./fifo_client
# 输入示例:hello server → Server会打印"client say: hello server"
# 输入quit → Client退出,Server也退出

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

特性匿名管道(pipe)命名管道(FIFO)
创建方式pipe()函数mkfifo()函数或mkfifo命令
标识方式文件描述符(无名称)文件系统路径名(有名称)
通信进程限制仅支持亲缘进程(父 / 子、兄弟)支持任意进程(无亲缘关系)
打开方式无需open()pipe()直接创建并打开需要open()打开(指定路径名)
文件系统存在不存在(仅内核缓冲区)存在(特殊文件,类型为 p)
生命周期随进程(所有持有 fd 的进程退出后释放)随内核(需手动unlink()删除文件)
读写语义相同(均通过read()/write()相同

五、总结与思考

本文详细讲解了 Linux 进程间通信的核心技术 —— 管道,包括匿名管道和命名管道的原理、代码实现及应用场景。我们可以总结出以下关键知识点:

  1. 管道的本质:内核维护的临时缓冲区,基于 “一切皆文件” 思想,操作与文件一致。
  2. 匿名管道的核心:依赖fork()继承文件描述符,仅支持亲缘进程,适合父子进程间的简单通信(如进程池)。
  3. 命名管道的核心:通过文件系统路径名标识,支持任意进程通信,适合无亲缘关系的进程(如 Server-Client)。
  4. 管道的读写规则:阻塞 / 非阻塞模式下的行为差异,以及原子写入的限制(PIPE_BUF)。

下一篇文章,我们将继续讲解 System V IPC 中的共享内存、消息队列和信号量,深入探讨更高效、更灵活的进程间通信技术。

posted on 2025-10-28 11:59  mthoutai  阅读(43)  评论(0)    收藏  举报