图 1. 使用直接 I/O 的数据传输
针对数据传输不需要经过应用程序地址空间的零拷贝技术
利用 mmap()
在 Linux 中,减少拷贝次数的一种方法是调用 mmap() 来代替调用 read,比如:
tmp_buf = mmap(file, len); write(socket, tmp_buf, len);首先,应用程序调用了 mmap() 之后,数据会先通过 DMA 拷贝到操作系统内核的缓冲区中去。接着,应用程序跟操作系统共享这个缓冲区,这样,操作系统内核和应用程序存储空间就不需要再进行任何的数据拷贝操作。应用程序调用了 write() 之后,操作系统内核将数据从原来的内核缓冲区中拷贝到与 socket 相关的内核缓冲区中。接下来,数据从内核 socket 缓冲区拷贝到协议引擎中去,这是第三次数据拷贝操作。
sendfile()
为了简化用户接口,同时还要继续保留 mmap()/write() 技术的优点:减少 CPU 的拷贝次数,Linux 在版本 2.1 中引入了 sendfile() 这个系统调用。
sendfile() 不仅减少了数据拷贝操作,它也减少了上下文切换。首先:sendfile() 系统调用利用 DMA 引擎将文件中的数据拷贝到操作系统内核缓冲区中,然后数据被拷贝到与 socket 相关的内核缓冲区中去。接下来,DMA 引擎将数据从内核 socket 缓冲区中拷贝到协议引擎中去。如果在用户调用 sendfile () 系统调用进行数据传输的过程中有其他进程截断了该文件,那么 sendfile () 系统调用会简单地返回给用户应用程序中断前所传输的字节数,errno 会被设置为 success。如果在调用 sendfile() 之前操作系统对文件加上了租借锁,那么 sendfile() 的操作和返回状态将会和 mmap()/write () 一样。
图 3. 利用 sendfile () 进行数据传输
sendfile() 系统调用不需要将数据拷贝或者映射到应用程序地址空间中去,所以 sendfile() 只是适用于应用程序地址空间不需要对所访问数据进行处理的情况。相对于 mmap() 方法来说,因为 sendfile 传输的数据没有越过用户应用程序 / 操作系统内核的边界线,所以 sendfile () 也极大地减少了存储管理的开销。但是,sendfile () 也有很多局限性,如下所列:
带有 DMA 收集拷贝功能的 sendfile()
上小节介绍的 sendfile() 技术在进行数据传输仍然还需要一次多余的数据拷贝操作,通过引入一点硬件上的帮助,这仅有的一次数据拷贝操作也可以避免。为了避免操作系统内核造成的数据副本,需要用到一个支持收集操作的网络接口,这也就是说,待传输的数据可以分散在存储的不同位置上,而不需要在连续存储中存放。这样一来,从文件中读出的数据就根本不需要被拷贝到 socket 缓冲区中去,而只是需要将缓冲区描述符传到网络协议栈中去,之后其在缓冲区中建立起数据包的相关结构,然后通过 DMA 收集拷贝功能将所有的数据结合成一个网络数据包。网卡的 DMA 引擎会在一次操作中从多个位置读取包头和数据。Linux 2.4 版本中的 socket 缓冲区就可以满足这种条件,这也就是用于 Linux 中的众所周知的零拷贝技术,这种方法不但减少了因为多次上下文切换所带来开销,同时也减少了处理器造成的数据副本的个数。对于用户应用程序来说,代码没有任何改变。首先,sendfile() 系统调用利用 DMA 引擎将文件内容拷贝到内核缓冲区去;然后,将带有文件位置和长度信息的缓冲区描述符添加到 socket 缓冲区中去,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,DMA 引擎会将数据直接从内核缓冲区拷贝到协议引擎中去,这样就避免了最后一次数据拷贝。
图 4. 带有 DMA 收集拷贝功能的 sendfile
通过这种方法,CPU 在数据传输的过程中不但避免了数据拷贝操作,理论上,CPU 也永远不会跟传输的数据有任何关联,这对于 CPU 的性能来说起到了积极的作用:首先,高速缓冲存储器没有受到污染;其次,高速缓冲存储器的一致性不需要维护,高速缓冲存储器在 DMA 进行数据传输前或者传输后不需要被刷新。然而实际上,后者实现起来非常困难。源缓冲区有可能是页缓存的一部分,这也就是说一般的读操作可以访问它,而且该访问也可以是通过传统方式进行的。只要存储区域可以被 CPU 访问到,那么高速缓冲存储器的一致性就需要通过 DMA 传输之前冲刷新高速缓冲存储器来维护。而且,这种数据收集拷贝功能的实现是需要硬件以及设备驱动程序支持的。
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);
ssize_t (*splice_write)(struct inode *pipe, strucuct file *out, size_t len, unsigned int flags); ssize_t (*splice_read)(struct inode *in, strucuct file *pipe, size_t len, unsigned int flags);这两个新的操作可以根据 flags 的设定在 pipe 和 in 或者 out 之间移动 len 个字节。Linux 文件系统已经实现了具有上述功能并且可以使用的操作,而且还实现了一个 generic_splice_sendpage() 函数用于和 socket 之间的接合。
数据传输的实现及其局限性
数据发送端
对于数据传输的发送端来说,实现相对来说是比较简单的,对与应用程序缓冲区相关的物理页面进行加锁,并将这些页面映射到操作系统内核的地址空间,并标识为“ write only ”。当系统调用返回的时候,用户应用程序和网络堆栈就都可以读取该缓冲区中的数据。在操作系统已经传送完所有的数据之后,应用程序就可以对这些数据进行写操作。如果应用程序尝试在数据传输完成之前对数据进行写操作,那么就会产生异常,这个时候操作系统就会将数据拷贝到应用程序自己的缓冲区中去,并且重置应用程序端的映射。数据传输完成之后,对加锁的页面进行解锁操作,并重置 COW 标识。
数据接收端
对于数据接收端来说,该技术的实现则需要处理复杂得多的情况。如果 read() 系统调用是在数据包到达之前发出的,并且应用程序是被阻塞的,那么 read() 系统调用就会告知操作系统接收到的数据包中的数据应该存放到什么地方去。在这种情况下,根本没有必要进行页面重映射,网络接口卡可以提供足够的支持让数据直接存入用户应用程序的缓冲区中去。如果数据接收是异步的,在 read() 系统调用发出之前,操作系统不知道该把数据写到哪里,因为它不知道用户应用程序缓冲区的位置,所以操作系统内核必须要先把数据存放到自己的缓冲区中去。
局限性
写时复制技术有可能会导致操作系统的处理开销很大.所有相关的缓冲区都必须要进行页对齐处理,并且使用的 MMU 页面一定要是整数个的。对于发送端来说,这不会造成什么问题。但是对于接收端来说,它需要有能力处理更加复杂的情况。首先,数据包的尺寸大小要合适,大小需要恰到好处能够覆盖一整页的数据,这就限制了那些 MTU 大小大于系统内存页的网络,比如 FDDI 和 ATM。其次,为了在没有任何中断的情况下将页面重映射到数据包的流,数据包中的数据部分必须占用整数个页面。对于异步接收数据的情况来说,为了将数据高效地移动到用户地址空间中去,可以使用这样一种方法:利用网络接口卡的支持,传来的数据包可以被分割成包头和数据两部分,数据被存放在一个单独的缓冲区内,虚拟存储系统然后就会将数据映射到用户地址空间缓冲区去。使用这种方法需要满足两个先决条件,也就是上面提到过的:一是应用程序缓冲区必须是页对齐的,并且在虚拟存储上是连续的;二是传来的数据有一页大小的时候才可以对数据包进行分割。事实上,这两个先决条件是很难满足的。如果应用程序缓冲区不是页对齐的,或者数据包的大小超过一个页,那么数据就需要被拷贝。对于数据发送端来说,就算数据在传输的过程中对于应用程序来说是写保护的,应用程序仍然需要避免使用这些忙缓冲区,这是因为写时拷贝操作所带来的开销是很大的。如果没有端到端这一级别的通知,那么应用程序很难会知道某缓冲区是否已经被释放还是仍然在被占用。
这种零拷贝技术比较适用于那种写时复制事件发生比较少的情况,因为写时复制事件所产生的开销要远远高于一次 CPU 拷贝所产生的开销。实际情况中,大多数应用程序通常都会多次重复使用相同的缓冲区,所以,一次使用完数据之后,不要从操作系统地址空间解除页面的映射,这样会提高效率。考虑到同样的页面可能会被再次访问,所以保留页面的映射可以节省管理开销,但是,这种映射保留不会减少由于页表往返移动和 TLB 冲刷所带来的开销,这是因为每次页面由于写时复制而进行加锁或者解锁的时候,页面的只读标志都要被更改。
缓冲区共享
还有另外一种利用预先映射机制的共享缓冲区的方法也可以在应用程序地址空间和操作系统内核之间快速传输数据。采用缓冲区共享这种思想的架构最先在 Solaris 上实现,该架构使用了“ fbufs ”这个概念。这种方法需要修改 API。应用程序地址空间和操作系统内核地址空间之间的数据传递需要严格按照 fbufs 体系结构来实现,操作系统内核之间的通信也是严格按照 fbufs 体系结构来完成的。每一个应用程序都有一个缓冲区池,这个缓冲区池被同时映射到用户地址空间和内核地址空间,也可以在必要的时候才创建它们。通过完成一次虚拟存储操作来创建缓冲区,fbufs 可以有效地减少由存储一致性维护所引起的大多数性能问题。该技术在 Linux 中还停留在实验阶段。
为什么要扩展 Linux I/O API
传统的 Linux 输入输出接口,比如读和写系统调用,都是基于拷贝的,也就是说,数据需要在操作系统内核和应用程序定义的缓冲区之间进行拷贝。对于读系统调用来说,用户应用程序呈现给操作系统内核一个预先分配好的缓冲区,内核必须把读进来的数据放到这个缓冲区内。对于写系统调用来说,只要系统调用返回,用户应用程序就可以自由重新利用数据缓冲区。
为了支持上面这种机制,Linux 需要能够为每一个操作都进行建立和删除虚拟存储映射。这种页面重映射的机制依赖于机器配置、cache 体系结构、TLB 未命中处理所带来的开销以及处理器是单处理器还是多处理器等多种因素。如果能够避免处理 I/O 请求的时候虚拟存储 / TLB 操作所产生的开销,则会极大地提高 I/O 的性能。fbufs 就是这样一种机制。使用 fbufs 体系结构就可以避免虚拟存储操作。由数据显示,fbufs 这种结构在 DECStation™ 5000/200 这个单处理器工作站上会取得比上面提到的页面重映射方法好得多的性能。如果要使用 fbufs 这种体系结构,必须要扩展 Linux API,从而实现一种有效而且全面的零拷贝技术。
快速缓冲区( Fast Buffers )原理介绍
I/O 数据存放在一些被称作 fbufs 的缓冲区内,每一个这样的缓冲区都包含一个或者多个连续的虚拟存储页。应用程序访问 fbuf 是通过保护域来实现的,有如下这两种方式:
图 5. Linux I/O API
I/O 子系统或者应用程序都可以通过 fbufs 管理器来分配 fbufs。一旦分配了 fbufs,这些 fbufs 就可以从程序传递到 I/O 子系统,或者从 I/O 子系统传递到程序。使用完后,这些 fbufs 会被释放回 fbufs 缓冲区池。
fbufs 在实现上有如下这些特性,如图 6 所示:
图 6. fbufs 体系结构
前面提到,这种方法需要修改 API,如果要使用 fbufs 体系结构,应用程序和 Linux 操作系统内核驱动程序都需要使用新的 API,如果应用程序要发送数据,那么它就要从缓冲区池里获取一个 fbuf,将数据填充进去,然后通过文件描述符将数据发送出去。接收到的 fbufs 可以被应用程序保留一段时间,之后,应用程序可以使用它继续发送其他的数据,或者还给缓冲区池。但是,在某些情况下,需要对数据包内的数据进行重新组装,那么通过 fbuf 接收到数据的应用程序就需要将数据拷贝到另外一个缓冲区内。再者,应用程序不能对当前正在被内核处理的数据进行修改,基于这一点,fbufs 体系结构引入了强制锁的概念以保证其实现。对于应用程序来说,如果 fbufs 已经被发送给操作系统内核,那么应用程序就不会再处理这些 fbufs。
fbufs 存在的一些问题
管理共享缓冲区池需要应用程序、网络软件、以及设备驱动程序之间的紧密合作。对于数据接收端来说,网络硬件必须要能够将到达的数据包利用 DMA 传输到由接收端分配的正确的存储缓冲区池中去。而且,应用程序稍微不注意就会更改之前发到共享存储中的数据的内容,从而导致数据被破坏,但是这种问题在应用程序端是很难调试的。同时,共享存储这种模型很难与其他类型的存储对象关联使用,但是应用程序、网络软件以及设备驱动程序之间的紧密合作是需要其他存储管理器的支持的。对于共享缓冲区这种技术来说,虽然这种技术看起来前景光明,但是这种技术不但需要对 API 进行更改,而且需要对驱动程序也进行更改,并且这种技术本身也存在一些未解决的问题,这就使得这种技术目前还只是出于试验阶段。在测试系统中,这种技术在性能上有很大的改进,不过这种新的架构的整体安装目前看起来还是不可行的。这种预先分配共享缓冲区的机制有时也因为粒度问题需要将数据拷贝到另外一个缓冲区中去。
总结
本系列文章介绍了 Linux 中的零拷贝技术,本文是其中的第二部分。本文对第一部分文章中提出的 Linux 操作系统上出现的几种零拷贝技术进行了更详细的介绍,主要描述了它们各自的优点,缺点以及适用场景。对于网络数据传输来说,零拷贝技术的应用受到了很多体系结构方面因素的阻碍,包括虚拟存储体系结构以及网络协议体系结构等。所以,零拷贝技术仍然只是在某些很特殊的情况中才可以应用,比如文件服务或者使用某种特殊的协议进行高带宽的通信等。但是,零拷贝技术在磁盘操作中的应用的可行性就高得多了,这很可能是因为磁盘操作具有同步的特点,以及数据传输单元是按照页的粒度来进行的。
针对 Linux 操作系统平台提出并实现了很多种零拷贝技术,但是并不是所有这些零拷贝技术都被广泛应用于现实中的操作系统中的。比如,fbufs 体系结构,它在很多方面看起来都很吸引人,但是使用它需要更改 API 以及驱动程序,它还存在其他一些实现上的困难,这就使得 fbufs 还只是停留在实验的阶段。动态地址重映射技术只是需要对操作系统做少量修改,虽然不需要修改用户软件,但是当前的虚拟存储体系结构并不能很好地支持频繁的虚拟地址重映射操作。而且为了保证存储的一致性,重映射之后还必须对 TLB 和一级缓存进行刷新。事实上,利用地址重映射实现的零拷贝技术适用的范围是很小的,这是因为虚拟存储操作所带来的开销往往要比 CPU 拷贝所产生的开销还要大。此外,为了完全消除 CPU 访问存储,通常都需要额外的硬件来支持,而这种硬件的支持并不是很普及,同时也是非常昂贵的。
本系列文章的目的是想帮助读者理清这些出现在 Linux 操作系统中的零拷贝技术都是从何种角度来帮助改善数据传输过程中遇到的性能问题的。关于各种零拷贝技术的具体实现细节,本系列文章没有做详细描述。同时,零拷贝技术一直是在不断地发展和完善当中的,本系列文章并没有涵盖 Linux 上出现的所有零拷贝技术。
参考资料
学习
讨论
原文:http://www.ibm.com/developerworks/cn/linux/l-cn-zerocopy2/index.html