Skip to content

第三十三章:管道

筑基后期

涉及内核源码:

林小源在观察 shell 小妹的过程中,看到了一个他之前忽略的机制。

shell 小妹执行 ls | grep foo 时,创建了两个子进程。第一个子进程执行 ls,第二个子进程执行 grep。它们之间通过一个管道连接——ls 的输出是 grep 的输入。

管道是 Unix 世界中最古老的进程间通信方式。它简单、优雅、高效。

林小源走近那条管道,看到它是一条细细的水流通道。ls 端把数据倒进去,grep 端从另一头接住。水流是单向的——只能从写端流向读端。

"你是什么?"林小源问管道。

"我是管道。"管道的声音像水流一样轻柔。"内核为我分配了一个环形缓冲区——一个循环的水池。写端把水注进来,读端把水取走。水池满了,写端就等着;水池空了,读端就等着。"

c
/*
 * 管道的内核实现:
 * 1. pipe() 创建两个文件描述符:fd[0](读端)、fd[1](写端)
 * 2. 内核分配一个环形缓冲区(pipe buffer)
 * 3. 写端写入数据到缓冲区
 * 4. 读端从缓冲区读取数据
 * 5. 缓冲区满时,写端阻塞
 * 6. 缓冲区空时,读端阻塞
 *
 * 管道的特点:
 * - 单向通信(一个读端,一个写端)
 * - 字节流(没有消息边界)
 * - 亲缘关系(通常用于父子进程之间)
 * - 缓冲区大小有限(通常 64KB)
 */

struct pipe_buffer {
    char data[64];      /* 环形缓冲区 */
    int read_pos;       /* 读位置 */
    int write_pos;      /* 写位置 */
    int count;          /* 数据量 */
    int capacity;       /* 缓冲区容量 */
};

int pipe_write(struct pipe_buffer *pipe, const char *data, int len) {
    int written = 0;
    for (int i = 0; i < len && pipe->count < pipe->capacity; i++) {
        pipe->data[pipe->write_pos] = data[i];
        pipe->write_pos = (pipe->write_pos + 1) % pipe->capacity;
        pipe->count++;
        written++;
    }
    return written;
}

int pipe_read(struct pipe_buffer *pipe, char *buf, int len) {
    int read = 0;
    for (int i = 0; i < len && pipe->count > 0; i++) {
        buf[i] = pipe->data[pipe->read_pos];
        pipe->read_pos = (pipe->read_pos + 1) % pipe->capacity;
        pipe->count--;
        read++;
    }
    return read;
}

printf("=== 管道 — 最古老的 IPC ===\n\n");

struct pipe_buffer pipe = { .read_pos = 0, .write_pos = 0,
                             .count = 0, .capacity = 64 };

/* 模拟 ls | grep foo */
printf("--- ls | grep foo ---\n\n");

/* ls 进程写入管道 */
const char *ls_output = "file1.txt\nfile2.txt\nfoo.c\nbar.c\n";
printf("[ls] 写入管道: %d 字节\n", pipe_write(&pipe, ls_output, strlen(ls_output)));

/* grep 进程从管道读取 */
char buf[64];
int n = pipe_read(&pipe, buf, sizeof(buf) - 1);
buf[n] = '\0';

printf("[grep] 从管道读取: %d 字节\n", n);
printf("[grep] 内容:\n");
printf("---\n%s---\n", buf);

printf("\n--- 管道的阻塞行为 ---\n");
printf("缓冲区空 → read() 阻塞(等待数据)\n");
printf("缓冲区满 → write() 阻塞(等待空间)\n");
printf("写端关闭 → read() 返回 0(EOF)\n");
printf("读端关闭 → write() 收到 SIGPIPE\n");

printf("\n--- 管道的生命周期 ---\n");
printf("1. pipe() 创建管道\n");
printf("2. fork() 创建子进程,所有文件描述符被复制\n");
printf("3. 父进程关闭读端,子进程关闭写端\n");
printf("4. 管道建立单向通信\n");
printf("5. 通信结束,关闭管道\n");

"你的缓冲区有多大?"林小源问。

"通常 64KB。"管道说,"够用了。ls 的输出不会太大,grep 处理得也很快。如果 ls 输出太多,grep 来不及读,我就让 ls 等着。反过来,如果我空了,grep 就等着。"

"你是怎么创建的?"

"shell 小妹在 fork 子进程之前调用 。"管道说,"内核给我分配两个文件描述符:fd[0] 是读端,fd[1] 是写端。然后 shell 小妹 fork——两个子进程都继承了这两个文件描述符。父进程关闭读端,子进程关闭写端,单向通信就建立了。"

| 符号的背后,是一条环形水流通道。

林小源在研究管道的过程中,发现了一个有趣的设计。

管道的缓冲区是一个环形缓冲区——数据从 写入,从 读出。当 到达缓冲区末尾时,它会回绕到开头。这种设计避免了数据移动——不需要 ,只需要调整位置指针。

"你的水流是环形的?"林小源问。

"当然。"管道说,"如果水流是直线的,写到最后就没地方了。环形设计让我可以循环使用同一块内存——写到末尾就绕回开头,读到末尾也绕回开头。不需要搬移数据,效率高。"

"但你是单向的。"

"对。"管道说,"fd[0] 只能读,fd[1] 只能写。如果两个进程需要双向通信,需要创建两个管道。"

"为什么不设计成双向的?"

管道沉默了一瞬。"因为简单。单向管道没有方向冲突,没有读写竞争。Unix 的哲学是:做一件事,做好它。我做的是单向字节流传输——仅此而已。"

管道的简单性,也是它的局限性。

林小源在观察 shell 小妹的管道操作时,注意到了一个细节。

shell 小妹在 fork 子进程之前,先调用 创建管道。然后 fork 之后,父进程关闭管道的读端,子进程关闭管道的写端。这样就建立了一个从父进程到子进程的单向数据通道。

"关闭也是一种建立。"林小源低声说。

"没错。"管道说,"管道的建立不是'打开'什么——而是'关闭'多余的端。父进程不需要读,就关掉读端;子进程不需要写,就关掉写端。剩下的,就是一条干净的单向通道。"

林小源想起了 Unix 的哲学:"一切皆文件"。管道也是文件——它有文件描述符,可以 read()write()。但管道和普通文件不同:管道的数据不存储在磁盘上,只存在于内存中。读过的数据就消失了。

"你是文件吗?"林小源问。

"我是文件。"管道说,"但我是'一次性'的文件。我的数据不落盘——读了就没了。我不像普通文件那样可以 ——我的水流只能向前,不能回头。"

"那你的生命周期呢?"

"从 开始,到所有文件描述符关闭结束。"管道说,"写端关闭,读端收到 EOF;读端关闭,写端收到 。当两端都关闭,内核释放我的缓冲区。我就不存在了。"

管道是"一次性"的文件——用完即弃,不落痕迹。


道藏笔记

内核启示

管道是 Unix 世界中最古老的进程间通信方式。

管道的内核实现:

  1. 创建两个文件描述符:fd[0](读端)、fd[1](写端)
  2. 内核分配一个环形缓冲区(
  3. write() 写入数据到缓冲区
  4. read() 从缓冲区读取数据
  5. 缓冲区满时,write() 阻塞
  6. 缓冲区空时,read() 阻塞

管道的特点:

  • 单向通信(一个读端,一个写端)
  • 字节流(没有消息边界)
  • 亲缘关系(通常用于父子进程之间)
  • 缓冲区大小有限(通常 64KB)
  • 数据不存储在磁盘上(内存中的临时数据)

管道的典型用法:

  • ls | grep foo — shell 管道命令
  • 父子进程之间的通信
  • 守护进程的日志收集

管道是 Unix 哲学的体现:简单、优雅、组合性强。


破关试炼

管道之试

管道内部用来循环保存写入数据、避免频繁搬移的缓冲结构叫什么?

答对后才能继续滑动和进入下一章。

以修仙之名,悟内核之道