在开发文件下载服务器或者静态资源分发服务时,一个绕不开的问题是:如何把磁盘上的文件高效地通过网络发送出去。最直观的写法是先用read把文件读进一块用户态缓冲区,再用write写到socket。这种写法在小文件、低并发的场景下没什么问题,但一旦面对GB级别的大文件和成百上千的并发连接,CPU会被大量消耗在无意义的数据搬运上,系统响应明显变慢。Linux内核提供的sendfile系统调用正是为解决这个问题而生的,它让数据从页缓存直接进入socket缓冲区,全程不需要用户态参与。本文先分析零拷贝的原理,再动手封装一个C++异步传输类。

一、传统IO传输路径与sendfile的对比
先看传统的read/write方案。应用调用read(fd, buf, size)时,如果数据不在页缓存中,内核会发起DMA拷贝,把磁盘数据读入内核缓冲区,然后CPU再把数据从内核缓冲区拷贝到用户缓冲区,这是第一次CPU拷贝。接着调用write,CPU又把用户缓冲区的数据拷贝到socket缓冲区,这是第二次CPU拷贝,最后由DMA把socket缓冲区的数据搬到网卡。整个过程伴随四次上下文切换(两次系统调用进出各算一次),数据在内核态和用户态之间来回折腾了两次。
sendfile则砍掉了中间环节。它的函数原型是sendfile(out_fd, in_fd, offset, count),其中out_fd在内核2.6.33之后可以是任意文件,但典型用法是socket。调用之后,内核内部完成"页缓存到socket缓冲区"的数据传递,如果网卡支持scatter/gather DMA(几乎所有的现代网卡都支持),连这次CPU拷贝都能省掉:内核只向socket缓冲区写入描述符(内存地址和长度),网卡直接从页缓存取数据。这样整个过程只有两次上下文切换、零次CPU拷贝,CPU占用率能下降一个数量级。
需要留意的是,sendfile并非万能。它不能对数据做任何加工,比如你需要在发送前加密、压缩或者修改内容,那就必须退回用户态方案。另外单次调用受count参数和内核内部限制(一般2GB),发送超大文件时要循环调用直到发完。
二、异步化设计:非阻塞socket加epoll的事件驱动模型
sendfile本身是同步调用,但配合非阻塞socket和epoll就能实现异步效果。核心思路是:把socket设为非阻塞模式,调用sendfile时如果内核缓冲区已满,返回值会小于请求的 count,甚至返回-1并置EAGAIN,此时把当前传输进度(文件描述符、已发送偏移、剩余长度)挂到epoll的写事件上,等socket可写时继续发送。这样单个线程就能同时推进成百上千个文件传输任务。
为此需要设计一个传输上下文结构体,记录每次传输的全部状态。关键点有三个:一是用off_t保存已发送偏移,传给sendfile的offset参数传NULL时它还会自动更新文件偏移,但多连接复用同一个文件描述符时必须自己维护偏移;二是文件描述符的生命周期管理,传输未完成前不能关闭;三是错误码的区分,EAGAIN和EINTR属于可重试错误,其他错误应当终止任务并回调上层。
另一个容易踩的坑是EPOLLOUT的注册时机。如果一直注册着写事件,socket一旦可写就会持续触发事件循环空转。正确做法是:只有当sendfile返回不足(短写)时才注册EPOLLOUT,发送追上进度后立刻取消注册,改回只监听EPOLLIN或者干脆不监听。
三、完整C++封装源码
下面给出一个精简但完整的实现,包含传输上下文、epoll事件循环和回调通知。代码在g++下编译通过,依赖Linux 2.6.17以上内核(支持sendfile返回值语义)。
#include <sys/sendfile.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>
#include <cstdio>
#include <unordered_map>
#include <functional>
#include <memory>
#include <cerrno>
// 单个文件传输任务的上下文
struct TransferTask {
int fileFd; // 源文件描述符
int sockFd; // 目标socket
off_t offset; // 已发送偏移
size_t totalSize; // 文件总大小
std::function<void(bool ok, size_t sent)> callback; // 完成回调
bool done() const { return (size_t)offset >= totalSize; }
};
class ZeroCopyServer {
public:
ZeroCopyServer() : epFd_(epoll_create1(0)) {}
~ZeroCopyServer() { if (epFd_ >= 0) close(epFd_); }
// 提交一个异步发送任务
bool submit(int sockFd, const char* path,
std::function<void(bool, size_t)> cb) {
int fileFd = open(path, O_RDONLY);
if (fileFd < 0) return false;
off_t fileSize = lseek(fileFd, 0, SEEK_END);
auto task = std::make_shared<TransferTask>();
task->fileFd = fileFd;
task->sockFd = sockFd;
task->offset = 0;
task->totalSize = (size_t)fileSize;
task->callback = std::move(cb);
// 设置socket非阻塞
int flags = fcntl(sockFd, F_GETFL, 0);
fcntl(sockFd, F_SETFL, flags | O_NONBLOCK);
tasks_[sockFd] = task;
// 先尝试直接发送,短写时再注册EPOLLOUT
pump(task);
return true;
}
// 事件循环
void run() {
epoll_event events[64];
while (true) {
int n = epoll_wait(epFd_, events, 64, -1);
for (int i = 0; i < n; ++i) {
int fd = events[i].data.fd;
auto it = tasks_.find(fd);
if (it == tasks_.end()) continue;
if (events[i].events & EPOLLOUT) {
auto task = it->second;
pump(task);
if (task->done()) {
epoll_ctl(epFd_, EPOLL_CTL_DEL, fd, nullptr);
finish(task, true);
}
}
}
}
}
private:
// 推进一次发送,不足则注册写事件
void pump(std::shared_ptr<TransferTask>& task) {
while (!task->done()) {
size_t remain = task->totalSize - (size_t)task->offset;
ssize_t sent = sendfile(task->sockFd, task->fileFd,
&task->offset, remain);
if (sent > 0) continue; // 发送成功,继续尝试
if (sent == 0) break; // 无数据可发
if (errno == EAGAIN || errno == EWOULDBLOCK) {
watchWritable(task->sockFd); // 注册EPOLLOUT等待可写
return;
}
if (errno == EINTR) continue; // 被信号打断,重试
finish(task, false); // 真正的错误
return;
}
if (task->done()) finish(task, true);
}
void watchWritable(int fd) {
epoll_event ev{};
ev.events = EPOLLOUT | EPOLLET;
ev.data.fd = fd;
epoll_ctl(epFd_, EPOLL_CTL_MOD, fd, &ev);
}
void finish(std::shared_ptr<TransferTask>& task, bool ok) {
if (task->callback)
task->callback(ok, (size_t)task->offset);
close(task->fileFd);
tasks_.erase(task->sockFd);
}
int epFd_;
std::unordered_map<int, std::shared_ptr<TransferTask>> tasks_;
};
使用方式很简单:建立好连接后调用submit传入socket和文件路径,然后启动run事件循环即可。每次任务完成或失败都会触发回调,回调里可以继续发送下一个文件或者关闭连接。编译命令为g++ -std=c++17 -O2 server.cpp -o server。
四、生产环境下的加固建议
上面的代码演示了核心机制,直接上生产还需要补几块。第一是资源上限:大量并发传输会同时打开很多文件描述符,记得调大进程的RLIMIT_NOFILE限制,并用ulimit -n验证。第二是背压控制,如果对端接收很慢,任务会长期挂在epoll上占用内存,可以给每个任务加一个超时时间戳,超时后主动断开。
第三是吞吐调优。发送前调用posix_fadvise(fileFd, 0, 0, POSIX_FADV_SEQUENTIAL)可以让内核预读策略更激进;对socket设置TCP_CORK能在发送HTTP响应头加文件体的场景下把小包合并,减少协议开销——先write头部,再sendfile文件,最后关闭Cork强制刷出。另外,如果目标端也是本机文件而非socket,可以考虑用copy_file_range,某些文件系统上它连页缓存到页缓存的拷贝都能省掉。
最后提一下性能预期的量级。在同一台机器上用回环口测试,read/write方案和sendfile方案在10GB文件传输下的对比中,sendfile路径的CPU时间通常能减少百分之六十以上,系统态占用下降尤其明显。这个差距在高并发场景下会被进一步放大,因为省下来的CPU时间片可以用来处理更多连接。如果还想再进一步,可以研究splice(管道中转的零拷贝)和io_uring(真正的异步提交),它们与sendfile各有适用场景,理解了本文的事件驱动框架后,替换底层系统调用只需要改动pump函数即可。
C++零拷贝sendfile系统调用异步文件传输修改时间:2026-09-07 03:20:41