背景
网络上不缺 零拷贝 这个技术话题的讲解;但能讲透这里面的一个知识点的,怕是很少。有些大而不全,有些专而不精。一篇国外2008年 讲零拷贝的文章;虽历经十多年 但作者对零拷贝里面transferTo 这个细致技术点的讲解和论证,还不错。推荐给大家。
原文地址:developer.ibm.com/articles/j-…
本文开头稍显啰嗦,但我认为作者把本文的摘要都写到了开头这几段里。
许多 Web 应用程序对大量静态内容提供对外服务,这相当于从磁盘读取数据;然后不做任何的改动并把完全相同的数据,写回到响应套接字。这个一读和一写的过程,可能看起来需要相对较少的 CPU 活动,但效率有些低下。
操作系统内核从磁盘读取数据,然后将这些数据从内核态拷贝到用户态,以此推送到应用程序;然后应用程序把用户态的数据拷贝到内核态;然后再将其推回要写出到套接字。实际上,应用程序充当了将数据从磁盘文件获取到套接字的低效中介。
每次数据穿越用户-内核边界时,都必须进行数据的拷贝,这会消耗 CPU 周期和内存带宽。幸运的是,您可以通过一种称为“零拷贝” 的技术来消除这些副本。使用零拷贝这种技术的应用程序,请求内核将数据直接从磁盘文件复制到套接字,而不通过应用程序。零拷贝极大地提高了应用程序性能,并减少了内核和用户模式之间的上下文切换次数。
Java 类库里 通过java.nio.channels.FileChannel
类的transferTo()
的方法在 Linux 和 UNIX 系统上支持零拷贝。您可以使用该transferTo()
方法将字节直接从调用它的通道传输到另一个可写字节通道,而不需要数据流经应用程序(即要发送的数据,不需要在拷贝到用户态内存空间里)。本文首先演示了通过传统复制语义完成简单文件传输所产生的开销,然后展示了使用零拷贝技术transferTo()
如何实现更好的性能。
数据传输:传统方法
考虑从文件读取数据并通过网络将数据传输到另一个程序的场景。(此场景描述了许多服务器应用程序的行为,包括提供静态内容的 Web 应用程序、FTP 服务器、邮件服务器等。)操作的核心在于清单 1 中的两个调用 —— 或者下载完整的示例代码:
清单 1. 将字节从文件复制到套接字
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);
虽然清单 1 在概念上很简单,但在内部,复制操作需要在用户模式和内核模式之间进行四次上下文切换,并且在操作完成之前数据会被复制四次。图 1 显示了数据如何从文件内部移动到套接字:
图 1. 传统数据复制方法
图 2 显示了上下文切换:
图 2. 传统的上下文切换
这里面的步骤:
read()
上下文切换(参见图 2 )。在内部发出 一次sys_read()
(或等效的)来从文件中读取数据。第一次复制 参见图 1)由直接内存访问 (DMA) 引擎执行,该引擎从磁盘读取文件内容并将其存储到内核地址空间缓冲区中。(通过DMA把硬件上的数据,复制到内核磁盘缓冲区)read()
返回。调用的返回导致另一次上下文从内核切换回用户模式。现在数据存储在用户地址空间缓冲区中。send()
调用导致上下文从用户模式切换到内核模式。执行第三次复制以将数据再次放入内核地址空间缓冲区(内核socket缓存区)中。不过,这一次,数据被放入不同的缓冲区中,该缓冲区与目标套接字相关联。send()
调用返回,创建第四个上下文切换。当 DMA 引擎将数据从内核缓冲区传递到协议引擎时,会独立且异步地进行第四次复制。使用了中间的内核缓冲区(而不是直接将数据传输到用户缓冲区)可能看起来效率低下。但内核缓冲区实际是用来提高性能的。当应用程序没有请求内核缓冲区所容纳的数据量时,在读取端使用中间缓冲区允许内核缓冲区充当“预读缓存”。当请求的数据量小于内核缓冲区大小时,这会显着提高性能。因为写入端的中间缓冲区允许写入异步完成。(猜测作者想表达的是,如果要读的磁盘的数据,正好内核缓存里有,那么直接读内核缓冲区里的数据,不需要再去读磁盘上的数据,以此来提高读的性能;而对于写,同样的,应用程序在往磁盘上写数据时,先写到内核的磁盘缓冲区,而不直接写到硬件上,后续异步在写入硬件上,以此来提高写的性能)
不幸的是,如果请求的数据大小远大于内核缓冲区大小,则这种方法本身可能会成为性能瓶颈。数据在最终交付给应用程序之前会在磁盘、内核缓冲区和用户缓冲区之间进行多次复制。
零拷贝通过消除这些冗余数据拷贝来提高性能。
数据传输:零拷贝方法
如果您重新检查传统场景,您会发现实际上并不需要第二个和第三个数据拷贝(即内核磁盘缓冲区拷贝到用户态,再从用户态拷贝到内核socket缓冲区)。应用程序除了缓存数据并将其传输回套接字缓冲区之外什么也不做。相反,数据可以直接从读缓冲区传输到套接字缓冲区。该transferTo()
方法可以让您准确地实现这一点。清单 2 显示了 transferTo()
方法的签名:
清单 2. TransferTo() 方法
public void transferTo(long position, long count, WritableByteChannel target);
该transferTo()
方法将数据从文件通道传输到给定的可写字节通道。在内部,取决于底层操作系统对零拷贝的支持;在 UNIX 和各种版本的 Linux 中,此调用被路由到sendfile()
系统调用,如清单 3 所示,该系统调用将数据从一个文件描述符传输到另一个文件描述符:
清单 3. sendfile() 系统调用
#include
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
清单 1中的file.read()
和socket.send()
,这两个调用 可以替换为单个调用,如清单 4 所示:transferTo()
清单 4. 使用transferTo() 将数据从磁盘文件拷贝到套接字
transferTo(position, count, writableChannel);
图3展示了使用 transferTo()
方法时的数据路径:
图 3. 使用 TransferTo() 进行数据拷贝
transferTo()
图 4 显示了使用该方法时的上下文切换:
图 4. 使用 TransferTo() 进行上下文切换
在清单 4 里使用 transferTo()
方法的步骤是:
transferTo()
方法使 DMA 引擎将文件内容复制到读缓冲区中。然后数据被内核复制到与输出套接字关联的内核缓冲区中。(即通过DMA把硬盘上的数据复制到内核磁盘缓冲区;然后在把内核磁盘缓存区数据复制到内核sockert缓冲区)这是一项改进:我们将上下文切换的次数,从 4 次减少到了 2 次,并将数据副本的数量从 4 个减少到了 3 个(其中只有一个涉及 CPU)。但这还没有让我们达到零拷贝的目标。如果底层网络接口卡支持收集操作,我们可以进一步减少内核所做的数据重复。在 Linux 内核 2.4 及更高版本中,修改了套接字缓冲区描述符以适应此要求。这种方法不仅减少了多次上下文切换,还消除了需要 CPU 参与的重复数据副本。用户端用法仍然保持不变,但内在原理发生了变化:
transferTo()
方法使 DMA 引擎将文件内容复制到内核缓冲区中。图 5 显示了在进行数据拷贝时 transferTo()
方法 使用的收集操作:
图 5. 使用transferTo() 和收集操作时的数据副本
构建一个文件服务器
现在让我们实践下零拷贝这种技术,使用在客户端和服务器之间传输文件的相同示例;请参阅完整的示例代码。TraditionalClient.java
和TraditionalServer.java
基于传统的复制方法,使用File.read()
和Socket.send()
。
TraditionalServer.java
是一个服务器程序:它监听特定端口以供客户端连接,然后从套接字一次读取 4K 字节的数据。TraditionalClient.java
客户端程序:File.read()
从文件中读取(使用)4K 字节的数据,然后socket.send()
通过套接字将内容发送(使用)到服务器。
类似地,TransferToServer.java
执行TransferToClient.java
相同的功能,但改为使用transferTo()
方法(以及sendfile()
系统调用)将文件从服务器传输到客户端。
性能对比
我们在linux 2.6 上执行相同的示例程序,并测量传统方法和各种文件大小的场景的 运行时间(以毫秒为单位) 。表 1 显示结果:
表 1. 性能比较:传统方法与零拷贝
正如您所看到的,transferTo()
与传统方法相比,减少了大约 65% 的时间。对于需要将大量数据从一个 I/O 通道复制到另一个 I/O 通道的应用程序(例如 Web 服务器),这有可能显着提高性能。
概括
我们已经展示使用 transferTo()
方法 ,从一个通道读取相同数据并将相同数据写入另一个通道相比的性能优势。中间缓冲区副本(即使是隐藏在内核中的拷贝)可能会产生可衡量的成本。在一个应用程序里,通过在两个通道之间进行大量数据复制时,使用零拷贝技术可以显着提高性能。