sendfile、splice与copy_file_range:Linux零拷贝的三种工程实现路径

sendfile、splice与copy_file_range:Linux零拷贝的三种工程实现路径 sendfile、splice与copy_file_rangeLinux零拷贝的三种工程实现路径一、传统read/write的文件传输瓶颈四次拷贝的代价典型的文件传输流程如Nginx的静态文件服务使用read()从磁盘读取到用户态缓冲区再用write()从用户态写回内核态的socket缓冲区。这个看似简单的流程涉及四次数据拷贝和四次上下文切换。第一步DMA引擎从磁盘拷贝数据到内核页缓存DMA Copy。第二步CPU从内核页缓存拷贝到用户态缓冲区CPU Copy。第三步CPU从用户态缓冲区拷贝回内核socket缓冲区CPU Copy。第四步DMA引擎从socket缓冲区拷贝到网卡DMA Copy。其中第二步和第三步的CPU拷贝是纯粹的浪费——数据不经历任何变换只是从内核的一个缓冲区搬运到另一个缓冲区再经过用户态中转。在万兆网卡时代一次文件传输的CPU拷贝时间可能比DMA传输时间更长CPU成为瓶颈。零拷贝技术的目标就是将CPU拷贝从数据传输路径中消除同时减少上下文切换次数。二、三种零拷贝系统调用的数据路径对比flowchart TD subgraph A[传统read/write: 4次拷贝] A1[磁盘] --|DMA Copy| A2[内核Page Cache] A2 --|CPU Copy| A3[用户态Buffer] A3 --|CPU Copy| A4[内核Socket Buffer] A4 --|DMA Copy| A5[网卡] end subgraph B[sendfile: 2次拷贝] B1[磁盘] --|DMA Copy| B2[内核Page Cache] B2 --|DMA Gather Copy| B3[网卡] end subgraph C[splice: 管道零拷贝] C1[磁盘] --|DMA Copy| C2[内核Page Cache] C2 --|splice: pipe迁移page引用| C3[Pipe环形缓冲区] C3 --|splice: pipe迁移page引用| C4[Socket缓冲区] C4 --|DMA Copy| C5[网卡] end subgraph D[copy_file_range: 文件系统级零拷贝] D1[源文件inode] --|reflink/clone| D2[目标文件inode] D1 --|server-side copy| D3[服务端直接拷贝] endsendfile通过一次系统调用完成文件到socket的传输。其核心机制是直接将内核页缓存的物理页面地址传递给socket的发送队列网卡通过DMA Gather操作从分散的物理页面直接组装数据包发送。CPU拷贝次数降为0在支持scatter-gather DMA的网卡上。splice利用pipe作为内核空间的中转站。pipe的环形缓冲区不存储数据本身而是存储页面的引用struct page指针。源端通过SPLICE_F_MOVE标志将页面从文件缓存迁移到pipe目标端再从pipe迁移到socket缓冲区。整个过程只传递页面引用指针不复制数据内容。copy_file_range是Linux 4.5引入的文件系统级零拷贝。当源和目标在同一文件系统且支持reflink如XFS、Btrfs时直接共享数据块而非复制数据。跨文件系统时退化为内核态拷贝无用户态中转这是它的最大优势——文件系统内部优化对用户完全透明。三、生产级零拷贝的性能基准测试框架# zero_copy_benchmark.py # 零拷贝三种实现的性能对比测试 import os import time import mmap import statistics from dataclasses import dataclass from pathlib import Path from typing import Callable dataclass class TransferStats: method: str file_size_mb: float duration_sec: float throughput_mbps: float cpu_user_percent: float cpu_sys_percent: float iterations: int class ZeroCopyBenchmark: 零拷贝方法性能基准测试 def __init__(self, test_dir: str /tmp/zc_bench): self.test_dir Path(test_dir) self.test_dir.mkdir(parentsTrue, exist_okTrue) def create_test_file(self, size_mb: int) - Path: 创建指定大小的测试文件 filepath self.test_dir / ftest_{size_mb}mb.bin if filepath.exists(): filepath.unlink() # 使用fallocate快速分配空间 fd os.open(str(filepath), os.O_CREAT | os.O_WRONLY) try: os.posix_fallocate(fd, 0, size_mb * 1024 * 1024) finally: os.close(fd) return filepath def bench_sendfile(self, src: Path, dst: Path) - TransferStats: 测试sendfile性能 import ctypes # sendfile需要off_t参数 libc ctypes.CDLL(libc.so.6, use_errnoTrue) iterations 10 durations [] for _ in range(iterations): fd_in os.open(str(src), os.O_RDONLY) fd_out os.open( str(dst), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644 ) try: file_size os.fstat(fd_in).st_size offset ctypes.c_longlong(0) start time.perf_counter() while offset.value file_size: ret libc.sendfile( fd_out, fd_in, ctypes.byref(offset), file_size - offset.value ) if ret 0: raise OSError(ctypes.get_errno()) elapsed time.perf_counter() - start durations.append(elapsed) finally: os.close(fd_in) os.close(fd_out) dst.unlink(missing_okTrue) avg_time statistics.mean(durations) file_size_mb dst.stat().st_size / (1024 * 1024) \ if dst.exists() else src.stat().st_size / (1024 * 1024) return TransferStats( methodsendfile, file_size_mbfile_size_mb, duration_secavg_time, throughput_mbpsfile_size_mb / avg_time, cpu_user_percent0.0, cpu_sys_percent0.0, iterationsiterations, ) def bench_splice(self, src: Path, dst: Path) - TransferStats: 测试splice性能通过pipe中转 import ctypes libc ctypes.CDLL(libc.so.6, use_errnoTrue) iterations 10 durations [] for _ in range(iterations): fd_in os.open(str(src), os.O_RDONLY) fd_out os.open( str(dst), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644 ) pipe_fds os.pipe() try: start time.perf_counter() file_size os.fstat(fd_in).st_size remaining file_size chunk 64 * 1024 # 64KB chunks while remaining 0: to_read min(chunk, remaining) # splice: fd_in - pipe n libc.splice( fd_in, None, pipe_fds[1], None, to_read, 0 # SPLICE_F_MOVE 0 ) if n 0: break # splice: pipe - fd_out n2 libc.splice( pipe_fds[0], None, fd_out, None, n, 0 ) if n2 0: break remaining - n2 elapsed time.perf_counter() - start durations.append(elapsed) finally: os.close(fd_in) os.close(fd_out) os.close(pipe_fds[0]) os.close(pipe_fds[1]) dst.unlink(missing_okTrue) avg_time statistics.mean(durations) file_size_mb src.stat().st_size / (1024 * 1024) return TransferStats( methodsplice, file_size_mbfile_size_mb, duration_secavg_time, throughput_mbpsfile_size_mb / avg_time, cpu_user_percent0.0, cpu_sys_percent0.0, iterationsiterations, ) def bench_copy_file_range(self, src: Path, dst: Path) - TransferStats: 测试copy_file_range性能 import ctypes libc ctypes.CDLL(libc.so.6, use_errnoTrue) iterations 10 durations [] for _ in range(iterations): fd_in os.open(str(src), os.O_RDONLY) fd_out os.open( str(dst), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644 ) try: start time.perf_counter() file_size os.fstat(fd_in).st_size copied 0 while copied file_size: ret libc.copy_file_range( fd_in, None, fd_out, None, file_size - copied, 0 ) if ret 0: raise OSError(ctypes.get_errno()) copied ret elapsed time.perf_counter() - start durations.append(elapsed) finally: os.close(fd_in) os.close(fd_out) dst.unlink(missing_okTrue) avg_time statistics.mean(durations) file_size_mb src.stat().st_size / (1024 * 1024) return TransferStats( methodcopy_file_range, file_size_mbfile_size_mb, duration_secavg_time, throughput_mbpsfile_size_mb / avg_time, cpu_user_percent0.0, cpu_sys_percent0.0, iterationsiterations, ) def bench_read_write(self, src: Path, dst: Path) - TransferStats: 传统read/write基准线 iterations 10 durations [] for _ in range(iterations): fd_in os.open(str(src), os.O_RDONLY) fd_out os.open( str(dst), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644 ) try: start time.perf_counter() while True: data os.read(fd_in, 64 * 1024) if not data: break os.write(fd_out, data) elapsed time.perf_counter() - start durations.append(elapsed) finally: os.close(fd_in) os.close(fd_out) dst.unlink(missing_okTrue) avg_time statistics.mean(durations) file_size_mb src.stat().st_size / (1024 * 1024) return TransferStats( methodread/write, file_size_mbfile_size_mb, duration_secavg_time, throughput_mbpsfile_size_mb / avg_time, cpu_user_percent0.0, cpu_sys_percent0.0, iterationsiterations, ) def run_full_benchmark(self, sizes_mb: list[int]) - dict: 执行完整基准测试 results {} for size in sizes_mb: src self.create_test_file(size) dst self.test_dir / fdst_{size}mb.bin dst.unlink(missing_okTrue) results[f{size}MB] { sendfile: vars(self.bench_sendfile(src, dst)), splice: vars(self.bench_splice(src, dst)), copy_file_range: vars( self.bench_copy_file_range(src, dst) ), read_write: vars(self.bench_read_write(src, dst)), } src.unlink() return results if __name__ __main__: bench ZeroCopyBenchmark() results bench.run_full_benchmark([10, 50, 100, 500]) for size, methods in results.items(): print(f\n {size} ) for method, stats in methods.items(): print( f {method:20s}: f{stats[throughput_mbps]:8.1f} MB/s, f{stats[duration_sec]:6.2f}s )四、场景选择矩阵并非所有场景都需要零拷贝sendfile适用于静态文件传输Nginx的sendfile on即为此场景。但其限制也很明确只能从文件发送到socket不能反向操作无法修改数据内容如添加HTTP头源必须是mmapable的文件描述符。对于需要SSL/TLS加密的文件传输sendfile无法直接使用——加密需要在用户态完成。splice的灵活性优于sendfile它可以在任意两个文件描述符之间移动数据。但splice要求至少一端是pipe——这在某些场景下引入了额外的pipe创建开销。对于小文件16KBpipe的创建和销毁成本可能超过零拷贝的收益。copy_file_range在文件系统内部的数据复制场景中表现最佳。当源和目标在同一XFS文件系统且支持reflink时它是一个O(1)操作——只复制元数据指针不复制数据块。但在跨网络文件系统NFS场景中可能退化为服务端拷贝性能取决于网络传输速度。决策原则大文件1MB的文件传输优先sendfile需要在管道中处理数据流时使用splice文件系统内的复制优先copy_file_range涉及数据修改的场景回退到read/write sendfile组合。五、总结sendfile通过DMA Gather机制将内核页缓存的物理页面直接提交给网卡消除CPU拷贝适用于文件到socket的单向传输。splice利用pipe的页面引用传递实现内核空间数据迁移灵活性优于sendfile但要求至少一方是pipe。copy_file_range通过文件系统的reflink或服务端拷贝实现文件系统级零拷贝对应用层完全透明。选择时需考虑传输方向sendfile单向、splice双向、copy_file_range文件间、数据是否需修改sendfile不可修改以及文件大小小文件收益有限。万兆网卡场景下零拷贝可将CPU占用从60%降至10%以下是高性能网络服务的必备优化。