Linux Zero-Copy

2016/08/28

Zero-Copy

零拷贝技术,就是避免CPU将数据从一块存储拷贝到另一块存储的技术,减少数据传输过程中发生的用户空间和内核空间的上下文切换带来的开销,从而有效地提高数据传输效率。

概念

read/wirte send/recv

在传统的文件传输里面,需要经过多次上下文切换:

	read(file, tmp_buf, len);
	write(socket, temp_buf, len);

以上两行代码是传统的read/write方式进行文件到socket的传输,具体流程如下: 1.调用read函数,文件数据被copy到内核缓冲区 2.read函数返回,文件数据从内核缓冲区copy到用户缓冲区 3.write函数调用,将文件数据从用户缓冲区copy到内核与socket相关的缓冲区 4.数据从socket缓冲区copy到相关协议引擎 在这个过程中,文件数据实际上经过了四次copy操作:硬盘到内核buf,内核buf到用户buf,用户buf到内核socket缓冲区,内核socket缓冲区到协议引擎。

零拷贝技术就是为了避免数据的多次拷贝:

  • 避免内核空间数据拷贝操作
  • 避免内核空间和用户空间的数据拷贝操作

DMA

DMA,Direct Memory Access 直接内存存取,可以在外部设备与内存间直接进行数据交换,而不用通过CPU,这样大大的提高了CPU的利用率。通常系统的总线是由CPU管理的,DMA传输时,CPU让出总数由DMA控制器直接掌管总线。

内存

物理内存和虚拟内存:日常所说的内存为物理内存;虚拟内存为在硬盘空间上虚拟出的一块逻辑内存,用作虚拟内存的磁盘空间被称为交换空间(Swap Space)。 堆外内存和堆内内存:JVM管理的内存为堆内内存;在JVM外的内存为堆外内存。

实现

mmap

“映射”表示一种一一对应关系,硬盘上文件的位置与内存中一块空间位置进行一一对应。在内存映射过程中,并没有实际的数据拷贝,只是将逻辑地址放入了内存。系统调用mmap()方法来建立这种映射。

mmap()方法映射后会返回指向进程逻辑地址的指针pstr,进程无需再调用read/write方法对文件进行读写,直接通过指针pstr进行文件操作。MMU将pstr指向的逻辑地址转换成物理地址,转成物理地址后在地址映射表中如未发现该地址,则会产生一个缺页中断。中断响应函数会在虚拟内存swap中查找该页,如果找不到,则通过转换成的物理地址,从硬盘上将文件读取到物理内存中。在拷贝数据时,如果发现物理内存不够,则会将暂时不用的物理页面存储到虚拟内存中。

mmap/write

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

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

首先,应用程序调用了mmap方法后,数据会先通过DMA拷贝到内核空间缓冲区中。然后,用户空间与内核空间共享这个缓冲区,这样,内核空间和用户空间就不需要再进行一次数据拷贝。接着,应用程序调用了write方法,数据直接从原来的内核空间缓冲区拷贝到与socket相关的内核空间缓冲区。最后,数据从内核空间的socket缓冲区拷贝到协议引擎。
通过使用mmap来代替read,可以减少操作系统的两次数据拷贝。当大量数据需要传输的时候,这样做就会有一个比较好的效率。但是,使用mmap方法也是存在潜在问题的。在对文件进行内存映射后,调用write方法,如果此时有其他的进程截断了这个文件,那么write方法将会被总线错误信号SIGBUS中断,这个信号将会导致进程被杀死。

内存映射

内存映射,就是将内核空间的一段内存区域映射到用户空间。映射成功后,用户对这段内存区域的修改可以直接反映到内核空间上;相反,内核空间对这段区域的修改也直接反映到用户空间。那么对于内核空间和用户空间两者之间需要大量数据传输等操作的话效率是非常高的。当然,也可以将内核空间的一段内存区域同时映射到多个进程,这样还可以实现进程间的共享内存通信。

系统调用mmap就是用来实现内存映射的。最常见的操作就是文件(在Linux下设备也被看成文件)的操作,可以将某文件映射至内存,如此可以把文件的操作转为对内存(进程空间)的操作,以此避免更多的lseekreadwrite操作,这点对于大文件或者频繁访问的文件而言尤其收益。

mmap将一个文件映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。munmap执行相反的操作,删除特定地址区域的对象映射。

当使用mmap映射文件到进程后,就可以直接操作这段虚拟地址继续文件的读写等操作,但是写入的内容不能超过当前文件大小。

采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要内核空间和用户空间进行四次的上下文切换和数据拷贝,而共享内存则只拷贝两次数据,并且减少一个上下文切换。共享区域的内容一直保存在共享内存中,并没有写回文件。往往是在接触映射时才写回文件。

sendfile

sendfile也是一种减少数据拷贝次数的方式,它还减少为了用户空间和内核空间的上下文切换。

#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

首先,sendfile通过DMA将数据拷贝到内核空间缓冲区中,然后数据被拷贝到与socket相关的缓冲区。最后,DMA将数据从内核空间的socket缓冲区拷贝到协议引擎。
sendfile不需要将数据拷贝或者映射到用户空间中,所以sendfile只适用于用户空间不需要对所访问的数据进行处理的情况。相对于mmap,因为sendfile传输数据没有越过用户空间和内核空间的边界线,所以sendfile也极大减少了存储管理的开销。

sendfile在两个文件描述符之间直接传递数据,完全在内核中操作,从而避免了内核缓冲区到用户缓冲区的拷贝,因此效率很高,称为零拷贝。

sendfile方法参数中的in_fd必须是一个支持类似mmap函数的文件描述符,即它必须指向真实的文件,不能是socket和管道;而out_fd必须是一个socket。可见,sendFile专为网络传输文件而生。sendfile系统调用提供了一种减少多次拷贝,提升文件传输性能的方法。

应用

FileChannel

Java类库通过java.nio.channels.FileChannel中的transferTo方法使用了零拷贝技术。transferTo方法直接将字节从它被调用的通道上传输到另外一个可写字节通道,数据无需流经应用程序。

package java.nio.channels;

public abstract class FileChannel
    extends AbstractInterruptibleChannel
    implements SeekableByteChannel, GatheringByteChannel, ScatteringByteChannel
{
	...

    /**
     * 将数据从文件通道传输到给定的可写字节通道
     * @param position 需要传输的文件起始位置
     * @param count 传输的最大字节数
     * @param target 目标通道
     */	
	public abstract long transferTo(long position, long count,
                                    WritableByteChannel target)
        throws IOException;
}

transferTo方法内部,它依赖底层操作系统对零拷贝的支持;在Linux系统中,此调用被传递到sendfile系统调用中。

#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

将数据从一个文件描述符传输到了另一个文件描述符。所以transferTo方法也是将用户空间和内核空间的上下文切换次数从四次减少到两次,将数据拷贝从四次减少到三次(其中只有一次涉及到了CPU)。

ByteBuffer分为HeapByteBuffer和DirectByteBuffer。HeapByteBuffer为堆内缓存,DirectByteBuffer为堆外缓存。DirectByteBuffer使用的是JNI的一个内存地址,因此在释放Buffer时,通过GC是无法释放的,需要JNI。

Non-DirectByteBuffer的通讯过程中需要,socket->directByteBuffer->userHeadpByteBuffer->directByteBuffer->sockt,需要2次copy数据拷贝。copy本身不耗费多长时间,但是建立directByteBuffer的时间比较久(JNI调用)。

DirectByteBuffer的通讯过程中需要,socket->userDirectByteBuffer->socket,0-copy。

Nginx

Web服务nginx,如果采用read/write方式读写传输数据,当某客户端发送GET /index.html HTTP/1.1请求时,nginx需要将存放在站点根目录下的index.html文件读取到内核空间,然后在将数据从内核空间拷贝到用户空间,接着利用write方法,将数据从用户空间拷贝到内核空间的连接套接口描述符来完成响应数据的发送。这样nginx利用read/write方式完成响应数据的发送工作一共需要4次的上下文切换和4次数据拷贝。如果nginx接收大量的并发请求,这种系统调用方式就会非常频繁,服务器性能就会下降。

nginx支持sendfile方式传输,避免了用户空间和内核空间的上下文切换,大大减少了系统性能的开销。

JDK NIO

Linux内存预备知识

网卡、硬盘都属于IO,内核是一个程序,全局描述符表,登记了内存空间划分

系统调用是通过中断实现的,系统调用是有性能成本的。

内存有两个空间,通过系统调用访问来访问。

System.out.println()方法,最终是调用操作系统的write方法系统调用。

/proc/[pid]/fd 查看进程打开的文件描述符

BIO 每个线程负责一个IO,问题是因为IO是阻塞的,所以要使用多线程才能处理多个请求。

操作系统是如何让进程切换的?

NIO 一般设置为configureBlocking(false),如果设置为true,accept会立即返回-1或者文件描述符,阻塞方法会一直被调用,造成系统调用的浪费。

所以内核为了解决这个问题,出现了多路复用技术,由内核来遍历集合,当方法可读可写才通知应用程序。

系统调用提供的select/poll/epoll方法,只是提供给应用程序传入需要监听的文件描述符,系统返回可读可写的文件描述符。可读可写由应用程序完成(这叫同步IO模型,只允许读写还是由应用程序完成就是同步IO)。

问题:

  1. 应用程序每次循环都有将文件描述符集合传入系统调用提供的方法(可以在内核空间提供一个空间,让应用程序和内核都可以直接访问,不用再拷贝)
  2. 内核每次都是O(n)主动遍历集合。

网卡有数据到达(DMA缓冲区 直接内存访问),会产生一个中断(调用内核的回调),到CPU,告诉应用程序有数据到达。

问题2的解决方案就是(epoll实现):epoll_create 在内核开辟一个空间1(存放注册的文件描述符),epoll_ctl 向开辟的空间注册文件描述符(只要调用注册一次就可以,不用每次调用注册),epoll_wait 等待这个空间2是否有结果给应用程序。如果有事件发生,内核会再开辟一个空间2, 将就绪的事件文件描述符从空间1拷贝到空间2,然后通知应用程序。

redis(非阻塞,因为是单进程单线程)、nginx(阻塞,多进程,一个master多个worker)

内存映射:

系统调用mmap映射,应用程序和内核共享的内存空间,且这个空间直接与磁盘挂钩(内核自动帮应用程序将内存空间的数据写入磁盘)。

零拷贝:

系统调用sendfile,输出socket文件描述符,输入磁盘文件,内核直接将磁盘文件数据拷贝到socket,不经过应用程序。

堆外内存

Java是一个操作系统进程 4G

堆内1G,堆外3G。

Unsafe方法,可以直接在堆内分配,也可以在堆外分配。也可以使用mmap技术。

RandomAccessFile可以打开一个文件,有一个文件.map(1G),就是让Java程序在堆外开辟了一个空间直达磁盘,返回一个bytebuffer字节数组,只要往这个字节数组put数据,数据就直接到磁盘,不需要再调用write/flush方法。seek方法不需要重新关闭打开文件,就可以实现跳读。

AIO只要windows的iocp实现

参考资料:

Post Directory