Linux零拷贝:splice系统调用原理与实战指南 在写网络代理、日志采集、文件分发这类后端模块时我经常遇到一个尴尬场景数据从一个 socket 进来还没被业务逻辑处理就要立刻写到另一个 socket或者需要把大文件内容快速推送下去。早期版本直接用read write循环搬运数据量一大CPU 占用立刻飙升用户态缓冲区、内核缓冲区之间来回拷贝白白浪费性能。后来接触到splice系统调用才真正体会到“零拷贝”在管道和 socket 场景下的价值。这篇文章会围绕splice展开先讲清楚它解决什么问题再拆解函数原型和核心参数然后通过三个完整的 C 语言实战案例演示“文件复制”“文件发送到 socket”“socket 转发到 socket”三种典型用法。最后整理高频报错和工程化建议方便你在自己的项目中快速落地。适合 Linux 系统编程初学者、中间件开发工程师以及正在优化网络传输性能的后端开发者。1. 为什么需要 splice传统 IO 与零拷贝的差距1.1 传统 read/write 的数据路径到底多浪费先看普通的数据转发流程。假设要把磁盘上的一个文件通过网络发送给客户端传统做法是应用调用read(fd, buf, len)读取文件数据。内核把磁盘数据通过 DMA 拷贝到内核页缓存Page Cache。内核再把页缓存中的数据拷贝到用户态缓冲区buf。应用调用write(sockfd, buf, len)发送数据。内核把用户态缓冲区的数据拷贝到 socket 发送缓冲区。网卡通过 DMA 从 socket 缓冲区读取数据并发送。这个过程中数据从磁盘到网卡经过了 4 次拷贝磁盘到页缓存、页缓存到用户态、用户态到 socket 缓冲区、socket 缓冲区到网卡。其中两次是 DMA 拷贝两次是 CPU 拷贝。与此同时还涉及 4 次用户态与内核态的上下文切换。如果只是小数据量这种开销可以忽略。但如果是高并发网关、日志采集代理、视频文件分发这类场景每秒钟要搬运 GB 级数据CPU 就会大量消耗在拷贝上业务逻辑反而得不到足够的计算资源。1.2 零拷贝家族mmap、sendfile、splice为了减少不必要的拷贝Linux 提供了一系列零拷贝方案它们的定位不同方案核心思想适用场景限制mmap把内核页缓存映射到用户态应用直接操作映射区域减少一次显式拷贝需要读写大文件又希望减少拷贝映射管理复杂仍可能在写回时发生缺页中断和拷贝sendfile在内核中直接把页缓存的数据发送到 socket 缓冲区文件到 socket 的零拷贝发送只支持文件作为输入输入输出方向固定splice通过管道在内核中搬运数据两个描述符之间不经过用户态文件、管道、socket 之间的任意两两组合输入和输出中至少有一个必须是管道mmap虽然减少了用户态拷贝但应用仍然能“看到”数据适合需要读取或修改文件内容的场景。sendfile更纯粹但只能用于文件发送到 socket。splice则更灵活尤其适合“socket 到 socket”“socket 到文件”这类sendfile无能为力的场景。1.3 splice 的定位和典型场景Splice是 Linux 2.6.17 内核版本引入的系统调用它允许应用在两个文件描述符之间直接传输数据数据全程在内核态完成搬运不经过用户态缓冲区。特殊之处在于参与传输的两个描述符中至少有一个必须是管道。看起来这个限制很奇怪但正是管道在这套机制里充当了“内核态临时中转站”的角色。数据以页引用的形式挂载到管道缓冲区上而不是真正拷贝一份到管道内存中因此依然具备零拷贝特性。splice的典型应用场景包括TCP 反向代理、流量网关把一个客户端连接的数据转发到另一个上游连接。HTTP 静态文件服务把磁盘文件内容高效发送到客户端 socket。日志采集与落盘从网络 socket 接收大量日志直接写入文件。流式数据处理管道在多个进程或多个 fd 之间搬运数据同时不破坏零拷贝优势。2. 环境准备与版本说明splice是 Linux 平台特有的系统调用不属于 POSIX 标准因此 macOS、Windows 上不能直接使用。本文示例基于常见 Linux 发行版核心环境如下操作系统Linux内核版本建议 4.x 及以上splice自 2.6.17 引入现代内核中行为更稳定。编译器gcc 4.8 及以上版本。需要的头文件fcntl.h、unistd.h、errno.h等。特性宏在包含头文件之前定义_GNU_SOURCE否则某些版本的头文件可能无法暴露splice函数声明。如果你的项目不是纯 C而是 Rust、Go 或 Python也可以通过对应的 FFI 或标准库封装调用splice。例如 Go 的golang.org/x/sys/unix包中就提供了Splice封装。不过本文以 C 语言为例因为它最接近系统调用本身也最便于观察底层行为。示例项目建议按以下结构组织splice-demo/ ├── copy_file.c # 例 1splice 复制文件 ├── file_to_socket.c # 例 2文件发送到 socket ├── socket_forward.c # 例 3socket 转发到 socket └── Makefile # 可选用于统一编译编译单个文件时直接使用gcc -Wall -O2 -o copy_file copy_file.c代码中会大量使用perror打印错误信息便于在出错时快速定位问题。3. splice 系统调用核心原理与 API 拆解3.1 函数原型splice的函数声明如下#define _GNU_SOURCE #include fcntl.h ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);3.2 参数逐项解释参数含义fd_in输入文件描述符数据从它这里读取off_in指向loff_t类型的偏移量指针表示从fd_in的哪个位置开始读如果为NULL则从fd_in当前文件偏移读取普通文件会更新偏移fd_out输出文件描述符数据写入到这里off_out指向loff_t类型的偏移量指针表示写入fd_out的偏移如果为NULL则从当前偏移写入len希望传输的字节数flags修饰行为的标志位见下文有一点需要特别注意off_in和off_out不能同时为非NULL因为这会导致死锁。更重要的是对于管道、socket、字符设备这类不支持随机访问的描述符偏移量参数必须传NULL否则调用会失败。3.3 flags 参数详解flags是一个位掩码可选值如下标志位含义SPLICE_F_MOVE尝试移动数据页而不是拷贝。在 Linux 2.6.21 之前有一定效果之后基本成为提示内核会按实际情况处理SPLICE_F_NONBLOCK以非阻塞方式执行splice操作。注意它只影响本次splice不会改变 fd 本身的阻塞状态SPLICE_F_MORE提示内核后续还会发送更多数据有助于驱动层优化发送行为类似为 socket 打上 TCP PUSH 语义SPLICE_F_GIFT由调用者将数据页“赠予”内核内核可以直接复用这些页。该标志在较老内核中用于某些特定场景普通应用较少使用实际开发中SPLICE_F_MORE和SPLICE_F_NONBLOCK使用频率最高。3.4 返回值与错误码返回值是ssize_t类型返回正数实际传输的字节数。返回 0表示没有更多数据可读等同于 EOF。返回 -1出错需要通过errno判断原因。比较常见的错误码有错误码含义EINVAL参数不合法。常见原因包括两个 fd 都不是管道、偏移量参数使用不当、fd 不支持spliceEBADF某个 fd 未打开或打开模式与本次操作方向不匹配ENOMEM内核内存不足ESPIPEfd_in或fd_out是管道但偏移量参数不为NULLEAGAIN使用了SPLICE_F_NONBLOCK但没有数据可读或写缓冲区已满3.5 管道在 splice 中的特殊地位Splice要求输入和输出中至少有一个是管道这是它和sendfile最大的区别。为什么管道如此重要因为管道在内核中是一组 page 指针的环形队列splice可以把输入端的页引用直接挂载到管道缓冲区上这样数据不需要进入用户态也不需要真正复制到管道内存中。也就是说管道在这里扮演的是“内核态中转站”角色。当数据从 socket 进入时内核把 socket 接收队列中的页直接转移到管道再从管道转移到另一个 socket整个过程本质是页引用的转移。这也是为什么用splice做 socket 转发时会先经过一个管道目的就是满足“至少一端是管道”的约束。4. 实战案例一用 splice 复制文件为了快速掌握splice的基本使用方式先实现一个文件复制工具。它的作用是把源文件内容复制到目标文件复制过程中数据经过管道但不进入用户态缓冲区。4.1 核心思路复制文件需要两个阶段从源文件splice到管道写端。从管道读端splice到目标文件。由于管道容量有限不能一次性读入整个文件必须用循环不断搬运直到源文件 EOF。4.2 完整代码// 文件路径splice-demo/copy_file.c #define _GNU_SOURCE #include fcntl.h #include stdio.h #include stdlib.h #include unistd.h #include errno.h #define BUFFER_SIZE 8192 int main(int argc, char *argv[]) { if (argc ! 3) { fprintf(stderr, 用法: %s 源文件 目标文件\n, argv[0]); exit(EXIT_FAILURE); } int fd_in open(argv[1], O_RDONLY); if (fd_in 0) { perror(open 源文件失败); exit(EXIT_FAILURE); } int fd_out open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644); if (fd_out 0) { perror(open 目标文件失败); close(fd_in); exit(EXIT_FAILURE); } int pipefd[2]; if (pipe(pipefd) 0) { perror(pipe 创建失败); close(fd_in); close(fd_out); exit(EXIT_FAILURE); } ssize_t n; while (1) { // 从源文件读取数据到管道写端 n splice(fd_in, NULL, pipefd[1], NULL, BUFFER_SIZE, SPLICE_F_MORE); if (n 0) { perror(splice 读入管道失败); break; } if (n 0) { break; // 已读到 EOF } // 从管道读端写入目标文件注意可能写不完 ssize_t remaining n; while (remaining 0) { ssize_t written splice(pipefd[0], NULL, fd_out, NULL, remaining, 0); if (written 0) { perror(splice 写出管道失败); close(pipefd[0]); close(pipefd[1]); close(fd_in); close(fd_out); exit(EXIT_FAILURE); } if (written 0) { break; } remaining - written; } } close(pipefd[0]); close(pipefd[1]); close(fd_in); close(fd_out); printf(文件复制完成\n); return 0; }4.3 编译与运行gcc -Wall -O2 -o copy_file copy_file.c echo hello splice, this is a zero-copy demo. source.txt ./copy_file source.txt target.txt cat target.txt预期输出hello splice, this is a zero-copy demo.这个例子虽然简单但展示了splice最核心的循环模式先写入管道再读管道写出处理返回值时必须考虑“一次没写完”的情况。5. 实战案例二文件到 socket 的零拷贝传输接下来做一个更有现实意义的例子用splice实现一个简化的 HTTP 静态文件服务。客户端连接后服务端读取磁盘上的文件并通过 socket 发送给客户端。整个文件数据不经过用户态缓冲区。5.1 与 sendfile 的取舍如果场景是“单纯的磁盘文件发送到 socket”sendfile其实更简单一行调用即可。但splice的好处在于它的通用性输入不一定是文件也可以是管道、socket输出也可以灵活组合。另外在需要先处理一部分协议头、再发送文件内容的场景中splice可以和write自然混用。5.2 完整代码// 文件路径splice-demo/file_to_socket.c #define _GNU_SOURCE #include fcntl.h #include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include arpa/inet.h #include sys/socket.h #include netinet/in.h #define PORT 8080 #define BUFFER_SIZE 8192 #define FILE_PATH ./index.html int main(void) { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket 创建失败); exit(EXIT_FAILURE); } int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(PORT); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind 失败); close(listen_fd); exit(EXIT_FAILURE); } if (listen(listen_fd, 8) 0) { perror(listen 失败); close(listen_fd); exit(EXIT_FAILURE); } printf(静态文件服务已启动: http://127.0.0.1:%d\n, PORT); while (1) { int client_fd accept(listen_fd, NULL, NULL); if (client_fd 0) { perror(accept 失败); continue; } // 发送简化的 HTTP 响应头 const char *header HTTP/1.1 200 OK\r\n Content-Type: text/html; charsetutf-8\r\n Connection: close\r\n \r\n; write(client_fd, header, strlen(header)); int file_fd open(FILE_PATH, O_RDONLY); if (file_fd 0) { perror(打开文件失败); close(client_fd); continue; } int pipefd[2]; if (pipe(pipefd) 0) { perror(pipe 创建失败); close(file_fd); close(client_fd); continue; } ssize_t n; while (1) { n splice(file_fd, NULL, pipefd[1], NULL, BUFFER_SIZE, SPLICE_F_MORE); if (n 0) { perror(splice 读取文件失败); break; } if (n 0) { break; } ssize_t remaining n; while (remaining 0) { ssize_t written splice(pipefd[0], NULL, client_fd, NULL, remaining, 0); if (written 0) { perror(splice 发送 socket 失败); remaining 0; break; } remaining - written; } } close(pipefd[0]); close(pipefd[1]); close(file_fd); close(client_fd); } close(listen_fd); return 0; }5.3 准备测试页面与运行cat index.html EOF html headtitlesplice demo/title/head bodyh1Hello, zero-copy!/h1/body /html EOF gcc -Wall -O2 -o file_to_socket file_to_socket.c ./file_to_socket另开一个终端验证curl http://127.0.0.1:8080/预期输出html headtitlesplice demo/title/head bodyh1Hello, zero-copy!/h1/body /html这里有一个关键细节HTTP 响应头走的是write而文件内容走的是splice。实际工程中你可以把响应头也放入管道一起发送但通过write直接发送更有助于理解两种路径的差异。6. 实战案例三socket 到 socket 转发最后一个实战更贴近真实中间件把客户端 A 发送的数据通过splice转发到客户端 B。这是 TCP 代理、内网穿透工具、流量收集网关的基础能力。6.1 注意事项splice不能直接在两个 socket 之间传输因为“至少一端必须是管道”的约束。这里仍然使用管道中转从 socket 读到管道再从管道写到另一个 socket。这个过程虽然经过管道但页引用是在内核中转移的不会复制到用户态仍然是零拷贝。为了方便演示下面实现一个单向转发服务客户端连接9001端口后服务端再连接本机9002端口然后把9001收到的数据全部转发到9002。6.2 完整代码// 文件路径splice-demo/socket_forward.c #define _GNU_SOURCE #include fcntl.h #include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include arpa/inet.h #include sys/socket.h #include netinet/in.h #define LISTEN_PORT 9001 #define TARGET_PORT 9002 #define BUFFER_SIZE 8192 static int create_listen_socket(int port) { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket 失败); exit(EXIT_FAILURE); } int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(port); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind 失败); exit(EXIT_FAILURE); } if (listen(listen_fd, 8) 0) { perror(listen 失败); exit(EXIT_FAILURE); } return listen_fd; } static int connect_target(const char *ip, int port) { int sock socket(AF_INET, SOCK_STREAM, 0); if (sock 0) { perror(socket 失败); return -1; } struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(port); inet_pton(AF_INET, ip, addr.sin_addr); if (connect(sock, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(connect 失败); close(sock); return -1; } return sock; } static void forward_data(int src_fd, int dst_fd) { int pipefd[2]; if (pipe(pipefd) 0) { perror(pipe 失败); return; } ssize_t n; while (1) { n splice(src_fd, NULL, pipefd[1], NULL, BUFFER_SIZE, SPLICE_F_MORE); if (n 0) { if (errno EAGAIN) { continue; } perror(splice 读取 socket 失败); break; } if (n 0) { break; // 对端关闭 } ssize_t remaining n; while (remaining 0) { ssize_t written splice(pipefd[0], NULL, dst_fd, NULL, remaining, 0); if (written 0) { perror(splice 写入 socket 失败); remaining 0; break; } remaining - written; } } close(pipefd[0]); close(pipefd[1]); } int main(void) { int listen_fd create_listen_socket(LISTEN_PORT); printf(转发服务启动监听端口 %d转发到 127.0.0.1:%d\n, LISTEN_PORT, TARGET_PORT); while (1) { int client_fd accept(listen_fd, NULL, NULL); if (client_fd 0) { perror(accept 失败); continue; } int target_fd connect_target(127.0.0.1, TARGET_PORT); if (target_fd 0) { close(client_fd); continue; } printf(新连接建立开始零拷贝转发\n); forward_data(client_fd, target_fd); close(target_fd); close(client_fd); printf(连接关闭\n); } close(listen_fd); return 0; }6.3 运行验证为了验证效果可以用nc在 9002 端口模拟一个“服务端”# 终端 A启动目标服务 nc -l 127.0.0.1 9002 # 终端 B编译并启动转发服务 gcc -Wall -O2 -o socket_forward socket_forward.c ./socket_forward # 终端 C连接转发服务输入测试数据 nc 127.0.0.1 9001此时在终端 B 输入的内容应该能原样出现在终端 A。这说明数据成功从客户端 socket 经过转发服务再到达目标 socket且全程走了内核态零拷贝通道。这个例子是单向转发真实代理工具通常需要两条splice通道正向一条、反向一条可以用两个线程分别处理核心逻辑完全一致。7. 常见问题与排查思路Splice使用过程中有不少容易踩坑的地方下面整理成表格并在表格后补充详细排查步骤。问题现象常见原因解决思路调用返回 -1errno为EINVAL输入和输出都不是管道或者偏移量参数在 socket 上使用确保至少一端是管道socket、管道描述符的偏移量必须传NULL使用SPLICE_F_NONBLOCK后频繁返回EAGAIN对端暂时没有数据或对端写缓冲区已满配合epoll监听可读、可写事件数据就绪后再调用splice转发大文件时程序卡住管道写满后阻塞或 socket 发送缓冲区满检查是否在循环内正确处理阻塞非阻塞场景依赖事件驱动从普通文件读取数据后导致文件偏移改变off_in传了NULL内核会更新文件当前偏移不想改变偏移时使用独立的loff_t offset变量并传入指针socket 之间直接splice失败splice 要求至少一端是管道必须用管道中转先转进管道再转出发送大文件时只有部分数据到达单次splice没有写完所有数据必须循环处理返回值直到剩余字节数为 07.1 排查清单如果你遇到splice相关异常可以按下面的顺序排查确认系统是否支持splice执行man 2 splice如果命令不存在说明系统文档不完整但仍可尝试编译测试。一般来说Linux 2.6.17 以上都支持。确认两个 fd 中至少有一个是管道。如果是要传输两个 socket必须显式创建管道中转。检查 fd 的打开模式输入 fd 必须可读输出 fd 必须可写。比如用open打开文件时如果只传了O_WRONLY那么把它作为fd_in就会失败。检查偏移量参数管道、socket 必须传NULL普通文件可以传NULL或有效指针但不要同时给输入和输出都传非NULL指针。检查返回值正数代表成功传输多少字节返回 0 是正常 EOF返回 -1 时打印errno可以快速定位原因。检查是否真的需要非阻塞默认阻塞模式下splice会一直等到有数据或出错。如果程序卡住先确认对端是否把数据发过来了。8. 最佳实践与工程建议8.1 根据场景选择正确的零拷贝方案splice虽然灵活但并不总是最优解。如果你只做“文件内容发送到 socket”使用sendfile更简单代码量更少。如果你需要在用户态修改或分析数据splice并不合适因为数据根本不进入用户态此时应该用mmap或普通read。splice的真正价值在于socket 到 socket 的流式转发。socket 到文件的大规模落盘。文件到 socket但流程中需要插入其他数据处理环节的场景。8.2 结合 epoll 驱动非阻塞 splice生产环境的网络服务基本都是事件驱动。splice和epoll可以这样配合用epoll监听 socket 的可读事件。可读事件触发后调用splice(src_fd, NULL, pipefd[1], NULL, len, SPLICE_F_NONBLOCK)。如果返回EAGAIN说明本次没有更多数据继续等待下一次事件。向目标 fd 写出时如果目标 fd 写缓冲区已满需要注册可写事件再继续splice(pipefd[0], NULL, dst_fd, NULL, remaining, SPLICE_F_NONBLOCK)。不要把splice直接丢进一个 while 死循环里自旋否则高并发场景下 CPU 和管道都会成为瓶颈。8.3 管道容量的影响管道默认容量通常是 65536 字节16 个页。一次splice能搬运的数据量受管道剩余空间限制。如果你搬运的数据块很大建议设置更大的管道容量int pipefd[2]; pipe(pipefd); int new_size 1024 * 1024; // 1MB fcntl(pipefd[1], F_SETPIPE_SZ, new_size);F_SETPIPE_SZ可以尝试调整管道容量但实际是否成功受权限和内核限制影响使用前可以检查返回值。8.4 处理部分写入Splice和write一样可能只写入一部分字节。因此必须用循环把len拆成多次调用直到所有数据都写完。实战案例中已经体现了这个模式这不是冗余代码而是健壮性的必要保证。8.5 安全边界与合规提示splice只是内核层面的数据搬运机制它不负责认证、加密、协议解析和内容过滤。如果你在公网环境下用它实现代理或转发必须在上层叠加 TLS 加密、身份认证、访问控制等安全措施。另外在生产环境改造数据通道前建议先在测试环境压测验证确保流量模型符合预期再逐步灰度上线。涉及敏感数据的传输还需要遵守所在组织的数据安全合规要求。9. 总结与下一步学习路线通过这篇文章我们完整梳理了splice系统调用的定位、原理和实战用法。从传统read write的四次拷贝问题出发理解了零拷贝的含义然后逐个拆解了splice的函数原型、偏移量参数、标志位和返回值最后用“文件复制”“文件到 socket”“socket 到 socket”三个案例把splice最常见的应用链路全部跑通。你还掌握了EINVAL、EAGAIN、管道容量、部分写入等高频坑点这些经验在真实项目中能帮你少走不少弯路。接下来可以沿着三个方向继续深入对比sendfile、splice、mmap在相同数据量下的 CPU 和内存表现自己写一个简单的基准测试工具。用splice配合epoll实现一个支持双向转发的 TCP 代理体会事件驱动模型下零拷贝通道的工程细节。了解io_uring提供的零拷贝能力它是目前 Linux 异步 IO 的重要方向在更高性能场景下值得学习和实践。如果这篇文章对你有帮助可以收藏备用后续调优网络传输性能时随时翻出来对照。实践过程中遇到问题也欢迎在评论区讨论。