概述
很多web应用都会有大量的静态文件。我们通常是从硬盘读取这些静态文件,并将完全相同的文件数据写到response socket。这样的操作需要较少的CPU,但是效率有些低,它需要经过如下的过程:kernel从硬盘读取数据,越过kernel-user边界将数据传递给用户空间的web应用;用户空间的web应用再次越过kernel-user边界将完全相同的数据写回到kernel空间的socket。在将数据从硬盘传递到socket的过程中,用户空间web应用的角色相当于一个中介,并且有些低效。
数据每次经过kernel-user边界的时候,都需要被copy一次,这样会消耗CPU资源及内存带宽。幸运的是,我们可以使用一种被称为zero copy的技术来消除这些copy操作。使用zero copy技术的应用会请求kernel直接将数据从硬盘拷贝到socket,而无需再经过应用。zero copy极大地提升了应用的性能,并减少了内核态和用户态上下文切换的次数。
在Linux和UNIX系统上,Java类库通过java.nio.channels.FileChannel的transferTo()方法实现了对zero copy的支持。我们可以使用transferTo()方法将读取到的字节数组直接从被调用的channel传输到另一个可写的channel上,这个过程中数据流转不需要通过应用。
接下来我们会先讲解一下如何使用传统的多次copy的机制实现数据的传输,而后再演示下使用transferTo()方法实现的zero copy技术是如何提升性能的。
传统数据传输方案
思考一下如下的场景:从一个文件读取数据,通过网络将数据传递给另一个应用程序(这个场景描述了大部分服务器应用的行为,包括处理静态文件的WEB服务器,FTP服务器,Mail服务器等)。这个操作的核心步骤只有两步,我们看下代码:
1
2
File.read(fileDesc,buf,len);
Socket.send(socket,buf,len);
我们的代码只有两行,看起来很简单,但是服务器完成这个过程却需要在用户态和内核态之间进行4次上下文切换,也就是说在这个操作完成之前数据需要被copy 4次。下面的图片展示了服务器是如何将数据从文件传输到socket的。
图一:传统模式下数据拷贝过程:
图二:传统模式下内核态和用户态之间的上下文切换
涉及到的步骤包括:
调用read()方法导致了用户态到内核态的切换(参看图二)。在系统内部是通过sys_read()(或类似的其他方法)从文件读取数据。第一次copy(参看图一)是通过直接内存访问(DMA)引擎实现的,这次copy从硬盘上读取了文件内容并将之保存在内核空间的缓冲区中。
第二次copy发生在数据从内核缓冲区被copy到用户缓冲区时,此时read()方法也返回了。read()方法的返回导致了从内核态到用户态一次切换。现在数据是保存在用户空间的缓冲区中。
socket调用send()方法再次引起了用户态到内核态的切换。第三次copy再次将数据放回到内核缓冲区。不过这次的内核缓冲区和上次的不同,这次的缓冲区和目标socket相关。
调用的send()方法返回时,产生了内核态到用户态的上下文切换。这次DMA引擎将数据从内核缓冲区发送到protocol引擎,也就是第四次copy,这是一个独立异步的操作。
使用内核缓冲区作为中间层(而不是直接将数据传送到用户缓冲区)可能看起来有些低效。但是最初将内核缓冲区作为中间层引入进程的目的就是提升性能。在读取数据的时候,作为中间层的内核缓冲区的角色相当于“预读取缓存”,也就是说如果应用请求的数据量比内核缓冲区空间小,就会将一部分数据预读取到作为中间层的内核缓冲区中以供下一次请求使用。很显然,在请求的数据量比内核缓冲区空间小时,这样做可以显著地提升应用性能。在写数据的时候,多个中间层有助于更好地实现异步写(先将数据写到中间缓存,中间层快满时再批量写出)。
不幸的是,在请求的数据量大过内核缓冲区很多时,这种方法本身也会成为性能瓶颈:因为数据会在硬盘、内核缓冲区和用户缓冲区之间多次拷贝。
zero copy可以排除这些多余的copy来提升性能。
zero copy方案
重新思考一下传统的数据传输方案,将会发现第二次和第三次的copy行为实际上是不必要的。在传统方案里,应用做的事情只不过是缓存数据并将之转发到socket缓冲区,我们可以考虑直接将数据从读缓存发送到socket缓冲区中。transferTo()方法能让我们实现这种操作。
transferTo()方法的定义如下:
1
publicvoidtransferTo(longposition,longcount,WritableByteChanneltarget);
transferTo()方法可以将数据从FileChannel发送到指定的WritableByteChannel中。transferTo()方法需要依赖底层操作系统的支持才能实现zero copy。在UNIX系统和各种Linux系统中,支持zero copy的系统方法是sendfile(),这个方法可以将数据从一个文件描述符转发到另一个文件描述符中。
sendfile()方法定义:
1
2
#include
ssize_tsendfile(intout_fd,intin_fd,off_t*offset,size_tcount);
在概述中,我们写过两行代码演示传统数据传输的方法,演示代码中的file.read()和socket.send()两个方法的调用可以替换为调用transferTo()方法,示例如下:
1
transferTo(position,count,writableChannel);
下图演示了调用transferTo()方法时数据传输的路径:
下图演示了调用transferTo()方法时用户态和内核态上下文切换的过程:
调用transferTo()方法涉及到的步骤为:
调用transferTo()方法产生了第一次copy:DMA引擎将文件内容copy到了读缓存中。
然后系统内核将数据copy到与输出socket相关的内核缓冲区中。
第三次copy发生在DMA引擎将数据从内核socket缓冲区发送到protocol引擎时。
看看效果:
将用户态-内核态上下文切换由四次减少到了两次;
将数据的copy由四次减少到了三次(其中只有一次涉及到CPU)。
不过这样子还没有达到使用zero copy的目标。如果底层网卡支持收集操作的话,我们还可以去掉由内核完成的copy(即第二次copy)。在Linux Kernel2.4及以后的版本中,socket缓冲区描述符已经被调整到满足这种需求了。这样这个方案不仅仅是减少了上下文切换的次数,也消除了copy过程中对CPU依赖的部分。尽管用户还是在用transferTo()方法,但是其底层行为已经发生了变化:
调用transferTo()方法时,DMA引擎将文件内容copy到内核缓冲区中;
不再将数据copy到socket缓冲区中,只是将数据描述符(包含地址信息和长度信息)追加到socket缓冲区。DMA引擎直接将数据从内核缓冲区传递到protocol引擎,从而消除了仅剩的CPU copy。
下图展示了使用transferTo()方法和收集操作时copy的详情:
构建文件服务器
现在我们练习使用一下zero copy,就演示一下文件在客户端和服务器之间的传递(示例代码下载地址见文末)。TraditionalClient.java以及TraditionalServer.java是基于传统方案的实现,和新方法是File.read()和Socket.send()。TraditionalServer.java是一个Server端程序,它监听着一个特定的端口以让Client连接,每次会从socket读取4KB数据。TraditionalClient.java连接到Server上,从一个文件中读取(使用File.read()方法)4KB数据并通过socket将数据发送(使用Socket.send()方法)给Server。
类似的,TransferToServer.java和TransferToClient.java实现了相同的功能,不过使用的是transferTo()方法(调用了系统的sendfile()方法),将文件数据从Server端发送到了Client端。
性能比较
我们在一台Linux Kernel版本2.6的机器上执行了示例代码,以毫秒级的时间尺度比较了传统方案和transferTo()方案传输不同大小的数据文件的速度。下表为测试结果:
File size
Normal file transfer (ms)
transferTo (ms)
7MB
156
45
21MB
337
128
63MB
843
387
98MB
1320
617
200MB
2124
1150
350MB
3631
1762
700MB
13498
4422
1GB
18399
8537
可以看到,较之传统方案,transferTo() API降低了大约65%的时间消耗。对于需要在IO channel间进行大量数据copy和传输的应用(比如WebServer),transferTo()可以显著地提升性能。
总结
我们演示了使用transferTo()的性能优势,可以看到中间缓冲区copy(即使是发生在内核中)会有一定的性能损失。对于需要进行channel间大量数据copy的应用,zero copy技术可以显著地提升性能。
其他
###################