4. sendfile零拷贝:数据从磁盘到网卡的直达快车
各位同学,今天我们来聊聊sendfile。说实话,这个系统调用是我在量化交易系统里用得最频繁的零拷贝技术之一。为什么?因为行情数据的分发场景,简直就是为sendfile量身定做的。
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或者管道。我刚开始用的时候就在这栽过跟头。
4.2 数据从磁盘到网卡的直接路径
传统方式下,数据要经过:磁盘 → 内核缓冲区 → 用户缓冲区 → 内核socket缓冲区 → 网卡。你想想看,这中间数据被拷贝了四次,CPU还得参与两次上下文切换。
而sendfile的路径是这样的:
磁盘 → 内核缓冲区 → 网卡
只有两次拷贝,而且CPU基本不参与数据搬运。为什么能做到?因为DMA控制器直接接管了磁盘到内核缓冲区的传输,然后内核又把数据直接从缓冲区推到网卡。
下面这张图能帮你直观理解:
4.3 sendfile vs mmap+write
很多同学会问:那mmap+write不也能实现零拷贝吗?没错,但两者有本质区别。
| 对比维度 | sendfile | mmap+write |
|---|---|---|
| 拷贝次数 | 2次(DMA) | 3次(1次CPU + 2次DMA) |
| 上下文切换 | 1次系统调用 | 2次系统调用 |
| 用户空间参与 | 完全不需要 | 需要映射和写入 |
| 适用场景 | 文件→socket | 文件→文件、文件→socket |
| 大文件性能 | 优秀 | 一般(缺页中断多) |
我个人习惯是:只要是从文件往socket发数据,无脑选sendfile。mmap+write更适合那种你需要对数据做点处理的场景,比如加个时间戳或者过滤一下。
4.4 sendfile的局限性
说实话,sendfile也不是万能的。我在项目中遇到过几个坑:
- 只能从文件到socket:你不能用它做socket到socket的传输
- 不支持非阻塞socket:如果socket缓冲区满了,sendfile会阻塞
- 大文件问题:一次传输超过2GB?32位系统上会出问题
- 无法修改数据:数据从磁盘到网卡,你碰都碰不到
4.5 在TCP协议栈中的优化
Linux内核从2.6.33版本开始,对sendfile做了个重要优化——SG-DMA(Scatter-Gather DMA)。说白了,就是网卡支持直接从内核缓冲区分散读取数据,不需要先合并再发送。
这个优化让sendfile的性能又上了一个台阶。原来需要先把数据从Page Cache拷贝到socket缓冲区,现在网卡直接去Page Cache里拿。嗯,这才是真正的零拷贝。
4.6 实战:用sendfile分发历史行情数据
好了,理论说完了,我们直接上代码。这是一个典型的历史行情分发服务:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/sendfile.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <sys/stat.h>
#define PORT 8080
#define BUFFER_SIZE 4096
int main() {
int server_fd, client_fd;
struct sockaddr_in address;
int opt = 1;
socklen_t addrlen = sizeof(address);
// 创建socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
bind(server_fd, (struct sockaddr *)&address, sizeof(address));
listen(server_fd, 3);
printf("历史行情分发服务启动,端口:%d\n", PORT);
while (1) {
client_fd = accept(server_fd, (struct sockaddr *)&address, &addrlen);
// 收到客户端请求,打开行情文件
int file_fd = open("historical_quotes.dat", O_RDONLY);
if (file_fd < 0) {
perror("文件打开失败");
close(client_fd);
continue;
}
struct stat file_stat;
fstat(file_fd, &file_stat);
off_t offset = 0;
ssize_t remaining = file_stat.st_size;
// 核心:用sendfile发送文件
while (remaining > 0) {
ssize_t sent = sendfile(client_fd, file_fd, &offset, remaining);
if (sent <= 0) {
perror("sendfile发送失败");
break;
}
remaining -= sent;
}
printf("已发送 %ld 字节的历史行情数据\n", file_stat.st_size);
close(file_fd);
close(client_fd);
}
return 0;
}
posix_fadvise设置WILLNEED),这样sendfile分发时基本不会触发磁盘I/O,性能直接拉满。
这段代码看起来简单,但背后就是sendfile的精髓——数据从磁盘到网卡,一路绿灯,没有任何不必要的拷贝。你想想看,如果每次客户端请求都要把文件读到用户空间再发出去,那CPU得忙成什么样?
好了,sendfile的内容就这些。记住它的核心:文件到socket的专用通道,零拷贝、高性能、低延迟。在量化交易系统里,历史行情回放、数据分发这些场景,用sendfile就对了。