前言
本文为 《图解系统》系列文章的个人学习笔记,对具体知识点与示例进行了归纳整理,详细内容参考小林coding 。
文章目录
一、 什么是零拷贝?
1.1 传统 I/O 过程 & DMA 技术引入
在没有 DMA 技术前,I/O 的过程是这样的:
整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。
直接内存访问(Direct Memory Access,DMA) 技术引入后,在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。
1.2 传统文件传输的性能问题
如果服务端要提供文件传输的功能,能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。上述操作一般需要使用两个系统调用:
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
数据读取(read()
)和写入(wirte()
)是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。
如图所示,传统文件传输方式将涉及 4 次 用户态与内核态的上下文切换,以及 4 次数据拷贝。显然,传统文件传输过程中,存在这多次不必要的上下文切换和内存拷贝,这极大的限制了文件传输性能。
1.3 文件传输性能优化——零拷贝的引入
A. 优化思路
要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数。
- 针对用户态与内核态的上下文切换,由于一次系统调用必然会发生 2 次上下文切换(用户态 - 内核态 - 用户态),因此需要减少减少系统调用的次数。
- 针对内存拷贝,若在用户空间我们并不会对数据「再加工」,数据实际上可以不用搬运到用户空间。
B. 零拷贝的实现
零拷贝技术实现的方式通常有 2 种:
(1)mmap
+write
:减少内存拷贝次数(数据不再搬运至用户空间)。
(2)senfile
:减少系统调用次数 + 减少内存拷贝次数(一次系统调用完成读/写两个操作 + 数据不再搬运至用户空间)。
此外,若网卡支持 SG-DMA技术,还可以进一步减少内核空间的内存拷贝次数(将内核缓冲区里的数据直接拷贝到 网卡,跳过 socket 缓冲区)。
优化方案1. mmap + write
read()
系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap()
替换 read()
系统调用函数。
mmap()
系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,因此操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。
buf = mmap(file, len);
write(sockfd, buf, len);
但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4
次上下文切换和 3
次数据拷贝次数。
方案2. sendfile
在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数sendfile()
,函数形式如下:
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。该系统调用有一下两个特点:
senfile()
替代了前面的read()
和write()
这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。senfile()
可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态。
在该方案下,文件传输过程需要 2
次上下文切换和 3
次数据拷贝次数。
方案3. sendfile + SG-DMA
如果网卡支持 SG-DMA 技术,可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
在该方案下,文件传输过程只需要 2
次上下文切换和 2
次数据拷贝次数。
Kafka 和 Nginx 都有实现零拷贝技术,这将大大提高文件传输的性能。
C. PageCache 的作用?
零拷贝技术是基于 PageCache 的,PageCache 会缓存最近访问的「热点」数据,提升了访问缓存数据的性能,同时,为了解决机械硬盘寻址慢的问题。这些优势,进一步提升了零拷贝的性能。
需要注意的是,零拷贝技术是不允许进程对文件内容作进一步的加工的,比如压缩数据再发送。
另外,当传输大文件时,不能使用零拷贝,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,并且大文件的缓存命中率不高,这时就需要使用「异步 IO + 直接 IO 」的方式。
TIP:在 Nginx 里,可以通过配置,设定一个文件大小阈值,针对大文件使用异步 IO 和直接 IO,而对小文件使用零拷贝。
二、I/O 多路复用:select/poll/epoll
2.1 基本Socket模型
最基础的 TCP 的 Socket 编程,它是阻塞 I/O 模型,基本上只能一对一通信,那为了服务更多的客户端,我们需要改进网络 I/O 模型。
比较传统的方式是使用多进程/线程模型,每来一个客户端连接,就分配一个进程/线程,然后后续的读写都在对应的进程/线程,这种方式处理 100 个客户端没问题,但是当客户端增大到 10000 个时,10000 个进程/线程的调度、上下文切换以及它们占用的内存,都会成为瓶颈。
2.2 I/O 的多路复用
为了解决上面这个问题,就出现了 I/O 的多路复用,可以只在一个进程里处理多个文件的 I/O,Linux 下有三种提供 I/O 多路复用的 API,分别是:select、poll、epoll。
A. select & poll
select 和 poll 并没有本质区别,它们内部都是使用「线性结构」来存储进程关注的 Socket 集合。
在使用的时候,首先需要把关注的 Socket 集合通过 select/pol 系统调用从用户态拷贝到内核态,然后由内核检测事件,当有网络事件产生时,内核需要遍历进程关注 Socket 集合,找到对应的 Socket,并设置其状态为可读/可写,然后把整个 Socket 集合从内核态拷贝到用户态,用户态还要继续遍历整个 Socket 集合找到可读/可写的Socket,然后对其处理。
很明显发现,select和 poll 的缺陷在于,当客户端越多,也就是 Socket 集合越大,Socket 集合的遍历和拷贝会带来很大的开销,因此也很难应对 C10K。
B. epoll
epoll 是解决 C10K 问题的利器,通过两个方面解决了 select/poll 的问题。
- epoll 在内核里使用「红黑树」来关注进程所有待检测的 Socket,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn),通过对这棵黑红树的管理,不需要像 select/poll 在每次操作时都传入整个 Socket 集合减少了内核和用户空间大量的数据拷贝和内存分配。
- epoll 使用事件驱动的机制,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的 Socket 集合传递给应用程序,不需要像select/poll 那样轮询扫描整个集合(包含有和无事件的 Socket ),大大提高了检测的效率。
而且,epoll 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。