哈喽,各位好!今天咱们来聊聊C++里的零拷贝技术,这玩意听起来玄乎,其实就是想方设法让数据搬运的时候少折腾几次,提高效率。重点说说sendfile
和splice
这俩哥们儿在网络编程中的应用。
一、啥是零拷贝?为啥需要它?
想象一下,你辛辛苦苦烤了一块披萨(数据),想送给朋友(网络),正常的流程是:
- 你得先把披萨从烤箱(硬盘)里拿出来,放到你的餐盘(内核缓冲区)。
- 然后你再从餐盘里把披萨切好,装到外卖盒(用户缓冲区)。
- 最后,外卖员(网络协议栈)再把披萨从外卖盒里拿走,送到你朋友家。
这中间是不是折腾了三道? 零拷贝技术,就是想减少这些不必要的折腾,最好是直接把烤箱里的披萨“咻”的一声送到朋友家,中间啥也不用管。
为啥要这么费劲?因为拷贝数据很耗资源啊!CPU得忙活,内存带宽也得占用。在高并发的网络应用里,这可不是闹着玩的,一点点浪费都会被放大成灾难。
二、传统的I/O操作:拷贝拷贝再拷贝
传统的I/O操作,比如read()
和write()
,数据至少要在用户空间和内核空间之间拷贝两次:
read()
:数据从硬盘拷贝到内核缓冲区,再从内核缓冲区拷贝到用户缓冲区。write()
:数据从用户缓冲区拷贝到内核缓冲区,再从内核缓冲区拷贝到网卡发送。
用代码来演示一下:
#include <iostream>
#include <fstream>
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <cstring>
const int BUFFER_SIZE = 4096;
int main() {
// 1. 创建socket
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
perror("socket failed");
return 1;
}
// 2. 绑定地址
struct sockaddr_in address;
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(8080); // 端口号
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
close(server_fd);
return 1;
}
// 3. 监听连接
if (listen(server_fd, 3) < 0) {
perror("listen failed");
close(server_fd);
return 1;
}
std::cout << "Server listening on port 8080..." << std::endl;
// 4. 接受连接
int new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&address);
if (new_socket < 0) {
perror("accept failed");
close(server_fd);
return 1;
}
// 5. 打开文件
std::ifstream file("example.txt", std::ios::binary);
if (!file.is_open()) {
perror("Failed to open file");
close(new_socket);
close(server_fd);
return 1;
}
// 6. 传统方式发送文件
char buffer[BUFFER_SIZE];
while (file.read(buffer, BUFFER_SIZE)) {
ssize_t bytes_read = file.gcount();
ssize_t bytes_sent = send(new_socket, buffer, bytes_read, 0);
if (bytes_sent == -1) {
perror("send failed");
break;
}
}
// 7. 关闭连接和文件
file.close();
close(new_socket);
close(server_fd);
std::cout << "File sent successfully!" << std::endl;
return 0;
}
在这个例子中,example.txt
的内容会先从硬盘拷贝到内核缓冲区,然后再从内核缓冲区拷贝到用户缓冲区buffer
,最后再拷贝到内核缓冲区,由socket发送出去。 哎,这小小的文件,就得经历好几道拷贝,太累了。
三、sendfile
:减少一次拷贝
sendfile
系统调用,就是为了解决这个问题而生的。它可以直接把数据从一个文件描述符(比如硬盘上的文件)拷贝到另一个文件描述符(比如socket),避免了用户空间和内核空间之间的一次拷贝。
简单来说,sendfile
让内核直接操作数据,省去了用户进程的参与。它大致的流程是:
- 在内核空间中,直接将数据从硬盘读取到内核缓冲区。
- 将内核缓冲区的数据直接拷贝到socket缓冲区。
- 通过网络协议栈发送数据。
修改上面的代码,使用sendfile
:
#include <iostream>
#include <fstream>
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <cstring>
#include <sys/sendfile.h>
#include <fcntl.h> // for open
int main() {
// 1. 创建socket
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
perror("socket failed");
return 1;
}
// 2. 绑定地址
struct sockaddr_in address;
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(8080); // 端口号
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
close(server_fd);
return 1;
}
// 3. 监听连接
if (listen(server_fd, 3) < 0) {
perror("listen failed");
close(server_fd);
return 1;
}
std::cout << "Server listening on port 8080..." << std::endl;
// 4. 接受连接
int new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&address);
if (new_socket < 0) {
perror("accept failed");
close(server_fd);
return 1;
}
// 5. 打开文件
int file_fd = open("example.txt", O_RDONLY);
if (file_fd == -1) {
perror("Failed to open file");
close(new_socket);
close(server_fd);
return 1;
}
// 6. 使用sendfile发送文件
off_t offset = 0;
struct stat file_stat;
fstat(file_fd, &file_stat);
ssize_t bytes_sent = sendfile(new_socket, file_fd, &offset, file_stat.st_size);
if (bytes_sent == -1) {
perror("sendfile failed");
}
// 7. 关闭连接和文件
close(file_fd);
close(new_socket);
close(server_fd);
std::cout << "File sent successfully using sendfile!" << std::endl;
return 0;
}
看到了吗?我们用sendfile(new_socket, file_fd, &offset, file_stat.st_size)
代替了原来的read()
和send()
。 file_fd
是打开的文件描述符,new_socket
是客户端socket描述符,offset
是指文件读取的起始位置,file_stat.st_size
是要发送的文件大小。 这样一来,数据就直接从硬盘拷贝到socket缓冲区了,省了一次从内核到用户空间的拷贝。
sendfile
的优点:
- 减少了一次数据拷贝,提高了性能。
- 减少了CPU的使用,降低了系统负载。
- 代码更简洁。
sendfile
的缺点:
- 只能用于文件到socket的传输。
- 某些老旧的内核版本可能不支持。
四、splice
:更灵活的零拷贝
splice
系统调用比sendfile
更强大,它可以在任意两个文件描述符之间移动数据,而不需要用户空间参与。它通过内核空间的pipe来实现数据的传输。
splice
的流程大致如下:
- 使用
pipe()
创建两个文件描述符,一个用于读,一个用于写。 - 使用
splice()
将数据从输入文件描述符移动到pipe的写端。 - 使用
splice()
将数据从pipe的读端移动到输出文件描述符。
#include <iostream>
#include <fstream>
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <cstring>
#include <fcntl.h>
#include <sys/stat.h>
const int BUFFER_SIZE = 4096;
int main() {
// 1. 创建socket
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
perror("socket failed");
return 1;
}
// 2. 绑定地址
struct sockaddr_in address;
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(8080); // 端口号
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
close(server_fd);
return 1;
}
// 3. 监听连接
if (listen(server_fd, 3) < 0) {
perror("listen failed");
close(server_fd);
return 1;
}
std::cout << "Server listening on port 8080..." << std::endl;
// 4. 接受连接
int new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&address);
if (new_socket < 0) {
perror("accept failed");
close(server_fd);
return 1;
}
// 5. 打开文件
int file_fd = open("example.txt", O_RDONLY);
if (file_fd == -1) {
perror("Failed to open file");
close(new_socket);
close(server_fd);
return 1;
}
// 6. 创建pipe
int pipe_fd[2];
if (pipe(pipe_fd) == -1) {
perror("pipe failed");
close(file_fd);
close(new_socket);
close(server_fd);
return 1;
}
// 7. 获取文件大小
struct stat file_stat;
if (fstat(file_fd, &file_stat) == -1) {
perror("fstat failed");
close(file_fd);
close(new_socket);
close(server_fd);
close(pipe_fd[0]);
close(pipe_fd[1]);
return 1;
}
off_t file_size = file_stat.st_size;
off_t offset = 0;
// 8. 使用splice发送文件
while (offset < file_size) {
ssize_t bytes_to_transfer = std::min((off_t)BUFFER_SIZE, file_size - offset);
// 从文件到pipe
ssize_t bytes_splice_in = splice(file_fd, &offset, pipe_fd[1], NULL, bytes_to_transfer, 0);
if (bytes_splice_in == -1) {
perror("splice in failed");
break;
}
// 从pipe到socket
ssize_t bytes_splice_out = splice(pipe_fd[0], NULL, new_socket, NULL, bytes_splice_in, 0);
if (bytes_splice_out == -1) {
perror("splice out failed");
break;
}
offset += bytes_splice_out;
}
// 9. 关闭文件和连接
close(file_fd);
close(new_socket);
close(server_fd);
close(pipe_fd[0]);
close(pipe_fd[1]);
std::cout << "File sent successfully using splice!" << std::endl;
return 0;
}
在这个例子中,我们先创建了一个pipe,然后用splice()
把数据从文件拷贝到pipe的写端,再用splice()
把数据从pipe的读端拷贝到socket。虽然看起来多了几个步骤,但是splice
的优势在于它的灵活性,它可以用于各种不同的场景,比如:
- 将数据从一个socket拷贝到另一个socket(代理服务器)。
- 将数据从一个管道拷贝到另一个管道。
- 在用户空间和内核空间之间进行更复杂的数据处理。
splice
的优点:
- 更加灵活,可以用于各种不同的场景。
- 仍然避免了用户空间和内核空间之间的数据拷贝。
splice
的缺点:
- 代码相对复杂。
- 需要创建和管理pipe。
五、tee
:锦上添花
tee
系统调用通常与splice
配合使用。它可以将数据从一个文件描述符拷贝到两个文件描述符,而不需要用户空间参与。 想象一下,你要把一份文件同时发送给两个人,用tee
就可以很方便地实现。
六、总结:选择合适的零拷贝技术
技术 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
传统I/O | 简单易懂,兼容性好 | 效率低,拷贝次数多 | 小型应用,对性能要求不高 |
sendfile |
减少了一次拷贝,效率较高,代码简洁 | 只能用于文件到socket的传输,兼容性可能存在问题 | 文件服务器,静态资源服务器 |
splice |
更加灵活,可以用于各种不同的场景,仍然避免拷贝 | 代码相对复杂,需要创建和管理pipe | 代理服务器,数据转发,需要灵活处理数据的场景 |
tee |
可以将数据拷贝到多个文件描述符 | 通常与splice 配合使用 |
需要同时将数据发送到多个目标的场景 |
选择哪种零拷贝技术,取决于你的具体需求。如果只是简单地发送文件,sendfile
就足够了。如果需要更灵活的数据处理,splice
可能更适合你。 如果需要将数据同时发送到多个目标,tee
可以助你一臂之力。
零拷贝技术是提高网络应用性能的重要手段。希望通过今天的讲解,大家能够对这些技术有一个更深入的了解,并在实际项目中灵活运用,让你的程序跑得更快、更稳!
记住,没有银弹!选择最适合你的,才是最好的。 祝大家编程愉快!