4、sendfile零拷贝机制:从系统调用到行情分发

各位同学,今天我们来聊聊sendfile。这个系统调用,说白了就是Linux零拷贝技术的「扛把子」。我在做行情系统那几年,几乎天天跟它打交道。你想想看,交易所一秒能推送几十万笔行情,如果每次都要把数据从内核拷到用户态,再拷回去发出去——那延迟早就爆了。

4.1 sendfile系统调用详解

先看它的原型,很简单:

#include <sys/sendfile.h>

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

参数不多,但每个都有讲究:

  • out_fd:目标文件描述符,通常是socket
  • in_fd:源文件描述符,必须是支持mmap的文件(比如普通文件)
  • offset:从源文件的哪个位置开始读,传NULL就从当前偏移
  • count:要传输的字节数
注意:in_fd必须指向真实文件,不能是socket或管道。out_fd必须是socket。这是sendfile的设计约束,我当年第一次用的时候就踩过这个坑。

sendfile的返回值是实际传输的字节数。如果返回-1,errno会告诉你原因。常见的错误有EAGAIN(非阻塞模式下缓冲区满)、EINVAL(参数不合法)。

它的工作流程,我画了张图:

sendfile零拷贝数据流 用户空间(User Space) 内核空间(Kernel Space) 磁盘文件 (行情数据文件) Page Cache (内核页缓存) DMA引擎 Socket Buffer (套接字缓冲区) 网卡(NIC) DMA读取 CPU拷贝 DMA发送 零拷贝:无用户态参与 传统方式:磁盘 → 页缓存 → 用户缓冲区 → Socket缓冲区 → 网卡(4次拷贝)

看到没?数据从磁盘到网卡,只经过两次DMA传输和一次CPU拷贝。传统方式要四次拷贝,两次上下文切换。sendfile直接把用户空间跳过去了。

4.2 DMA引擎协同

DMA(Direct Memory Access)是什么?说白了就是硬件自己搬数据,不用CPU插手。我打个比方:CPU是项目经理,DMA是搬砖工。项目经理只需要告诉搬砖工「把A搬到B」,然后就可以去干别的活了。

在sendfile流程中,DMA干了两次活:

  1. 第一次DMA:从磁盘把数据搬到内核的Page Cache
  2. 第二次DMA:从Socket缓冲区把数据搬到网卡发送

中间那一次CPU拷贝(从Page Cache到Socket Buffer),其实也很快——因为数据已经在内存里了,只是改个指针的事。嗯,这里要注意,CPU拷贝虽然叫「拷贝」,但在现代CPU面前,几十KB的数据就是一瞬间的事。

我的经验:在行情系统中,我见过有人纠结那一次CPU拷贝,想用splice彻底去掉。但实测下来,sendfile的CPU拷贝开销几乎可以忽略。真正影响延迟的是DMA传输和内存带宽。别为了追求理论上的「绝对零拷贝」而过度优化。

4.3 scatter/gather DMA

这个技术名字听着高大上,其实原理很简单。传统DMA要求数据在物理内存上是连续的。但你想啊,Page Cache里的数据可能分散在不同的物理页上。如果每次都要把数据整理成连续的再传输,那效率就低了。

Scatter/gather DMA允许DMA引擎直接从多个不连续的物理地址读取数据,拼成一个数据流发送出去。就像你手里有几张纸,不用先粘在一起,直接一张一张递给对方就行。

在sendfile场景下,scatter/gather DMA的作用是:

  • Scatter(分散):从磁盘读取数据时,DMA可以把数据分散写入多个Page Cache页
  • Gather(聚集):从Socket Buffer发送时,DMA可以从多个不连续的页中读取数据,组装成网络包发送

这技术对行情分发特别重要。因为行情数据往往是多个小文件或内存片段,scatter/gather让它们能一次DMA搞定,不用合并。

特性 传统DMA Scatter/Gather DMA
内存连续性要求 必须连续 不要求连续
CPU参与度 需要CPU整理数据 CPU几乎不参与
传输效率 较低
适用场景 小数据块 大数据块、碎片化数据

4.4 sendfile在行情分发中的应用

好了,理论讲完了,咱们来点实战。行情分发是sendfile的经典应用场景。我当年做的一个项目,需要把交易所的Level-2行情实时推送给上千个客户端。

传统做法是:

// 传统方式:read + write
char buf[65536];
while (1) {
    int n = read(fd_file, buf, sizeof(buf));  // 用户态拷贝
    write(fd_socket, buf, n);                  // 用户态拷贝
}

用sendfile改写后:

// sendfile方式:零拷贝
off_t offset = 0;
while (1) {
    int n = sendfile(fd_socket, fd_file, &offset, 65536);
    if (n <= 0) break;
}

代码量少了一半,性能提升了几倍。我实测过,在10Gbps网络下,sendfile的吞吐量能达到传统方式的3-5倍。延迟从微秒级降到纳秒级。

核心要点:行情分发用sendfile,本质上是把「文件读取+网络发送」合并成一步。数据从磁盘到网卡,全程在内核空间完成。用户程序只需要告诉内核「从哪个文件读,往哪个socket写」。

不过,sendfile也不是万能的。我遇到过几个坑:

  • 大文件问题:sendfile一次最多传2GB(32位系统),64位系统没这个限制,但要注意offset类型
  • 非阻塞socket:如果socket设了非阻塞,sendfile可能返回EAGAIN,需要重试
  • 文件空洞:如果文件有空洞(稀疏文件),sendfile会传0字节,可能导致客户端解析出错
我曾经踩过的坑:有一次行情推送突然卡住,排查了半天,发现是磁盘I/O压力太大,Page Cache里的数据被换出了。sendfile虽然零拷贝,但依赖Page Cache。如果内存不够,它一样会等磁盘I/O。所以,用sendfile之前,确保你的热点数据都在内存里。

最后说一句,sendfile在Linux 2.6.33之后支持了TCP_CORK和TCP_NODELAY的配合。如果你要发多个小数据包,建议先设置TCP_CORK,等数据攒够了再发。这样可以减少网络包的数量,提高带宽利用率。

好了,sendfile就讲这么多。记住它的核心思想:数据搬运的事,尽量让硬件干,别让CPU掺和。下一节我们讲splice,它比sendfile更灵活,但限制也更多。


公众号:蓝海资料掘金营,微信deep3321