Linux I/O 演进史:从管道到零拷贝,一篇串起11个服务端核心原语

本文是 Linux 高性能服务器开发系列的第四篇,承接前三篇《吃透Linux/C++系统编程:文件与I/O操作从入门到避坑》《TCP/IP 协议:高性能服务器的底层基石》《Linux 网络编程核心 API 速查手册》,深入讲解 Linux 服务端 I/O 的演进逻辑与零拷贝优化,从底层原理到代码落地,构建完整的高性能服务器开发知识体系。

彻底搞懂 fcntl/pipe/dup/CGI/mmap/sendfile/splice/tee 的底层逻辑


为什么要搞懂这一串 Linux I/O 原语

当你在浏览器输入网址按下回车,静态文件被快速返回;当你用反向代理转发 TCP 流量;当你用一行管道命令cat log.txt | grep error过滤日志------背后支撑这一切的,正是本文要讲的这11个Linux核心I/O原语。

很多开发者对它们的认知停留在「背过API参数、应付过面试」,却始终找不到它们之间的关联:dup/dup2CGI有什么关系?pipe为什么是splice/tee的核心?sendfilemmap到底怎么选?

本文我们将沿着基础能力建设→经典场景落地→零拷贝极致优化的完整演讲路径,把所有技术点串成一条完整的逻辑链,搞懂每一个技术的出现背景,解决的痛点,以及在Linux I/O体系中的位置。

核心公识:Linux中一切皆文件,所有I/O的核心载体,都是文件描述符(fd)


I/O的控制中枢:fcntl,fd的「万能工具箱」

我把fcntl放在最开头,因为它是所有I/O操作的幕后控制者------你后面看到的所有技术,几乎都离不开它的辅助。

fcntl核心定位,是对文件描述符的属性做精细化控制,它的核心能力刚好覆盖率后续所有场景的基础需求:

  1. 修改fd的阻塞/非阻塞模式 :通过O_NONBLOCK标志,为后续的管道、socket I/O提供非阻塞能力;
  2. 复制文件描述符 :通过F_DUPFD实现和dup/dup2同源的fd复制能力;
  3. 设置FD_CLOEXEC标志 :控制进程exec执行时是否关闭fd,是CGI实现的关键细节;
  4. 调整管道缓冲区大小 :通过F_SETPIPE_SZ修改管道容量,是splice/tee性能调优的核心手段;
  5. 获取/修改文件状态:统一管理fd的权限、标志位,是所有I/O操作的基础。

一句话总结:fcntl是Linux I/O体系的「全局控制面板」,没有它,后续的所有I/O能力都无法灵活落地。

函数原型

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

// 核心函数:fd控制的万能入口
int fcntl(int fd, int cmd, ... /* arg */);
  • fd:目标文件描述符
  • cmd:控制命令(如 F_GETFL/F_SETFL/F_GETFD/F_SETFD/F_SETPIPE_SZ
  • ...:可变参数,根据 cmd 决定是否需要

核心代码示例

点击查看代码

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

// 1. 将fd设置为非阻塞模式
void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

// 2. 设置FD_CLOEXEC:exec时自动关闭fd
void set_cloexec(int fd) {
    int flags = fcntl(fd, F_GETFD, 0);
    fcntl(fd, F_SETFD, flags | FD_CLOEXEC);
}

// 3. 调整管道缓冲区大小为1MB
void set_pipe_size(int pipe_fd) {
    fcntl(pipe_fd, F_SETPIPE_SZ, 1024 * 1024);
}

基础I/O的第一次优化:readv/writev,告别冗余系统调用

有了fd的基础控制能力,我们先看最经典的I/O模式:read/write

传统模式的痛点

当我们需要读写多个分散的内存缓冲区时,比如HTTP响应要先写Header、再写Body,传统方案需要多次调用write,每一次调用都要触发「用户态→内核态」的上下文切换。高并发场景下,这些切换的CPU开销,甚至会超过数据拷贝本身。

解决方案:readv/writev

readv/writev被称为分散/聚集I/O,它用一次系统调用,就能完成多个不连续缓冲区的读写:

  • readv:从fd中读取数据,按顺序分散填充到多个缓冲区;
  • writev:把多个缓冲区的数据,按顺序聚集写入到fd中。

它的核心价值,就是把N次系统调用压缩为1次,大幅减少上下文切换的CPU开销 。比如HTTP响应的场景,用writev可以一次把Header和Body写入socket,无需两次write调用。

这也是我们第一次接触到Linux I/O优化的核心思路:能少一次系统调用,就少一次;能少一次数据拷贝,就少一次。这个思路,将贯穿后续整个零拷贝演进的全过程。

函数原型

cpp 复制代码
#include <sys/uio.h>
#include <unistd.h>

// 分散读:从fd读取数据,填充到多个iovec缓冲区
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);

// 聚集写:将多个iovec缓冲区的数据,一次性写入fd
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
  • ioviovec 结构体数组,每个元素描述一个缓冲区(地址+长度)
  • iovcntiov 数组的元素个数

核心代码示例:HTTP响应的writev实现

点击查看代码

cpp 复制代码
#include <sys/uio.h>
#include <unistd.h>
#include <cstring>
#include <string>

void send_http_response(int sock_fd) {
    // 两个分散的缓冲区:HTTP头 + 响应体
    const std::string header = "HTTP/1.1 200 OK\r\nContent-Length: 11\r\n\r\n";
    const std::string body = "Hello World";

    // 构造iovec数组,每个元素对应一个缓冲区
    struct iovec iov[2];
    iov[0].iov_base = const_cast<char*>(header.data());
    iov[0].iov_len = header.size();
    iov[1].iov_base = const_cast<char*>(body.data());
    iov[1].iov_len = body.size();

    // 一次writev调用,写入所有缓冲区
    writev(sock_fd, iov, 2);
}

跨进程I/O的基石:pipe,Linux最经典的IPC原语

解决了进程内的I/O优化,我们自然会遇到下一个问题:进程之间是内存隔离的,如何让两个进程高效传输数据?

答案就是pipe(管道),Linux最古老、最基础的进程间通信(IPC)机制。它的本质是内核中的一个环形缓冲区,对外提供一对fd:读端(只读)和写端(只写),实现进程间的单向字节流传输。

pipe的核心特性

  1. 单向传输:数据只能从写端流入,读端流出,要实现双向通信需要创建两对管道;
  2. 字节流语义:无消息边界,和TCP类似,写入的字节流会被连续读取;
  3. 同步机制:写端缓冲区满时阻塞写入,读端缓冲区空时阻塞读取;
  4. 生命周期:随进程存在,当所有持有管道fd的进程都关闭后,管道会被内核销毁。

我们最熟悉的shell管道命令ls | grep test,底层就是pipe实现的:shell创建一对管道,把ls的标准输出重定向到管道写端,把grep的标准输入重定向到管道读端,实现两个进程的数据传输。

而这里的「重定向」,就需要我们下一个主角登场:dup/dup2

函数原型

cpp 复制代码
#include <unistd.h>

// 创建管道,fd[0]为读端,fd[1]为写端
int pipe(int fd[2]);
  • fd[2]:输出参数,成功后 fd[0] 是只读的管道读端,fd[1] 是只写的管道写端

核心代码示例:shell管道的底层C++实现

点击查看代码

复制代码
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    int pipefd[2];
    pipe(pipefd); // 创建管道:pipefd[0]读端,pipefd[1]写端

    if (fork() == 0) {
        // 子进程:执行 ls
        close(pipefd[0]); // 关闭读端
        dup2(pipefd[1], 1); // 将标准输出重定向到管道写端
        close(pipefd[1]);
        execlp("ls", "ls", nullptr);
        exit(1);
    }

    if (fork() == 0) {
        // 子进程:执行 grep test
        close(pipefd[1]); // 关闭写端
        dup2(pipefd[0], 0); // 将标准输入重定向到管道读端
        close(pipefd[0]);
        execlp("grep", "grep", "test", nullptr);
        exit(1);
    }

    // 父进程:关闭管道,等待子进程
    close(pipefd[0]);
    close(pipefd[1]);
    wait(nullptr);
    wait(nullptr);
    return 0;
}

管道的灵魂搭档:dup/dup2,实现I/O重定向

有了管道,我们如何让一个进程的标准输入/输出,无缝接入管道?这就要靠dup/dup2------文件描述符复制函数。

核心原理

dup/dup2的本质,是复制fd的内核引用,而非文件内容。复制后的两个fd,会指向同一个内核文件对象,共享文件偏移、权限、状态标志。

  • dup(fd):自动分配一个当前可用的最小fd编号,复制传入的fd;
  • dup2(old_fd, new_fd):强制把old_fd复制到指定的new_fd编号,如果new_fd已经打开,会先自动关闭它。

核心价值:I/O重定向

这是dup/dup2最核心的用途。比如我们要把进程的标准输出(stdout,固定fd=1)重定向到文件,只需要几行代码:

函数原型

cpp 复制代码
#include <unistd.h>

// 复制fd,自动分配最小可用的新fd编号
int dup(int oldfd);

// 复制fd,强制指定新fd编号(若newfd已打开则先关闭)
int dup2(int oldfd, int newfd);

核心代码示例:标准输出重定向到文件(C++)

点击查看代码

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

int main() {
    // 打开文件
    int file_fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    
    // 核心:将文件fd复制到标准输出fd=1
    dup2(file_fd, 1);
    close(file_fd); // 原文件fd已不需要,关闭

    // 此后所有cout都会写入文件
    std::cout << "这行文字会被写入 output.txt,而不是终端" << std::endl;
    return 0;
}

经典工业级落地:pipe+dup/dup2,彻底搞懂CGI的底层实现

很多人对CGI的认知停留在「过时的动态网页技术」,却不知道它是Linux基础I/O原语的完美综合应用,也是承上启下的关键节点。

什么是CGI?

CGI(通用网关接口)是早期Web服务器实现动态内容的标准方案。它的核心逻辑很简单:

Web服务器收到客户端的动态请求后,fork一个子进程执行CGI程序(可以是C、Python、Shell脚本等任意可执行文件),把HTTP请求通过标准输入传给CGI程序,CGI程序执行完成后,把HTTP响应通过标准输出返回给Web服务器,最终由Web服务器返回给客户端。

CGI的底层实现,完全依赖pipe+dup/dup2

整个CGI的执行流程,就是这两个函数的教科书级应用:

  1. Web服务器收到请求,创建一对管道pipe_fd[2](读端+写端);
  2. 调用fork()创建子进程;
  3. 子进程中执行:
    • dup2(pipe_fd[0], 0):把管道读端重定向到标准输入(fd=0),CGI程序可以从stdin读取HTTP请求;
    • dup2(pipe_fd[1], 1):把管道写端重定向到标准输出(fd=1),CGI程序可以往stdout写入HTTP响应;
    • 调用exec()执行CGI程序,替换进程镜像。
  4. 父进程中:往管道写入HTTP请求,从管道读取CGI返回的响应,发送给客户端。

你看,CGI不是什么高深的技术,它就是pipe+dup/dup2+fork+exec的组合应用。理解了它,你就真正理解了Linux「一切皆文件」的设计哲学------无论进程内还是进程间,所有I/O都可以通过fd和管道,实现无缝衔接。

核心代码示例:简化的CGI父子进程流程

点击查看代码

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <cstring>
#include <string>

int main() {
    int pipe_req[2], pipe_resp[2];
    pipe(pipe_req);  // 传输请求:父->子
    pipe(pipe_resp); // 传输响应:子->父

    if (fork() == 0) {
        // 子进程:模拟CGI程序
        close(pipe_req[1]);
        close(pipe_resp[0]);

        // 重定向标准输入/输出到管道
        dup2(pipe_req[0], 0);
        dup2(pipe_resp[1], 1);
        close(pipe_req[0]);
        close(pipe_resp[1]);

        // 模拟CGI:读取请求,返回响应
        char req[1024];
        ssize_t n = read(0, req, sizeof(req) - 1);
        req[n] = '\0';
        std::cout << "HTTP/1.1 200 OK\r\nContent-Length: 15\r\n\r\nCGI Response: " << req;
        exit(0);
    }

    // 父进程:模拟Web服务器
    close(pipe_req[0]);
    close(pipe_resp[1]);

    // 发送请求给CGI
    const std::string test_req = "test_input";
    write(pipe_req[1], test_req.c_str(), test_req.size());
    close(pipe_req[1]);

    // 读取CGI响应
    char resp[2048];
    ssize_t n = read(pipe_resp[0], resp, sizeof(resp) - 1);
    resp[n] = '\0';
    std::cout << "Web服务器收到响应:\n" << resp << std::endl;
    close(pipe_resp[0]);

    wait(nullptr);
    return 0;
}

零拷贝的第一次革命:sendfile,告别用户态冗余拷贝

CGI解决了动态内容的问题,但随着Web服务的发展,静态文件传输的性能瓶颈越来越突出。

传统read/write模式的性能灾难

Web服务器传输静态文件时,传统read/write的完整数据路径是:

复制代码
磁盘 → 内核页缓存 → [CPU拷贝1] → 用户空间缓冲区 → [CPU拷贝2] → 内核socket缓冲区 → 网卡

整个流程有2次CPU数据拷贝、4次用户态↔内核态上下文切换。在静态文件传输场景下,数据全程不需要任何修改,却要在用户态来回拷贝两次,绝大多数CPU资源都被浪费在了无意义的数据搬运上。

解决方案:sendfile,专为文件传输设计的零拷贝API

sendfile是Linux为「文件→网络」这个高频场景,专门设计的零拷贝系统调用。它全程让数据停留在内核态,完全绕过用户空间,数据路径简化为:

复制代码
磁盘 → 内核页缓存 → 内核socket缓冲区 → 网卡

整个流程0次CPU拷贝、2次上下文切换,性能提升50%以上。它的核心原理,是基于网卡DMA的scatter/gather特性,直接在内核页缓存和socket缓冲区之间搬运数据,完全不需要CPU参与拷贝。

sendfile的出现,直接让Web服务器的静态文件吞吐能力上了一个台阶,至今仍是Nginx、Apache等Web服务器静态资源传输的核心方案。

它的核心局限

sendfile是一个「专用优化方案」,场景限制极强:

  1. 输入fd必须是支持mmap的可寻址文件(普通文件、块设备),绝对不能是socket、管道等流式fd;
  2. 早期版本输出fd只能是socket,后续仅扩展支持了普通文件;
  3. 无法处理socket→socket的流式转发,而这正是反向代理、API网关的核心需求。

为了突破这些限制,Linux I/O进入了通用零拷贝的时代:mmap

函数原型

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

// 零拷贝在两个fd之间传输数据(in_fd必须是可寻址文件)
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
  • in_fd:输入文件描述符,必须是支持mmap的可寻址文件
  • out_fd:输出文件描述符,通常是socket或普通文件
  • offset:输入文件的偏移指针,函数返回后更新为新的偏移
  • count:最大传输字节数

核心代码示例:文件到socket的零拷贝发送

点击查看代码

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

void send_file_zero_copy(int sock_fd, const char *file_path) {
    int file_fd = open(file_path, O_RDONLY);
    struct stat st;
    fstat(file_fd, &st);
    off_t offset = 0;

    // 核心:一次sendfile调用,零拷贝发送整个文件
    sendfile(sock_fd, file_fd, &offset, st.st_size);

    close(file_fd);
}

零拷贝的通用化进阶:mmap/munmap,把文件直接映射到内存

sendfile解决了特定场景的零拷贝问题,但我们需要一个更通用的方案:既能零拷贝读写文件,又能支持随机访问,还能实现进程间内存共享。

这个方案就是mmap(内存映射),它的核心能力是:把磁盘文件/设备直接映射到进程的虚拟地址空间,让程序像读写内存一样操作文件,完全不需要read/write系统调用

核心原理

调用mmap时,内核只会在进程的虚拟地址空间分配一块连续的区域,建立虚拟地址和文件页的映射关系,并不会加载文件内容。当程序第一次访问映射地址时,CPU触发缺页异常,内核才会把对应的文件页加载到物理内存,建立页表映射。后续所有访问都直接操作内存,无任何系统调用和用户态拷贝。

它的两个核心映射模式,覆盖了绝大多数场景:

  • MAP_SHARED:共享映射,对映射内存的修改会直接同步到磁盘文件,所有映射该文件的进程共享同一份物理内存,可用于实现进程间共享内存;
  • MAP_PRIVATE:私有映射,写入时触发「写时复制(COW)」,修改仅对当前进程可见,不会同步到原文件,常用于动态链接库的加载。

munmap则用于解除映射,释放虚拟地址空间,是mmap的配套调用。

优势与局限

相比sendfilemmap的通用性极强:

  • 支持大文件随机读写,是数据库、搜索引擎的核心IO技术;
  • 支持多进程共享内存,是最快的IPC方式之一;
  • 无需管理用户缓冲区,直接用指针操作文件,编程模型更简洁。

但它依然有无法突破的局限:

  • 有缺页异常的开销,小文件场景性能反而不如read/write
  • 无法处理socket→socket的流式转发,无法实现反向代理的核心需求;
  • 大文件映射会占用大量虚拟地址空间,32位系统下受限严重。

Linux需要一个更底层、更通用的零拷贝原语,而这个原语,又回到了我们前面讲的「管道」。

函数原型

cpp 复制代码
#include <sys/mman.h>
#include <unistd.h>

// 建立内存映射
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

// 解除内存映射
int munmap(void *addr, size_t length);
  • addr:建议映射地址,填 NULL 由内核自动分配
  • length:映射长度(页对齐)
  • prot:访问权限(PROT_READ/PROT_WRITE/PROT_EXEC/PROT_NONE
  • flags:映射类型(MAP_SHARED/MAP_PRIVATE/MAP_ANONYMOUS
  • fd:文件描述符(匿名映射填 -1
  • offset:文件偏移(页对齐)

核心代码示例:文件映射读写

点击查看代码

cpp 复制代码
#include <iostream>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <cstring>

int main() {
    int fd = open("test.txt", O_RDWR);
    struct stat st;
    fstat(fd, &st);
    size_t file_size = st.st_size;

    // 核心:映射文件到内存
    char *map = static_cast<char*>(mmap(nullptr, file_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0));
    close(fd); // 映射后可关闭文件描述符

    // 像操作内存一样读写文件
    std::cout << "文件内容: " << map << std::endl;
    if (file_size > 0) {
        map[0] = 'A'; // 直接修改文件
    }

    // 解除映射
    munmap(map, file_size);
    return 0;
}

零拷贝的终极通用方案:splice,任意fd之间的零拷贝搬运

sendfilemmap都无法解决的「socket→socket流式转发」问题,最终被splice完美解决。

splice是Linux特有的通用零拷贝系统调用 ,它以管道为中转,实现任意两个文件描述符之间的零拷贝数据移动。它的核心约束只有一个:两个fd中,至少有一个必须是管道

核心原理

splice的零拷贝,完全基于管道的内核缓冲区实现:

管道的数据存储在内核的物理页中,每个物理页都有对应的引用计数。splice并不会真正拷贝数据,只是在不同的fd之间,移动物理页的引用指针和所有权------就像把仓库的钥匙从A交给B,货物本身根本没有动,全程0次CPU拷贝。

比如我们要实现socket→socket的TCP反向代理,只需要两步:

  1. 第一次splice:把客户端socket的数据,零拷贝移动到管道写端;
  2. 第二次splice:把管道读端的数据,零拷贝移动到上游服务socket。

整个流程数据全程停留在内核态,完全绕过用户空间,性能比传统read/write模式提升70%以上,这也是Nginx、HAProxy等高性能反向代理的核心实现技术。

它的颠覆性价值

splice彻底打破了sendfilemmap的场景限制,实现了真正通用的零拷贝:

  • 支持文件↔socket、socket↔socket、文件↔文件、管道↔设备等任意fd组合;
  • 无需提前知道数据长度,天然支持无边界的流式数据;
  • 不依赖硬件DMA特性,兼容性更强。

函数原型

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

// 零拷贝在两个fd之间移动数据(至少一个fd是管道)
ssize_t splice(int fd_in, off64_t *off_in,
                int fd_out, off64_t *off_out,
                size_t len, unsigned int flags);
  • fd_in/fd_out:输入/输出文件描述符,至少一个是管道
  • off_in/off_out:偏移指针(可定位fd传指针,不可定位fd传NULL)
  • len:最大移动字节数
  • flags:控制标志(SPLICE_F_MOVE/SPLICE_F_NONBLOCK/SPLICE_F_MORE

核心代码示例:socket到socket的单向零拷贝转发

点击查看代码

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

constexpr size_t BUF_SIZE = 64 * 1024;

// 零拷贝单向转发:sock_in -> sock_out
void zero_copy_forward(int sock_in, int sock_out) {
    int pipefd[2];
    pipe(pipefd);

    while (true) {
        // 1. sock_in -> 管道写端
        ssize_t n = splice(sock_in, nullptr, pipefd[1], nullptr, BUF_SIZE, SPLICE_F_MOVE);
        if (n <= 0) break;

        // 2. 管道读端 -> sock_out
        ssize_t remaining = n;
        while (remaining > 0) {
            ssize_t sent = splice(pipefd[0], nullptr, sock_out, nullptr, remaining, SPLICE_F_MOVE);
            remaining -= sent;
        }
    }

    close(pipefd[0]);
    close(pipefd[1]);
}

零拷贝的场景扩展:tee,管道间的零拷贝数据复印机

splice实现了数据的零拷贝移动,但它有一个特点:源管道的数据会被「消耗」------读走之后就没有了。如果我们要实现「一份输入,多路输出」的场景,比如一份日志同时写入本地磁盘、发送到远程日志服务器、实时推送到监控系统,splice就无能为力了。

这个时候,tee就登场了。

tee是Linux特有的零拷贝数据复制系统调用 ,它专门用于在两个管道之间复制数据,核心特点是:复制后不消耗源管道的数据,源管道的数据仍可被后续读取

核心原理

teesplice同源,都基于管道的物理页引用机制。它执行时,不会拷贝物理页的数据,只会把源管道物理页的引用计数+1,让目标管道也指向同一份物理页,从而实现「两个管道共享同一份物理数据」,全程0次CPU拷贝。

只有当所有管道都释放了对物理页的引用后,内核才会回收该物理页。

配合splice,我们就能实现完美的零拷贝一对多分流:

  1. splice把输入数据移动到源管道;
  2. tee把源管道的数据,零拷贝复制到多个目标管道;
  3. splice把每个目标管道的数据,分别移动到对应的输出fd(文件、socket等)。

整个流程只发生一次数据读取,无任何CPU拷贝,内存占用不随输出数量线性增长,是日志系统、直播流分发、数据镜像的核心优化技术。

函数原型

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

// 零拷贝在两个管道之间复制数据(不消耗源管道)
ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags);
  • fd_in:源文件描述符,必须是管道读端
  • fd_out:目标文件描述符,必须是管道写端
  • len:最大复制字节数
  • flags:控制标志(仅 SPLICE_F_NONBLOCK 有效)

核心代码示例:tee+splice的一对多简化流程

点击查看代码

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

constexpr size_t BUF_SIZE = 64 * 1024;

int main() {
    int pipe_in[2], pipe_out1[2], pipe_out2[2];
    pipe(pipe_in);
    pipe(pipe_out1);
    pipe(pipe_out2);

    // 模拟输入:写入pipe_in
    const std::string data = "Test data for tee";
    write(pipe_in[1], data.c_str(), data.size());
    close(pipe_in[1]);

    // 1. tee复制:pipe_in -> pipe_out1(不消耗pipe_in)
    tee(pipe_in[0], pipe_out1[1], BUF_SIZE, 0);

    // 2. tee复制:pipe_in -> pipe_out2(不消耗pipe_in)
    tee(pipe_in[0], pipe_out2[1], BUF_SIZE, 0);

    // 3. 分别从pipe_out1和pipe_out2读取数据
    char buf1[1024] = {0};
    char buf2[1024] = {0};
    read(pipe_out1[0], buf1, sizeof(buf1) - 1);
    read(pipe_out2[0], buf2, sizeof(buf2) - 1);
    std::cout << "输出1: " << buf1 << std::endl;
    std::cout << "输出2: " << buf2 << std::endl;

    return 0;
}

全链路总结:Linux I/O演进的核心逻辑

到这里,我们已经把所有技术点完整串了起来,整个演进路径清晰可见:

复制代码
基础能力层:fcntl(fd控制) → readv/writev(基础I/O优化)
跨进程层:pipe(IPC基石) → dup/dup2(I/O重定向) → CGI(工业级落地)
零拷贝演进层:sendfile(特定场景零拷贝) → mmap(通用零拷贝访问) → splice(通用零拷贝移动) → tee(零拷贝复制)

你会发现,Linux I/O几十年的演进,始终围绕两个核心目标:

  1. 减少CPU数据拷贝:能不拷贝,就不拷贝;
  2. 减少系统调用/上下文切换:能少一次切换,就少一次。

所有这些技术,都不是孤立的API,而是围绕这两个目标,一步步迭代出来的解决方案。理解了这条演进路径,你就理解了Linux服务端I/O的本质------不是死记硬背参数,而是知道「什么场景下,该用什么工具,解决什么痛点」。


结尾

从一行简单的shell管道命令,到支撑百万并发的反向代理,背后都是这些Linux I/O原语在默默工作。它们不是面试时的八股文,而是每一个Linux服务端开发者,都应该刻在骨子里的底层能力。

当你下次再写I/O相关的代码时,不妨停下来想一想:我现在写的代码,处于这条演进路径的哪一层?有没有更高效、更优雅的解决方案?

这,就是理解底层原理的意义。