前面几课,我们认识了进程、线程、fd、fork/exec、信号。但你有没有发现一个问题:这些"孤岛"之间,怎么交换数据? 进程 A 算出一个结果,进程 B 怎么才能拿到?这一课,我们就来解决进程之间"说话"的问题——进程间通信(IPC,Inter-Process Communication)

从"ls | wc"说起

你在终端敲下:

ls | wc -l

ls 列出当前目录的文件,wc -l 数一数有多少行。问题是:ls 的输出,是怎么跑到 wc 的输入里去的?

答案就是这一课的主角——管道(pipe)。而管道,正是"进程间通信"最经典的一种方式。这一课,我们把它彻底搞懂。


9.1 为什么需要 IPC:进程天生是"孤岛"

回想第 4 课:每个进程有独立的内存空间。A 进程里的变量,B 进程根本看不见——哪怕你俩是父子关系。

一句话理解:进程内存相互独立,A 进程的数据 B 进程看不见。要交换数据,必须借助内核提供的"通信通道"。

这就是 IPC 存在的根本原因:进程之间没有共享内存,只能靠操作系统当"信使",帮你传话。 内核提供了好几种"传话通道",我们一个个看。


9.2 管道(pipe):最经典的 IPC

先看最经典、也是你最熟悉的一个——管道

一句话理解pipe() 创建一条单向数据通道,给出一对 fd:一个写端、一个读端。写进去的数据,按顺序从读端读出来。

还记得第 6 课的"一切皆文件"吗?管道也是"文件"的一种——它的两个端点,就是两个文件描述符

  • fd[0]:读端(从这个 fd 读数据);
  • fd[1]:写端(往这个 fd 写数据)。

于是你就能理解了:管道本质上就是"用两个 fd 连起来的一条数据管道"。往写端 write,从读端 read,数据就流过去了。这也正是 ls | wc 的底层:

  • ls 的 fd 1(stdout)接到管道写端;
  • wc 的 fd 0(stdin)接到管道读端;
  • ls 一输出,数据就顺着管道流进 wc

(如果你还记得第 7 课,这个"改 fd 指向"的窗口期,正是 fork 之后、exec 之前完成的——现在全都串起来了。)

但管道有两个局限,要记住:

  1. 单向、半双工:数据只能从写端流向读端,不能双向同时用;
  2. 通常只在有亲缘关系的进程间方便使用:因为管道的两个 fd,得通过 fork 继承才能传给子进程。两个毫无关系的进程,拿不到对方的 fd,用普通 pipe 就麻烦了。

9.3 命名管道(FIFO):给管道起个名字

普通管道没名字,只能靠 fork 传承。那"两个独立启动、毫无亲缘关系的进程"想通信,怎么办?答案:命名管道(FIFO)

一句话理解:管道没有名字,只能父子间传;命名管道在文件系统里有个"名字"(路径),让没有亲缘关系的进程也能通过它通信。

命名管道会在文件系统里"长"出一个路径(比如 /tmp/myfifo)。之后,任何两个进程,只要约定好读写同一个路径,就能通信——一个往 /tmp/myfifo 写,一个从 /tmp/myfifo 读。

它和普通文件的区别在于:命名管道是"先进先出"的流,数据读走了就没了,不会真的存到磁盘上(它更像一根水管,而不是一个储水罐)。


9.4 消息队列 / 共享内存 / socket

除了管道,内核还提供了好几种 IPC 机制,各有各的适用场景。一表看清:

机制特点适用场景
消息队列有"消息边界",可带类型结构化消息
共享内存最快(直接映射同一块内存)大数据量、高频
socket可跨机器网络通信(第 22 课)

逐个点一下:

  • 消息队列:管道是"字节流"(一股脑的字节,没有边界),消息队列则是"一条一条"的消息,还能给消息打标签分类。适合传结构化、有边界的消息。
  • 共享内存:最快的一种。它让两个进程直接映射同一块物理内存,一方写、另一方立刻能看到,中间不经过内核拷贝。适合大数据量、高频交换。代价是:既然共享,就得自己处理"同时写"的竞态问题(第 12~14 课的并发与同步,就是为它准备的)。
  • socket:最强大的一种——它不仅能本机通信,还能跨机器。第 22 课网络编程会专门讲它。
这里埋个伏笔:前面第 5 课说过"协程是并发的一种手段",现在又说"共享内存是最快的 IPC"。等到第 12 课,你会看到"共享内存"和"并发竞态"这对好兄弟是怎么纠缠在一起的。

动手实验:C 版 + Python 对照

C 版(父进程写管道,子进程读):

// lesson09.c —— 父进程写管道,子进程读
#include <stdio.h>
#include <unistd.h>
#include <string.h>

int main(void) {
    int fd[2];
    pipe(fd);               // fd[0]=读端, fd[1]=写端

    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:读
        close(fd[1]);                       // 关掉不用的写端
        char buf[64];
        read(fd[0], buf, sizeof(buf));      // 从读端读
        printf("子进程收到:%s\n", buf);
        close(fd[0]);
    } else {
        // 父进程:写
        close(fd[0]);                       // 关掉不用的读端
        write(fd[1], "hello from parent", 17);
        close(fd[1]);
    }
    return 0;
}

编译运行:

gcc lesson09.c -o lesson09 && ./lesson09

预期输出:子进程收到:hello from parent

注意两个细节:

  1. 父进程 close(fd[0])、子进程 close(fd[1])——各关掉自己不用的那一端,这是好习惯;
  2. 管道的读写,用的就是第 6 课学的 read/write 和 fd——"一切皆文件"再次发威

Python 对照版

import os

r, w = os.pipe()
pid = os.fork()
if pid == 0:
    os.close(w)
    print("子进程收到:", os.read(r, 64).decode())
    os.close(r)
else:
    os.close(r)
    os.write(w, b"hello from parent")
    os.close(w)

同样的一套:os.pipeos.fork → 各关一端 → 读写。语言换了,底子没换。


深入点:两个值得知道的细节

① 管道缓冲区:天然的"流量控制"

管道不是无限大的,它在内核里有一个固定大小的缓冲区(通常约 64KB)。

  • 如果写端写得太快,把缓冲区写满了,写端就会阻塞——等读端读走一些,腾出空间,才继续写;
  • 如果读端读得太快,把缓冲区读空了,读端也会阻塞——等写端写进新数据。
一句话理解:管道有固定大小缓冲区,写满则写端阻塞、读空则读端阻塞——这就是天然的"流量控制"。

所以你不用自己写"生产太快怎么办"的代码,管道已经替你挡了一手。这背后的"生产者—消费者"模型,第 13 课会正式展开。

② socket 也能做本机 IPC

| 是管道,但别以为跨机器通信才用 socket。同一台机器上,用 Unix domain socket 也能做 IPC,而且它是双向的,比单向的 pipe 更灵活。第 22 课会看到它。


小结与思考题

这一课,我们打通了进程之间的"语言不通"问题:

  • 进程内存独立,是 IPC 存在的根本原因——交换数据必须借内核的"通道";
  • 管道(pipe):单向数据流,一对 fd(读端/写端),是 ls | wc 的底层;
  • 命名管道(FIFO):给管道起个路径名,让无关进程也能通信;
  • 消息队列 / 共享内存 / socket:各有各的适用场景,共享内存最快,socket 可跨机器;
  • 管道缓冲区提供天然的流量控制:写满阻塞、读空阻塞。

留三个问题:

  1. 管道是单向还是双向的?写满/读空会发生什么?
  2. 两个完全没有亲缘关系的进程,能用普通 pipe 通信吗?要怎么办?
  3. 为什么共享内存是"最快"的 IPC?
到这里,第二阶段(系统编程入门)的四大件——fd、fork/exec、信号、IPC——全部收齐。从下一课开始,我们进入第三阶段:并发与同步。那里有个更"硬核"的问题在等你:当多个线程/进程同时抢同一个资源时,系统该怎么安排"谁先谁后"?答案的第一块拼图,是 CPU 调度

标签: 计算机基础, 操作系统, Linux

添加新评论