零拷贝你需要知道的事

4,185 阅读3分钟

0x01 引子:传统的拷贝模式

一个实际的场景是静态文件服务器,客户端请求一个静态资源,服务返回内容给它。传统的处理方式是这样的(备注,为了代码简洁起见,省略一些代码)

for (;;) {
    if (lseek(fd, 0, SEEK_SET) < 0) err_quit("error seek file");

    connect_fd = accept(listen_fd, &serv_addr, &client_addr);
    if (connect_fd < 0) err_quit("accept failed");

    int n = 0;
    char buf[BUFFER_SIZE];
    while ((n = read(fd, buf, BUFFER_SIZE)) > 0) {
        write(connect_fd, buf, n);
    }
    close(connect_fd);
}

对于许多应用程序来说,这样的循环读写是可接受的,但是如果我们需要通过socket 频繁传输大文件的话,这种方式就会非常的不高效:这个过程涉及到 4 次数据拷贝,如下图所示

-w825

详细的时序图如下:

  1. 执行 read 系统调用
  2. 操作系统上下文切换到 kernel 模式,DMA 读取磁盘的文件内容并存储到 kernel 的缓冲区缓存(第 1 次上下文切换,第 1 次数据拷贝)
  3. 操作系统内核把数据从 kernel 拷贝到 用户空间缓冲区,上下文切换到 user 模式,read() 函数退出 (第2次上下文切换,第 2 次数据拷贝)
  4. 执行一些可能的业务代码,然后执行 write 系统调用
  5. 操作系统上下文切换到 kernel 模式,然后把数据从用户空间拷贝到内核空间的 socket 发送缓冲区(第 3 次上下文切换,第 3 次数据拷贝)
  6. write 函数返回,操作系统切换到 user 模式,与此同时,第四次拷贝出现,DMA 把内核缓冲区的数据拷贝到网卡网络协议处理引擎中(第 4 次上下文切换,第 4 次数据拷贝)

可以看到其中涉及到两次用户空间和内核空间之间的拷贝,一个用来将文件内容从内核缓冲区缓存中拷贝到用户空间,另一个用来将用户空间缓冲区拷贝会内核空间,以此才能通过套接字进行传输。如果应用程序在发起传输之前根本不需要对文件内容做任何处理的话,那么这种来回拷贝完全是一种浪费。

0x02 好一点的方式

从 2.1 内核版本开始,Linux 引入了 sendfile 来简化操作,sendfile 方法成功的将数据拷贝的次数从四次减少到了三次(其中只有一次涉及到了 CPU),但是这种方式还没有达到零拷贝的要求 核心代码如下

for (;;) {
    connect_fd = accept(listen_fd, &serv_addr, &client_addr);
    if (connect_fd < 0) err_quit("accept failed");

    struct stat stat_buf;
    fstat(fd, &stat_buf);
    off_t offset = 0;
    int cnt = 0;
    if ((cnt = sendfile(connect_fd, fd, &offset, stat_buf.st_size)) < 0) {
        err_quit("send file failed");
    }
    close(connect_fd);
}

-w750

具体的流程图如下

  1. 执行 sendfile 系统调用
  2. DMA 读取磁盘的文件内容并存储到 kernel 的缓冲区缓存
  3. 把 kernel 缓冲区的数据拷贝到 socket 发送缓冲区
  4. DMA 引擎把数据从 socket 发送缓冲区传输到协议引擎

0x03 真正的零拷贝

当网卡支持scatter-n-gather模式时,可以把 0x02 中的中间那次拷贝也去掉 具体流程如下图所示:

-w747

具体的流程图如下

到此,数据拷贝已经完全跟 CPU 没关系了,都是由内核操作的,内核只需要两次拷贝即可

0x04 测试代码

代码仓库如下:github.com/arthur-zhan… 模拟的是一个静态资源服务器,当客户端连上来的时候,返回静态资源文件 index.html 的内容。测试的方式是用 telnet localhost 34567模拟客户端连接