上一篇解决了进程池中进行大文件传输的问题,通过循环接收和发送指定大小的内容实现大文件的可靠传输。
【Linux C | 网络编程】进程池大文件传输的实现详解(三)
但是其中不可避免的在循环中使用多次的send和recv,这就涉及到多次从内核缓冲区到用户缓冲区的切换,这其中就会增加文件传输的耗时。为了减少这些不必要的系统开销,可以使用一些零拷贝的技术,提升文件传输的效率。
1.使用mmap开辟一块内存映射区
目前我们传输文件的时候是采用
read
和
send
来组合完成,这种当中的数据流向是怎么样的呢?首先打开一个普通文件,数据会从磁盘通过DMA
设备传输到内存,即文件对象当中的内核缓冲区部分,然后调用 read
数据会从内核缓冲区拷贝到一个用户态的
buf
上面(
buf
是
read
函数的参数),接下来调用send ,就将数据拷贝到了网络发送缓存区,最终实现了文件传输。
但是实际上这里涉及了大量的不必要的拷贝操作,比如下图中
read
和
send
的过程:
如何减少从内核文件缓冲区到用户态空间的拷贝呢?解决方案就是使用
mmap
系统调用直接建立文件和用户态空间buf
的映射。这样的话数据就减少了一次拷贝。在非常多的场景下都会使用
mmap
来减少拷贝次数,典型的就是使用图形的应用去操作显卡设备的显存。除此以外,这种传输方式也可以减少由于系统调用导致的CPU
用户态和内核态的切换次数。
服务端可以建立文件对象和内存映射区,减少一次不必要的拷贝,客户端同样可以建立一个接收文件的内存映射区减少一次不必要的拷贝。
客户端
#include "process_pool.h"
#define FILENAME "bigfile.avi"
//sendn函数可以发送确定的字节数
int sendn(int sockfd, const void * buff, int len)
{
int left = len;
const char* pbuf = buff;
int ret = -1;
while(left > 0) {
ret = send(sockfd, pbuf, left, 0);
if(ret < 0) {
perror("send");
return -1;
}
left -= ret;
pbuf += ret;
}
return len - left;
}
int transferFile(int peerfd)
{
//读取本地文件
int fd = open(FILENAME, O_RDWR);
ERROR_CHECK(fd, -1, "open");
//获取文件的长度
struct stat st;
memset(&st, 0, sizeof(st));
fstat(fd, &st);
char buff[100] = {0};
int filelength = st.st_size;
printf("filelength: %d\n", filelength);
//进行发送操作
//1. 发送文件名
train_t t;
memset(&t, 0, sizeof(t));
t.len = strlen(FILENAME);
strcpy(t.buf, FILENAME);
sendn(peerfd, &t, 4 + t.len);
//2. 再发送文件内容
//2.1 发送文件的长度
sendn(peerfd, &filelength, sizeof(filelength));
//服务器这一边采用的是零拷贝的技术
//当mmap映射成功时,pMap指向的就是内核中文件缓冲区,大小为filelength的内存地址空间,和打开的文件对象建立映射关系
char * pMap = mmap(NULL, filelength, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
int ret = send(peerfd, pMap, filelength, 0); //等于直接发送内存数据
printf("send ret: %d\n", ret);
return 0;
}
#include <func.h>
#include <unistd.h>
//接收确定的字节数的数据
int recvn(int sockfd, void * buff, int len)
{
int left = len;
char * pbuf = buff;
int ret = -1;
while(left > 0) {
ret = recv(sockfd, pbuf, left, 0);
if(ret == 0) {
break;
} else if(ret < 0) {
perror("recv");
return -1;
}
left -= ret;
pbuf += ret;
}
return len - left;
}
int main()
{
//创建客户端的套接字
int clientfd = socket(AF_INET, SOCK_STREAM, 0);
ERROR_CHECK(clientfd, -1, "socket");
struct sockaddr_in serveraddr;
memset(&serveraddr, 0, sizeof(serveraddr));
//指定使用的是IPv4的地址类型 AF_INET
serveraddr.sin_family = AF_INET;
serveraddr.sin_port = htons(8080);
serveraddr.sin_addr.s_addr = inet_addr("127.0.0.1");
//连接服务器
int ret = connect(clientfd, (struct sockaddr*)&serveraddr,
sizeof(serveraddr));
ERROR_CHECK(ret, -1, "connect");
printf("connect success.\n");
//进行文件的接收
//1. 先接收文件的名字
//1.1 先接收文件名的长度
int length = 0;
ret = recvn(clientfd, &length, sizeof(length));
printf("filename length: %d\n", length);
//1.2 再接收文件名本身
char buff[1000] = {0};
ret = recvn(clientfd, buff, length);
printf("1 recv ret: %d\n", ret);
int fd = open(buff, O_CREAT|O_RDWR, 0644);
ERROR_CHECK(fd, -1, "open");
//2. 再接收文件的内容
//2.1 先接收文件内容的长度
ret = recvn(clientfd, &length, sizeof(length));
printf("fileconent length: %d\n", length);
//对于客户端来说,当写入数据时,需要先制造一个文件空洞
ftruncate(fd, length);
//再将用户态映射到内核态, pMap指向的就是内核文件缓冲区,建立和接收文件和内存映射区
char * pMap = mmap(NULL, length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
//当调用recv函数就已经完成了文件的写入操作
ret = recv(clientfd, pMap, length, MSG_WAITALL); //客户端等于直接从内存接收文件内容
printf("recv ret: %d\n", ret);
close(fd);
close(clientfd);
return 0;
}
2.sendfile
使用
mmap
系统调用只能减少数据从磁盘文件的文件对象到用户态空间的拷贝,但是依然无法避免从用户态到内核已连接套接字的拷贝(因为网络设备文件对象不支持 mmap
)。
sendfile
系统调用可以解决这个问题,它可以使数据直接在内核中传递而不需要经过用户态空间,调用 sendfile
系统调用可以直接将磁盘文件的文件对象的数据直接传递给已连接套接字文件对象,从而直接发送到网卡设备之上。
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
参数解释
out_fd:目标文件描述符,通常是一个套接字文件描述符,用于接收数据。
in_fd:源文件描述符,通常是一个打开的文件描述符,用于读取数据。
offset:
指向一个 off_t 类型的指针,表示从源文件的哪个位置开始读取。如果 offset 不为 NULL,传输完毕后会更新此偏移量。如果 offset 为 NULL,则从当前文件偏移位置开始读取,且不会改变文件的偏移位置。
count:要传输的数据的字节数。
返回值
成功时,返回传输的字节数。
失败时,返回 -1,并设置 errno 表示错误原因。
使用 sendfile 的时候要特别注意, out_fd一般只能填写网络套接字的描述符,表示写入的文件描述
符, in_fd一般是一个磁盘文件,表示读取的文件描述符。从上述的需求可以得知, sendfile只能用
于发送文件方的零拷贝实现,无法用于接收方,并且发送文件的大小上限是2GB。
#include "process_pool.h"
#define FILENAME "bigfile.avi"
//sendn函数可以发送确定的字节数
int sendn(int sockfd, const void * buff, int len)
{
int left = len;
const char* pbuf = buff;
int ret = -1;
while(left > 0) {
ret = send(sockfd, pbuf, left, 0);
if(ret < 0) {
perror("send");
return -1;
}
left -= ret;
pbuf += ret;
}
return len - left;
}
int transferFile(int peerfd)
{
//读取本地文件
int fd = open(FILENAME, O_RDWR);
ERROR_CHECK(fd, -1, "open");
//获取文件的长度
struct stat st;
memset(&st, 0, sizeof(st));
fstat(fd, &st);
char buff[100] = {0};
int filelength = st.st_size;
printf("filelength: %d\n", filelength);
//进行发送操作
//1. 发送文件名
train_t t;
memset(&t, 0, sizeof(t));
t.len = strlen(FILENAME);
strcpy(t.buf, FILENAME);
sendn(peerfd, &t, 4 + t.len);
//2. 再发送文件内容
//2.1 发送文件的长度
sendn(peerfd, &filelength, sizeof(filelength));
//零拷贝技术之sendfile
int ret = sendfile(peerfd, fd, NULL, filelength);
printf("sendfile ret: %d\n", ret);
return 0;
}
3.splice
考虑到
sendfile
只能将数据从磁盘文件发送到网络设备中,那么接收方如何在避免使用
mmap
的情况下使用零拷贝技术呢? 一种方式就是采用管道配合 splice
的做法。
splice
系统调用可以直接将数据从内核管道文件缓冲区发送到另一个内核文件缓冲区,也可以反之,将一个内核文件缓冲区的数据直接发送到内核管道缓冲区中。所以只需要在内核创建一个匿名管道,这个管道用于本进程中,在磁盘文件和网络文件之间无拷贝地传递数据。
splice函数在服务器和客户端都可以使用
splice
函数在Linux系统中用于实现文件描述符之间的零拷贝数据传输。它允许直接在内核空间中进行数据传输,避免了用户空间和内核空间之间的数据拷贝,因此在性能上有显著的优势。
#include <fcntl.h>
#include <unistd.h>
ssize_t splice(int fd_in, loff_t *off_in, int fd_out,
loff_t *off_out, size_t len, unsigned int flags);
参数解释
fd_in:源文件描述符,从该文件描述符中读取数据。
off_in:指向 loff_t 类型的指针,表示从源文件的哪个位置开始读取数据。如果 off_in 为 NULL,则从当前文件偏移位置开始读取。
fd_out:目标文件描述符,向该文件描述符写入数据。
off_out:指向 loff_t 类型的指针,表示从目标文件的哪个位置开始写入数据。如果 off_out 为 NULL,则从当前文件偏移位置开始写入。
len:要传输的数据的长度,以字节为单位。
flags:控制传输行为的标志位,可以是以下值的组合:
SPLICE_F_MOVE:使用移动数据模式,将数据从 fd_in 移动到 fd_out,而不是复制。
SPLICE_F_NONBLOCK:非阻塞模式,使得 splice 调用不会阻塞,立即返回。
SPLICE_F_MORE:提示内核还有更多数据要传输,对性能有优化作用。
返回值
成功时,返回实际传输的字节数。
失败时,返回 -1,并设置 errno 表示错误原因。
服务端:
#include "process_pool.h"
#define FILENAME "bigfile.avi"
//sendn函数可以发送确定的字节数
int sendn(int sockfd, const void * buff, int len)
{
int left = len;
const char* pbuf = buff;
int ret = -1;
while(left > 0) {
ret = send(sockfd, pbuf, left, 0);
if(ret < 0) {
perror("send");
return -1;
}
left -= ret;
pbuf += ret;
}
return len - left;
}
int transferFile(int peerfd)
{
//读取本地文件
int fd = open(FILENAME, O_RDWR);
ERROR_CHECK(fd, -1, "open");
//获取文件的长度
struct stat st;
memset(&st, 0, sizeof(st));
fstat(fd, &st);
char buff[100] = {0};
int filelength = st.st_size;
printf("filelength: %d\n", filelength);
//进行发送操作
//1. 发送文件名
train_t t;
memset(&t, 0, sizeof(t));
t.len = strlen(FILENAME);
strcpy(t.buf, FILENAME);
sendn(peerfd, &t, 4 + t.len);
//2. 再发送文件内容
//2.1 发送文件的长度
sendn(peerfd, &filelength, sizeof(filelength));
int fds[2];
pipe(fds);//创建一条匿名管道
//零拷贝技术之splice
int sendSize = 0;
int ret = 0;
while(sendSize < filelength) {
//从文件描述符读数据,发送到splice管道写端,每次4k
ret = splice(fd, NULL, fds[1], NULL, 4096, SPLICE_F_MORE);
//从splice管道读端读数据,发送到通信套接字,然后通过网络(网络缓冲区)到客户端
ret = splice(fds[0], NULL, peerfd, NULL, ret, SPLICE_F_MORE);
sendSize += ret;
}
printf("splice ret: %d\n", sendSize);
return 0;
}