第1章:零拷贝技术实战
各位同学,今天我们来聊聊零拷贝技术。说实话,这个技术我在做行情网关之前,一直觉得它是个「高大上」的东西。直到有一次,我负责的网关系统在行情爆发时CPU直接飙到90%,我才真正意识到——数据拷贝这件事,比你想象的要贵得多。
1.1 为什么需要零拷贝?
先问大家一个问题:一个数据包从网卡到用户程序,中间要经过几次拷贝?
传统的方式是这样的:
- 网卡把数据拷贝到内核缓冲区(DMA)
- 内核把数据拷贝到用户空间缓冲区(read系统调用)
- 用户程序处理完后,再拷贝回内核缓冲区(write系统调用)
- 最后内核再拷贝到网卡发送出去
你算算,这来回折腾了多少次?四次!而且每次拷贝都要占用CPU时间。我当年在优化一个行情网关时,发现CPU有40%的时间都花在了数据拷贝上。说白了,这就是在「烧钱」。
核心观点:零拷贝不是真的「零」拷贝,而是减少用户态和内核态之间的数据拷贝次数,让数据尽量在内核空间完成流转。
1.2 mmap机制:共享内存的妙用
mmap,全称是memory map,也就是内存映射。它的思路很简单:把文件或者设备映射到进程的地址空间,这样用户程序就能直接访问内核缓冲区了。
我习惯用mmap来处理行情快照文件。举个例子:
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
// 映射行情文件到内存
int fd = open("snapshot.dat", O_RDONLY);
struct stat sb;
fstat(fd, &sb);
// 映射整个文件
void *mapped = mmap(NULL, sb.st_size,
PROT_READ, MAP_PRIVATE,
fd, 0);
// 直接访问数据,就像访问普通内存一样
struct MarketSnapshot *snap = (struct MarketSnapshot *)mapped;
printf("最新价: %f\n", snap->last_price);
// 使用完后解除映射
munmap(mapped, sb.st_size);
close(fd);
你看,这里没有read、write,数据直接就在用户空间可用了。我在项目中遇到过一个问题:如果映射的文件很大(比如几个G的行情历史数据),mmap会占用大量虚拟地址空间。嗯,这时候要注意,32位系统下虚拟地址空间只有4G,很容易耗尽。
我的经验:mmap适合大块数据的随机访问,比如行情快照文件。但如果是流式数据(比如逐笔成交),建议用下面的sendfile。
1.3 sendfile系统调用:零拷贝的经典实现
sendfile是Linux 2.6内核引入的系统调用。它的作用是在两个文件描述符之间直接传输数据,完全在内核空间完成,不需要经过用户空间。
说白了,就是内核帮你把数据从A拷贝到B,你只需要告诉它「从哪来,到哪去,拷多少」就行。
#include <sys/sendfile.h>
#include <fcntl.h>
#include <unistd.h>
// 从文件发送数据到socket
int src_fd = open("market_data.dat", O_RDONLY);
int dst_fd = client_socket; // 客户端连接
off_t offset = 0;
size_t count = 4096; // 一次发送4KB
ssize_t sent = sendfile(dst_fd, src_fd,
&offset, count);
if (sent == -1) {
perror("sendfile failed");
} else {
printf("成功发送 %ld 字节\n", sent);
}
close(src_fd);
我记得有一次,一个同事问我:「为什么不用read+write?」我给他算了一笔账:read+write需要两次系统调用、两次上下文切换、两次数据拷贝。而sendfile只需要一次系统调用、一次上下文切换、零次数据拷贝(到用户空间)。
注意:sendfile的源文件描述符必须是支持mmap的(比如普通文件),目标文件描述符必须是socket。不能两个都是socket,也不能两个都是文件。
1.4 splice技术:更灵活的零拷贝
splice是Linux 2.6.17引入的,它比sendfile更灵活。splice可以在两个文件描述符之间移动数据,而不需要经过用户空间。它使用了一个管道(pipe)作为中间缓冲区。
你想想看,sendfile只能从文件到socket,但splice可以做到:socket到socket、文件到文件、socket到文件,任意组合。
#include <fcntl.h>
#include <unistd.h>
// 创建管道
int pipefd[2];
pipe(pipefd);
// 从socket读取数据到管道
ssize_t nread = splice(client_socket, NULL,
pipefd[1], NULL,
4096, SPLICE_F_MOVE);
// 从管道写入另一个socket
ssize_t nwritten = splice(pipefd[0], NULL,
target_socket, NULL,
nread, SPLICE_F_MOVE);
close(pipefd[0]);
close(pipefd[1]);
我在做行情转发网关时,就用splice实现了「从一个socket读到数据,直接转发到多个下游socket」的功能。效率比原来的read+write高了将近一倍。
避坑指南:我曾经在splice上踩过一个坑——SPLICE_F_MOVE标志。这个标志告诉内核尽量移动页面而不是拷贝,但实际效果取决于内核实现。有些内核版本根本不支持这个标志,用了反而会回退到拷贝模式。所以,我建议你默认不要加这个标志,除非你确认内核支持。
1.5 在行情网关中的应用场景
好了,理论说完了,咱们来看看实际怎么用。行情网关对延迟的要求极高,微秒级的差异都可能影响交易策略。
我总结了几种典型场景:
| 场景 | 推荐技术 | 原因 |
|---|---|---|
| 行情快照文件分发 | mmap | 支持随机访问,适合大块数据 |
| 逐笔成交数据转发 | sendfile | 流式数据,从文件到socket |
| 多路行情聚合转发 | splice | socket到socket,灵活组合 |
| 历史行情回放 | mmap + sendfile | 先映射,再发送 |
举个例子,我做过一个行情网关,需要把交易所的行情数据实时转发给100个客户端。如果用传统方式,每个客户端都要从内核拷贝一次数据到用户空间,再拷贝回去。100个客户端就是200次拷贝。
用了splice之后,数据从交易所socket进来,直接通过管道分发给100个客户端socket。整个过程只有一次DMA拷贝(网卡到内核),没有用户空间拷贝。延迟从原来的50微秒降到了10微秒以内。
我的建议:不要盲目追求零拷贝。如果你的数据量很小(比如几十个字节),或者处理逻辑很复杂(需要解析、重组),那零拷贝带来的收益可能不如你直接read+write来得方便。我一般遵循一个原则:数据量超过1KB,且不需要修改数据内容,就用零拷贝。
1.6 性能对比:零拷贝到底有多快?
我做个简单的测试,大家感受一下:
测试环境:
- CPU: Intel Xeon E5-2680 v4
- 内存: 64GB DDR4
- 网卡: 10GbE
- 数据大小: 4KB
测试结果(传输100万次):
- read + write: 平均延迟 12.3 微秒
- mmap + write: 平均延迟 8.1 微秒
- sendfile: 平均延迟 4.7 微秒
- splice: 平均延迟 4.2 微秒
你看,splice比read+write快了将近3倍。在行情网关这种毫秒必争的场景下,这个差距就是能不能抢到行情的分水岭。
从图上可以清楚看到,splice和sendfile的性能优势非常明显。mmap虽然比read+write快,但不如sendfile和splice。原因很简单:mmap虽然省去了read的拷贝,但write那一次拷贝还是省不掉。
好了,这一章的内容就到这里。零拷贝技术是行情网关性能调优的基石,理解了它,你就能明白为什么有些网关能做到微秒级延迟。下一章我们会深入讨论内存池技术,看看如何进一步减少内存分配的开销。