BUAAZP.

进程间大数据拷贝方法调研

June 13, 2014

进程间大数据拷贝方法调研

@招牌疯子

综述

多进程程序经常会遇到数据拷贝的需求,一般情况下的进程间通信可以认为是个传递消息的场景。在此场景中,原始数据和目标数据都存储于内存里,传递的消耗比较小,而且有大量成熟的消息库可以拿来使用,本文不再赘述。但是如果我们需要传递的是一个容量很大的数据,它们不能长期存储于内存中,就会涉及到从磁盘取出,然后传递给目标进程,目标进程再把数据发给磁盘或socket fd的情景。在这个情况下,由于多次数据复制和CPU状态的切换,会导致数据传输性能低下,严重制约整体服务的负载能力。本文将针对此问题调研三种拷贝方法,并进行比较和测试,试图寻找大数据拷贝的最佳方案,以提高既有系统的效率。

传统数据传输流程

当我们需要打开或保存磁盘上的一个文件时,最先想到的就是read()和write()方法,我们用一个进程从源文件fd中read数据,然后放到管道里,另一个进程通过管道把这些数据write到磁盘或socket fd中,完成拷贝流程。

Linux中传统的 I/O 操作是一种缓冲I/O,I/O过程中产生的数据传输通常需要在缓冲区中进行多次的拷贝操作。一般来说,在传输数据的时候,用户应用程序需要分配一块大小合适的缓冲区用来存放需要传输的数据。应用程序从文件中读取一块数据,然后把这块数据通过网络发送到接收端去。用户应用程序只是需要调用两个系统调用read()和write()就可以完成这个数据传输操作,应用程序并不知晓在这个数据传输的过程中操作系统所做的数据拷贝操作。对于Linux操作系统来说,基于数据排序或者校验等各方面因素的考虑,操作系统内核会在处理数据传输的过程中进行多次拷贝操作。在某些情况下,这些数据拷贝操作会极大地降低数据传输的性能。

当应用程序需要访问某块数据的时候,操作系统内核会先检查这块数据是不是因为前一次对相同文件的访问而已经被存放在操作系统内核地址空间的缓冲区内,如果在内核缓冲区中找不到这块数据,Linux操作系统内核会先将这块数据从磁盘读出来放到操作系统内核的缓冲区里去。如果这个数据读取操作是由DMA完成的,那么在DMA进行数据读取的这一过程中,CPU只是需要进行缓冲区管理,以及创建和处理DMA,除此之外,CPU不需要再做更多的事情,DMA执行完数据读取操作之后,会通知操作系统做进一步的处理。Linux操作系统会根据read()系统调用指定的应用程序地址空间的地址,把这块数据存放到请求这块数据的应用程序的地址空间中去,在接下来的处理过程中,操作系统需要将数据再一次从用户应用程序地址空间的缓冲区拷贝到与网络堆栈相关的内核缓冲区中去,这个过程也是需要占用CPU的。数据拷贝操作结束以后,数据会被打包,然后发送到网络接口卡上去。在数据传输的过程中,应用程序可以先返回进而执行其他的操作。之后,在调用write()系统调用的时候,用户应用程序缓冲区中的数据内容可以被安全的丢弃或者更改,因为操作系统已经在内核缓冲区中保留了一份数据拷贝,当数据被成功传送到硬件上之后,这份数据拷贝就可以被丢弃。

从上面的描述可以看出,在这种传统的数据传输过程中,数据至少发生了四次拷贝操作,即便是使用了DMA来进行与硬件的通讯,CPU仍然需要访问数据两次。在read()读数据的过程中,数据并不是直接来自于硬盘,而是必须先经过操作系统的文件系统层。在write()写数据的过程中,为了和要传输的数据包的大小相吻合,数据必须要先被分割成块,而且还要预先考虑包头,并且要进行数据校验和操作。

图 1. 传统使用read和write系统调用的数据传输

传统使用 read 和 write 系统调用的数据传输

上述流程还只是单进程读写操作的情况,如果是进程间数据拷贝,还需要加上用户空间往管道拷贝的过程,性能进一步下降。

零拷贝(zero copy)技术

简单一点来说,零拷贝就是一种避免 CPU 将数据从一块存储拷贝到另外一块存储的技术。针对操作系统中的设备驱动程序、文件系统以及网络协议堆栈而出现的各种零拷贝技术极大地提升了特定应用程序的性能,并且使得这些应用程序可以更加有效地利用系统资源。这种性能的提升就是通过在数据拷贝进行的同时,允许 CPU 执行其他的任务来实现的。零拷贝技术可以减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不必要的中间拷贝次数,从而有效地提高数据传输效率。而且,零拷贝技术减少了用户应用程序地址空间和操作系统内核地址空间之间因为上下文切换而带来的开销。进行大量的数据拷贝操作其实是一件简单的任务,从操作系统的角度来说,如果 CPU 一直被占用着去执行这项简单的任务,那么这将会是很浪费资源的;如果有其他比较简单的系统部件可以代劳这件事情,从而使得 CPU 解脱出来可以做别的事情,那么系统资源的利用则会更加有效。综上所述,零拷贝技术的目标可以概括如下:

避免数据拷贝

将多种操作结合在一起

零拷贝技术分类

零拷贝技术的发展很多样化,现有的零拷贝技术种类也非常多,而当前并没有一个适合于所有场景的零拷贝技术的出现。对于 Linux 来说,现存的零拷贝技术也比较多,这些零拷贝技术大部分存在于不同的 Linux 内核版本,有些旧的技术在不同的 Linux 内核版本间得到了很大的发展或者已经渐渐被新的技术所代替。本文针对这些零拷贝技术所适用的不同场景对它们进行了划分。概括起来,Linux 中的零拷贝技术主要有下面这几种:

前两类方法的目的主要是为了避免应用程序地址空间和操作系统内核地址空间这两者之间的缓冲区拷贝操作。这两类零拷贝技术通常适用在某些特殊的情况下,比如要传送的数据不需要经过操作系统内核的处理或者不需要经过应用程序的处理。第三类方法则继承了传统的应用程序地址空间和操作系统内核地址空间之间数据传输的概念,进而针对数据传输本身进行优化。我们知道,硬件和软件之间的数据传输可以通过使用 DMA 来进行,DMA 进行数据传输的过程中几乎不需要CPU 参与,这样就可以把 CPU 解放出来去做更多其他的事情,但是当数据需要在用户地址空间的缓冲区和Linux 操作系统内核的页缓存之间进行传输的时候,并没有类似DMA 这种工具可以使用,CPU 需要全程参与到这种数据拷贝操作中,所以这第三类方法的目的是可以有效地改善数据在用户地址空间和操作系统内核地址空间之间传递的效率。

注:在本文下面部分中将主要调研和对比第二类零拷贝技术。

三种方法对比

1. 传统方法

如本文第二部分所描述的那样,父进程读取原始数据,拷贝至管道中,子进程从管道中获取数据,再写到磁盘上,使用read()和write()方法。

process_1 sendfile():

char buffer[BUF_SIZE];
while((bytes = read(in_fd,buffer,sizeof(buffer))) >0)
{
    if(write(pipefd[1],buffer,bytes) != bytes)
    {
        perror("write pipe errno");
        exit(1);
    }
}

process_2 getfile():

char buffer[BUF_SIZE];
while(len > 0)
{
    if((bytes = read(pipefd[0],buffer,sizeof(buffer))) < 0)
    {
        perror("read pipefd error");
        exit(1);
    }
    if((write(out_fd1,buffer,bytes)) != bytes)
    {
        perror("write out_fd1 error");
        exit(1);
    }
    else
        len -= bytes;
}

2. mmap和共享内存方法

此方法采用mmap将源数据fd映射至内存中,然后进行memcpy拷贝给共享内存,其他进程也将至目标数据fd进行mmap映射至内存中,再从共享内存memcpy出来,这样当memcpy结束时,数据就已经拷贝至目标fd中,减少了拷贝次数。

process_1 sendfile():

void *src = mmap(NULL, len, PROT_READ, MAP_SHARED, in_fd, 0);
if(src==MAP_FAILED) {
    perror("mmap map src faild");
    return;
}

void *shm = shared_memory;

int size=BUF_SIZE,total=0;
while(total < len)
{
    size = len - total > BUF_SIZE ? BUF_SIZE : len-total;
    memcpy(shm,src,size);
    shm += size;
    src += size;
    total += size;
    //printf("total_write=%d size=%d\n", total, size);
}

munmap(src, len);

process_2 getfile():

if(ftruncate(out_fd2, len) < 0) {
    perror("ftruncate faild");
    return;
}
void *dst = mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_SHARED, out_fd2, 0);
if(dst==MAP_FAILED) {
    perror("mmap map dst faild");
    return;
}

void *shm = shared_memory;

int size=BUF_SIZE,total=0;
while(total < len)
{
    size = len - total > BUF_SIZE ? BUF_SIZE : len-total;
    memcpy(dst, shm, size);
    shm += size;
    dst += size;
    total += size;
    //printf("total_read=%d size=%d\n", total, size);
}

munmap(dst, len);

mmap()详解

在 Linux 中,减少拷贝次数的一种方法是调用 mmap() 来代替调用 read,比如:

tmp_buf = mmap(file, len); 
write(socket, tmp_buf, len);

首先,应用程序调用了 mmap() 之后,数据会先通过 DMA 拷贝到操作系统内核的缓冲区中去。接着,应用程序跟操作系统共享这个缓冲区,这样,操作系统内核和应用程序存储空间就不需要再进行任何的数据拷贝操作。应用程序调用了 write() 之后,操作系统内核将数据从原来的内核缓冲区中拷贝到与 socket 相关的内核缓冲区中。接下来,数据从内核 socket 缓冲区拷贝到协议引擎中去,这是第三次数据拷贝操作。

图 2. 利用 mmap() 代替 read()

利用 mmap() 代替 read()

3. splice()方法

该方法中我们先启用一对管道,在父进程中将原始数据fd和pipe[1]进行splice,然后在子进程中将pipe[0]和目标数据fd进行splice,代码简单,在两次splice之后就完成了数据的进程间拷贝。

process_1 sendfile():

while(len > 0)
{
    if((bytes=splice(in_fd,NULL,pipefd[1],NULL,len,0x1))<0)
    {
        perror("splice in_fd faild");
        return;
    }
    else
        len -= bytes;
}

process_2 getfile():

while(len > 0)
{
    if((bytes=splice(pipefd[0],NULL,out_fd3,NULL,len,0x1))<0)
    {
        perror("splice out_fd3 faild");
        return;
    }
    else
        len -= bytes;
}

splice()详解

splice() 是Linux中与 mmap() 和 sendfile() 类似的一种方法。它也可以用于用户应用程序地址空间和操作系统地址空间之间的数据传输。splice() 适用于可以确定数据传输路径的用户应用程序,它不需要利用用户地址空间的缓冲区进行显式的数据传输操作。那么,当数据只是从一个地方传送到另一个地方,过程中所传输的数据不需要经过用户应用程序的处理的时候,spice() 就成为了一种比较好的选择。splice() 可以在操作系统地址空间中整块地移动数据,从而减少大多数数据拷贝操作。而且,splice() 进行数据传输可以通过异步的方式来进行,用户应用程序可以先从系统调用返回,而操作系统内核进程会控制数据传输过程继续进行下去。splice() 可以被看成是类似于基于流的管道的实现,管道可以使得两个文件描述符相互连接,splice 的调用者则可以控制两个设备(或者协议栈)在操作系统内核中的相互连接。

splice() 系统调用和 sendfile() 非常类似,用户应用程序必须拥有两个已经打开的文件描述符,一个用于表示输入设备,一个用于表示输出设备。与 sendfile() 不同的是,splice() 允许任意两个文件之间互相连接,而并不只是文件到 socket 进行数据传输。对于从一个文件描述符发送数据到 socket 这种特例来说,一直都是使用 sendfile() 这个系统调用,而 splice 一直以来就只是一种机制,它并不仅限于 sendfile() 的功能。也就是说,sendfile() 只是 splice() 的一个子集,在 Linux 2.6.23 中,sendfile() 这种机制的实现已经没有了,但是这个 API 以及相应的功能还存在,只不过 API 以及相应的功能是利用了 splice() 这种机制来实现的。

在数据传输的过程中,splice() 机制交替地发送相关的文件描述符的读写操作,并且可以将读缓冲区重新用于写操作。它也利用了一种简单的流控制,通过预先定义的水印( watermark )来阻塞写请求。有实验表明,利用这种方法将数据从一个磁盘传输到另一个磁盘会增加 30% 到 70% 的吞吐量,数据传输的过程中,CPU 的负载也会减少一半。

Linux 2.6.17 内核引入了 splice() 系统调用,但是,这个概念在此之前其实已经存在了很长一段时间了。1988 年,Larry McVoy 提出了这个概念,它被看成是一种改进服务器端系统的 I/O 性能的一种技术,尽管在之后的若干年中经常被提及,但是 splice 系统调用从来没有在主流的 Linux 操作系统内核中实现过,一直到 Linux 2.6.17 版本的出现。splice 系统调用需要用到四个参数,其中两个是文件描述符,一个表示文件长度,还有一个用于控制如何进行数据拷贝。splice 系统调用可以同步实现,也可以使用异步方式来实现。在使用异步方式的时候,用户应用程序会通过信号 SIGIO 来获知数据传输已经终止。splice() 系统调用的接口如下所示:

long splice(int fdin, int fdout, size_t len, unsigned int flags);

调用 splice() 系统调用会导致操作系统内核从数据源 fdin 移动最多 len 个字节的数据到 fdout 中去,这个数据的移动过程只是经过操作系统内核空间,需要最少的拷贝次数。使用 splice() 系统调用需要这两个文件描述符中的一个必须是用来表示一个管道设备的。splice() 系统调用利用了 Linux 提出的管道缓冲区( pipe buffer )机制,这就是为什么这个系统调用的两个文件描述符参数中至少有一个必须要指代管道设备的原因。

4. 性能对比测试

依照本文上面提出的思路,将三种拷贝方法用代码(测试代码在最后给出)实现出来,并统计每种方法所使用的时间,为了更精确地反映三种拷贝方法的优劣,统计时间只包括父进程发送数据的时间sendfile和子进程接收数据的时间getfile,其他CPU消耗不进行统计。

测试对象是一个1.4G的二进制文件,上述三种方法各进行5次拷贝,计算平均值,测试结果如下:

copy_test_result

总结

说得再多,也抵不过测试结果,从测试数据来看,splice方法完胜其它两种,性能提升约30%~55%。

mmap+memcpy的方法也还不错,但是这是在完全理想的情况下的结果,本测试中忽略了共享内存同步问题,规避了大量的加减锁操作,直接申请了2G的共享内存,而实际工程中会涉及到大量的锁操作,效果会下降不少。

传统read/write方法完败,并不是说无用武之地,而只是在拷贝这个特殊情景下效果比较差,如果是普通应用中只涉及单次读写或者应用程序对数据需要进行操作的场景,零复制方法并不适合。

最后,如果你所参与的项目也是类似微博图床这样需要存储和处理海量图片数据,需要从前到后不断优化和提升的系统,欢迎留下微博ID,有机会相互探讨。

如需转载,请注明出处,谢谢。

参考资料和测试代码

Linux 中的零拷贝技术,第 1 部分

Linux 中的零拷贝技术,第 2 部分

文中所使用的测试代码

测试结果原始数据

招牌疯子

Coder, OpenSource, DataStorageEngineer. Work@ByteDance
开源爱好者,zimg作者,大规模数据存储工程师。