高效数据拷贝之zero copy

数据传输:传统方式

许多Web程序需要实现从磁盘读取大量数据然后写入到网络socket中传输的功能。这类数据操作看起来不怎么消耗CPU,但是这个过程存在缺陷:操作系统内核(kernel)从磁盘中读出数据,然后将数据从内核区推到用户区,交给用户的应用系统。之后,用户的应用系统会将收到的数据再返回给内核区socket缓存区。实际上,用户应用程序只想去将数据从磁盘读出来然后交给网络socket处理。

每一次用户区和内核区的交互的数据都需要拷贝,而拷贝这个过程是消耗CPU时钟资源和内存资源的。幸运的是,我们可以通过一种叫做“零拷贝”的技术来减少以上的拷贝过程。零拷贝实现了将内核区从磁盘读取到的数据直接拷贝到内核区的网络socket缓存中,不再需要跟用户区的应用程序交互了。零拷贝在极大提高应用程序性能的同时,减少了操作系统在内核区和用户区进行上下文切换的次数。

java通过实现类库中java.nio.channels.FileChannel类的transferTo() 方法,来支持在Linux和Unix系统上使用零拷贝技术。通过使用transferTo() 方法可以实现将字节流直接从一个channel传输到另外一个channel中,而不需要中途经过应用系统。本文首先通过展示传统文件传输过程中来分析其文件频繁拷贝的困境,然后使用 transferTo()方法来展示应用零拷贝技术实现的更高性能。

数据传输:传统方式

想想我们平时从文件系统读取数据然后交给应用程序,在由应用程序传输给网络的过程。(这个场景描述了需要应用服务的原理,包括Web应用相应静态内容、FTP服务、邮箱服务等)这类操作的核心过程需要如下两个过程:

File.read(fileDesc, buf, len);

Socket.send(socket, buf, len);

听起来从文件系统到网络Socket过程很简单,但其内部实现需要4次数据拷贝过程,以及由此导致的4次内核区和用户区之间的上下文切换才能实现文件完整传输。下图展示了数据在文件系统和网络系统之间移动过程:

高效数据拷贝之zero copy

高效数据拷贝之zero copy

图1:文件拷贝过程

 

高效数据拷贝之zero copy

图2:上下文拷贝过程

 

以上涉及到的过程如下:

步骤1:read()方法导致上下文从用户态切换到内核态,内核态中触发sys_read()从文件中读取数据。  DMA(direct memory access)引擎从磁盘读取数据然后放到内核的空闲缓存中,这样第一次数据拷贝就形成了。

 

步骤2:在read()方法调用后,数据从内核区拷贝到用户区。这次拷贝将导致上下文从内核态切换到用户态。现在数据就存储在了用户区的空闲缓存中。

 

步骤3:调用send()方法将导致上下文从用户态切换到内核态,伴随着数据又拷贝回内核区的第三次拷贝过程。这次拷贝过程将数据存储在内核区有关网络socket的缓存区。

 

步骤4:send()方法调用返回后,又进行了第四次上下文切换。同时在DMA引擎里面异步发生了第四次数据拷贝,来将内核区的数据传递到目标驱动中(NIC)。看起来中间使用内核缓存区(而不是直接将数据返回给用户区缓存)的设计存在弊端。但是设计使用内核缓存区的目的正好时用来提高性能的。当应用程序没有要读取超过内核缓存区大小的数据时,在读数据一侧使用内核缓存起到了文件预读(readahead)的作用。这种情况下将显著提高系统性能。内核缓冲区缓存在写入的时候采用异步的方式去写完。

 

不幸的是,如果请求的数据量大于内核缓存区大小的时候,这种设计存在性能瓶颈。其中,数据在磁盘、内核缓存区、用户缓存区之间传输完成之前多次被拷贝。

 

零拷贝技术就是通过减少拷贝次数来优化性能的。

 

数据传输:零拷贝方式

 

如果你已经调研过传统传输场景,你将会注意到图1中第二步和第三步其实并不需要。应用程序除了缓存数据然后将数据传递给网络Socket缓存,其他并没有做什么。为何不直接将数据从内核态的缓存区直接传输到网络Socket中呢?transferTo()方法给我们提供了这样的功能。

 

transferTo()方法:

public void transferTo(long position, long count, WritableByteChannel target);

 

上面提到的file.read()方法和socket.send()方法都可以又这个单独的transferTo() 调用所代替。transferTo()方法将数据从channel传输到另外一个可写的channel中,其内部需要依赖操作支持零拷贝技术。在Unix和linux系统中,transferTo()方法使用了sendfile()系统调用,sendfile()真正实现了将数据从文件传输到其他地方的逻辑,详情如下:

 

sendfile()系统调用描述:

#include ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

 

高效数据拷贝之zero copy

图3:transferTo()的文件拷贝过程

 

 

高效数据拷贝之zero copy

图4:transferTo()涉及到的上下文切换

 

transferTo()步骤描述如下:

步骤1:transferTo()方法使用DMA引擎将磁盘系统中的文件数据拷贝到内核缓存区中,然后数据由内核缓存区拷贝到网络Socket区。

步骤2:第三次拷贝发生在DMA引擎将数据从网络Socket传输到目标协议引擎中。

这里有一个改进点:我们将上下文切换到次数从4次降到了2次,同时也将拷贝次数从4次降到了3次(其中只有一次涉及到了CPU的参与)。这远没有达到零拷贝的目标,如果我们的网卡支持收集操作(gather operations),那么我们减少内核区数据的冗余情况。在linux的2.4及后续版本中,网络Socket缓存描述符已经满足收集操作(gather operations)。这样就减少了上下文切换的次数而且不再需要CPU来参与。虽然这样内部实现原理变化了,但是用户在使用上感觉不到差异。gather operations使用后的过程如下:

 

高效数据拷贝之zero copy

图5:gather operations使用后的结果

 

建立一个文件系统

现在我们实践一下零拷贝技术:使用两种方法实现客户端不断从文件系统读取4K文件然后通过网络传输给服务端。

性能比较

我们在linux2.6内核的操作系统上进行了这个实验,下面是测试结果:

 

文件大小(MB) 传统传输耗时 (ms) transferTo耗时(ms)
7 156 45
21 337 128
63 843 387
98 1320

817

200 2124 1150
350 3631 1762
700 13498 4422
1024(1GB) 18399 8537

我们看到使用transferTo() API 相对传统传输方式来说节省了65%的时间。这对进行从I/O类channel里面拷贝数据传输到其他地方的应用,尤其是Web服务来说是非常具有应用潜力的。

总结

本文我们展示了transferTo()方法在读取传输进行传输过程中的性能优势。同时内核缓存区的数据拷贝消耗的时间是不容忽视的。零拷贝技术在大量数据传输类的应用中起到了重要的性能优化作用。

 

欢迎大家关注我的技术公众号,更多更丰富的内容每天推送给您!

高效数据拷贝之zero copy