考虑这样一个过程:我们从磁盘中读取一个文件数据,然后将数据通过网络传输到另一个机器。对用户来说可能就是简单的理解为两步操作。
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);
但是,如果我们看传输中涉及的内核部分的内部工作原理,我们将看到
即使是使用DMA传输的硬件支持,这种方法也效率很低。首先,内核将使用DMA将磁盘中的数据加载到其自己的内核缓冲区中,除非在先前访问同一文件之后,该数据仍被缓存在内核缓冲区中。
这样传输不需要太多的CPU工作,CPU只需要进行缓冲区管理和DMA创建和处理。linux 操作系统会根据 read() 系统调用指定的应用程序地址空间的地址,把这块数据存放到请求这块数据的应用程序的地址空间中去,在接下来的处理过程中,操作系统需要将数据再一次从用户应用程序地址空间的缓冲区拷贝到与网络堆栈相关的内核缓冲区中去,这个过程也是需要占用 CPU 的。
数据拷贝操作结束以后,数据会被打包,然后发送到网络接口卡上去。在数据传输的过程中,应用程序可以先返回进而执行其他的操作。
之后,在调用 write() 系统调用的时候,用户应用程序缓冲区中的数据内容可以被安全的丢弃或者更改,因为操作系统已经在内核缓冲区中保留了一份数据拷贝,当数据被成功传送到硬件上之后,这份数据拷贝就可以被丢弃。
所以我们会发现这个过程涉及到了3次上下文切换,和4次数据拷贝的过程:
在 Linux 中,减少拷贝次数的一种方法是调用 mmap() 来代替调用 read,比如:
tmp_buf = mmap(file, len);
write(socket, tmp_buf, len);
首先,应用程序调用了 mmap() 之后,数据会先通过 DMA 拷贝到操作系统内核的缓冲区中去。接着,应用程序跟操作系统共享这个缓冲区,这样,操作系统内核和应用程序存储空间就不需要再进行任何的数据拷贝操作。应用程序调用了 write() 之后,操作系统内核将数据从原来的内核缓冲区中拷贝到与 socket 相关的内核缓冲区中。接下来,数据从内核 socket 缓冲区拷贝到协议引擎中去,这是第三次数据拷贝操作
尽管mmap()可以减少一次 I/O 拷贝,但由于mmap()的实现很复杂,调用mmap()将会带来额外的开销,因此在一些情况下,没有使用mmap()的必要:
访问小文件时,直接使用read()或write()将更加高效。
单个进程对文件执行顺序访问时(sequential access),使用mmap()几乎不会带来性能上的提升。譬如说,使用read()顺序读取文件时,文件系统会使用 read-ahead 的方式提前将文件内容缓存到文件系统的缓冲区,因此使用read()将很大程度上可以命中缓存。
那么,在什么情况下使用mmap()去访问文件会更高效呢?
对文件执行随机访问时,如果使用read()或write(),则意味着较低的 cache 命中率。这种情况下使用mmap()通常将更高效。
多个进程同时访问同一个文件时(无论是顺序访问还是随机访问),如果使用mmap(),那么 OS 缓冲区的文件内容可以在多个进程之间共享,从操作系统角度来看,使用mmap()可以大大节省内存。
为了简化用户接口,同时还要继续保留 mmap()/write() 技术的优点:减少 CPU 的拷贝次数,Linux 在版本 2.1 中引入了 sendfile() 这个系统调用。
sendfile(sockfd, fd, NULL, len);
sendfile() 不仅减少了数据拷贝操作,它也减少了上下文切换。首先:sendfile() 系统调用利用 DMA 引擎将文件中的数据拷贝到操作系统内核缓冲区中,然后数据被拷贝到与 socket 相关的内核缓冲区中去。接下来,DMA 引擎将数据从内核 socket 缓冲区中拷贝到协议引擎中去。
可以看到,与使用read()和write()发送文件相比,使用sendfile()减少了一次 I/O 拷贝和两次 上下文切换。
为了避免操作系统内核造成的数据副本,需要用到一个支持收集操作的网络接口,这也就是说,待传输的数据可以分散在存储的不同位置上,而不需要在连续存储中存放。
这样一来,从文件中读出的数据就根本不需要被拷贝到 socket 缓冲区中去,而只是需要将缓冲区描述符传到网络协议栈中去,之后其在缓冲区中建立起数据包的相关结构,然后通过 DMA 收集拷贝功能将所有的数据结合成一个网络数据包。
网卡的 DMA 引擎会在一次操作中从多个位置读取包头和数据。Linux 2.4 版本中的 socket 缓冲区就可以满足这种条件,这也就是用于 Linux 中的众所周知的零拷贝技术,这种方法不但减少了因为多次上下文切换所带来开销,同时也减少了处理器造成的数据副本的个数。
对于用户应用程序来说,代码没有任何改变。首先,sendfile() 系统调用利用 DMA 引擎将文件内容拷贝到内核缓冲区去;然后,将带有文件位置和长度信息的缓冲区描述符添加到 socket 缓冲区中去,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,DMA 引擎会将数据直接从内核缓冲区拷贝到协议引擎中去,这样就避免了最后一次数据拷贝。