Linux 网络系统
IO 其实就是 Input 和 Output,在操作系统中就对应数据流的输入与输出。这个数据流的两端,可以是文件,也可以是网络的一台主机。
一个文件要从磁盘到我们的内存,需要经过很复杂的操作:
- 首先,需要将数据从 硬件 读取出来,然后放入操作系统 内核缓冲区
- 之后再将数据从 内核缓冲区 拷贝到 程序缓冲区 ,最后应用程序才能读取到这个文件。
简单地说,无论什么 IO 模型,其读取过程总会经历下面两个阶段:
- 等待数据到达内核缓冲区
- 从内核缓冲区拷贝数据到程序缓冲区
而我们 Linux 根据这两个阶段的是否阻塞,分成了 5 个经典的 IO 的模型,分别是:
- 阻塞 IO 模型
- 非阻塞 IO 模型
- IO 复用模型
- 信号驱动 IO 模型
- 异步 IO 模型
在正式进入Linux的IO模型的学习之前,我们先要了解一部分前置知识:
- 内存空间的划分
- 内存与磁盘的交换方式
- 缓存IO与直接IO
- 磁盘IO与网络IO
- 同步IO与异步IO
- 阻塞IO与非阻塞IO
前置知识
内存空间的划分
在操作系统中,为了保证操作系统的稳定性,将内存划分为两个空间:
- 用户空间
- 不能直接访问磁盘等资源,需要调用内核空间来完成。
- 用户应用程序运行处。
- 内核空间
- 负责管理系统资源和提供核心功能,例如文件系统、内存管理、进程管理等。
- 内核空间可以访问系统的底层硬件和资源。
用户空间和内核空间之间通过系统调用(system call)进行通信,为了安全,它们是隔离的,即使用户的程序崩溃了,内核也不受影响。
涉及到系统调用就会造成系统上下文切换,会影响性能。
内存与磁盘的交换方式
内存与磁盘(包括各种慢速I/O设备)的数据传输方式有两种:
- PIO
- 数据都要经过CPU存储转发,再到内存(内核空间)。
- DMA
- 数据直接从磁盘到内存(内核空间),传送完再通知CPU。
现在大部分使用的方式都是DMA,这样可以释放CPU性能。
缓存IO与直接IO
- 缓存IO
- 磁盘DMA copy到内核
- 内核cpu copy到用户
- 直接IO
- 磁盘DMA copy到用户
缓存IO即为内核空间的缓冲区,用户空间的读取和写入行为都直接作用于缓冲区,可以有效降低IO次数和用户态与内核态的切换次数。
直接IO即用户空间的读取和写入操作直接"落盘",减少了额外的拷贝工作,对于数据库层面使用较多。
磁盘IO与网络IO
- 磁盘IO一般就是基于File类,即文件的IO模式。
- 网络IO一般是基于Socket的IO模式。
同步IO与异步IO
同步和异步针对的是程序和内核空间之间的数据交互。
- 同步
- 等待内核空间准备好后接收数据
- 异步
- 无需等待内核空间
- 内核空间准备好后通知它
- 它再进行数据拷贝等操作。
阻塞IO与非阻塞IO
阻塞和非阻塞针对的是调用方式
- 阻塞
- 进行系统调用时,主线程阻塞
- 非阻塞
- 进行系统调用时,主线程非阻塞
Linux的IO模型
阻塞 IO 模型
阻塞 IO 称为 Blocking IO,简称 BIO。
在阻塞 IO 模型里,从硬件到系统内核、从系统内核到程序空间,都是阻塞的。
阻塞的意思是进程阻塞,不再执行其他操作。
因此,在使用上,我们通常是创建独立的线程去执行读取操作,但是当并发较大的时候,需要创建大量的线程来处理连接,需要占用大量的系统资源,连接建立完成以后,如果当前线程没有数据可读,将会阻塞在read操作上造成线程资源的浪费。
基于上述问题,解决方案如下:
- 采用IO复用模型
- 线程池复用
上述结合起来,即为Reactor模式。
非阻塞 IO 模型
非阻塞 IO 中的非阻塞说的就是进程不会阻塞在这里,而是会不断重试。
(这个重试 个人理解是,读取失败后,可以去使用新建一个线程去读的方式来处理,而不是和阻塞io一样,直接停在那儿了)
在非阻塞 IO 模型里,从硬件到系统内核并不是阻塞的,从系统内核到程序空间,是阻塞的。
IO 复用模型
IO 复用之所以叫复用,是因为其能同时操作多个数据流。而前面的 阻塞 IO、非阻塞 IO 同一时间只能操作一个数据流。
在 IO 复用模型中,进程监听多个数据流并阻塞,当任何一个数据流有数据之后,其便会收到内核的响应。
IO 复用模型在第二阶段与阻塞 IO 和非阻塞 IO 是完全一致的。但是在第一阶段上,其有效率上的巨大提升,其能同时 轮询 多个数据流,提高了效率。
IO多路复用模型的具体实现是通过select、poll 以及 epoll。
select 和 poll
select 实现多路复用的方式是:
- 将已连接的 Socket 都放到一个文件描述符集合
- 调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生
- 检查的方式很粗暴,就是通过遍历文件描述符集合的方式
- 当检查到有事件产生后,将此 Socket 标记为可读或可写
- 把整个文件描述符集合拷贝回用户态里
- 然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理
所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。
poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
epoll
epoll 通过两个方面,很好解决了 select/poll 的问题。
- epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里。
- epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。且不需要像select和/poll 一样 传递整个集合给用户态copy。
这里需要特别强调一下 epoll 对比 select/poll 能减少内核态->用户态的copy,是因为 epoll 使用的一个链表来存放以就绪事件,只需要传递已就绪事件便可以完成读写需求, 网上有文章说 epoll 直接在用户态和内核态之间共享内存(这是完全错误的) 内核态和用户态的隔离是操作系统的基本安全原则,确保用户空间的程序不能直接访问或修改内核空间的数据。
epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT)。
- 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
- 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;
信号驱动 IO 模型
信号驱动 IO 分为两个阶段
- 请求内核读取数据,这时候不会阻塞,也不会去寻轮,而是设置一个信号回调。
- 当数据完全拷贝到系统内核时,系统发出 SIGIO 信号,通知进程去进行第二阶段,将数据拷贝到程序缓冲区。
异步 IO 模型
异步 IO 相比前面几个流程,真正做到了完全非阻塞。无论是在第一阶段,还是在第二阶段都是非阻塞。
与信号驱动 IO 类似,异步 IO 模型通过信号回调的方式,在第一个阶段实现了进程的非阻塞。而当数据到达内核缓冲区之后,进程便会收到通知。
而当进程收到通知之后,进程再次将数据从内核缓冲区复制到进程缓冲区,但这时进程并不等待,而是同样设置一个信号回调。当复制完成后,进程收到通知,再进行相应的处理。
异步 IO 不仅仅是在第一阶段实现了信号回调,其也在第二阶段实现了信号回调,从而完全实现了异步 IO 操作。
总结
我们回顾一下这 5 种 IO 模型,按照其在两个阶段的特点区分:
- 阻塞 IO 模型:硬件到系统内核,阻塞。系统内核到程序空间,阻塞。
- 非阻塞 IO 模型:硬件到系统内核,轮询阻塞。系统内核到程序空间,阻塞。
- 复用 IO 模型:硬件到系统内核,多流轮询阻塞。系统内核到程序空间,阻塞。
- 信号驱动 IO 模型:硬件到系统内核,信号回调不阻塞。系统内核到程序空间,阻塞。
- 异步 IO 模型:硬件到系统内核,信号回调不阻塞。系统内核到程序空间,信号回调不阻塞。
Linux的网络模型
Reactor
Reactor 模式,是基本基于 I/O 多路复用,基于面向对象的思想,对 I/O 多路复用作了一层封装,让使用者不用考虑底层网络 API 的细节,只需要关注应用代码的编写。
Reactor 模式主要由 Reactor 和 处理资源池 这两个核心部分组成,它俩负责的事情如下:
- Reactor 负责监听和分发事件,事件类型包含连接事件、读写事件;
- 处理资源池负责处理事件,如 read -> 业务逻辑 -> send;
Reactor 模式是灵活多变的,可以应对不同的业务场景,灵活在于:
- Reactor 的数量可以只有一个,也可以有多个;
- 处理资源池可以是单个进程 / 线程,也可以是多个进程 /线程;
单 Reactor 单进程 / 线程
进程里有 Reactor、Acceptor、Handler 这三个对象:
- Reactor 对象的作用是监听和分发事件;
- Acceptor 对象的作用是获取连接;
- Handler 对象的作用是处理业务;
接下来,介绍下「单 Reactor 单进程」这个方案:
- Reactor 对象通过 select (IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;
- 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件;
- 如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应;
- Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。
单 Reactor 单进程的方案因为全部工作都在同一个进程内完成,所以实现起来比较简单,不需要考虑进程间通信,也不用担心多进程竞争。
但是,这种方案存在 2 个缺点:
第一个缺点,因为只有一个进程,无法充分利用 多核 CPU 的性能; 第二个缺点,Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟;
所以,单 Reactor 单进程的方案不适用计算机密集型的场景,只适用于业务处理非常快速的场景。
单 Reactor 多线程 / 多进程
详细说一下这个方案:
- Reactor 对象通过 select (IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;
- 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件;
- 如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应;
- Handler 对象不再负责业务处理,只负责数据的接收和发送,Handler 对象通过 read 读取到数据后,会将数据发给子线程里的 Processor 对象进行业务处理;
- 子线程里的 Processor 对象就进行业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send 方法将响应结果发送给 client;
单 Reator 多线程的方案优势在于能够充分利用多核 CPU 的能,那既然引入多线程,那么自然就带来了多线程竞争资源的问题。
例如,子线程完成业务处理后,要把结果传递给主线程的 Handler 进行发送,这里涉及共享数据的竞争。
要避免多线程由于竞争共享资源而导致数据错乱的问题,就需要在操作共享资源前加上互斥锁,以保证任意时间里只有一个线程在操作共享资源,待该线程操作完释放互斥锁后,其他线程才有机会操作共享数据。
聊完单 Reactor 多线程的方案,接着来看看单 Reactor 多进程的方案。
事实上,单 Reactor 多进程相比单 Reactor 多线程实现起来很麻烦,主要因为要考虑子进程 <-> 父进程的双向通信,并且父进程还得知道子进程要将数据发送给哪个客户端。
而多线程间可以共享数据,虽然要额外考虑并发问题,但是这远比进程间通信的复杂度低得多,因此实际应用中也看不到单 Reactor 多进程的模式。
另外,「单 Reactor」的模式还有个问题,因为一个 Reactor 对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。
多 Reactor 多进程 / 线程
要解决「单 Reactor」的问题,就是将「单 Reactor」实现成「多 Reactor」,这样就产生了第 多 Reactor 多进程 / 线程的方案。
- 主线程中的 MainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 对象中的 accept 获取连接,将新的连接分配给某个子线程;
- 子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 select 继续进行监听,并创建一个 Handler 用于处理连接的响应事件。
- 如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应。
- Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。
多 Reactor 多线程的方案虽然看起来复杂的,但是实际实现时比单 Reactor 多线程的方案要简单的多,原因如下:
主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理。 主线程和子线程的交互很简单,主线程只需要把新连接传给子线程,子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端。
大名鼎鼎的两个开源软件 Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案。
采用了「多 Reactor 多进程」方案的开源软件是 Nginx,不过方案与标准的多 Reactor 多进程有些差异。
具体差异表现在主进程中仅仅用来初始化 socket,并没有创建 mainReactor 来 accept 连接,而是由子进程的 Reactor 来 accept 连接,通过锁来控制一次只有一个子进程进行 accept(防止出现惊群现象),子进程 accept 新连接后就放到自己的 Reactor 进行处理,不会再分配给其他子进程。
Proactor
前面提到的 Reactor 是非阻塞同步网络模式,而 Proactor 是异步网络模式。
现在我们再来理解 Reactor 和 Proactor 的区别,就比较清晰了。
- Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。
- Proactor 是异步网络模式, 感知的是已完成的读写事件。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。
因此,Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」。这里的「事件」就是有新连接、有数据可读、有数据可写的这些 I/O 事件这里的「处理」包含从驱动读取到内核以及从内核读取到用户空间。
介绍一下 Proactor 模式的工作流程:
- Proactor Initiator 负责创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核;
- Asynchronous Operation Processor 负责处理注册请求,并处理 I/O 操作;
- Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor;
- Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理;
- Handler 完成业务处理;
可惜的是,在 Linux 下的异步 I/O 是不完善的, aio 系列函数是由 POSIX 定义的异步操作接口,不是真正的操作系统级别支持的,而是在用户空间模拟出来的异步,并且仅仅支持基于本地文件的 aio 异步操作,网络编程中的 socket 是不支持的,这也使得基于 Linux 的高性能网络程序都是使用 Reactor 方案。
操作系统IO读写演进
PIO
- 用户态:
- 用户进程 read()
- 上下文切换
- 内核态(全程CPU参与):
- 将 文件 从 磁盘 -> 磁盘控制器缓冲区 (IO操作)
- 将 文件 从 磁盘控制器 -> PageCache
- 将 文件 从 PageCache -> 用户缓冲区
- 上下文切换
- 用户态
- read()返回结果
全程包含两次上下文切换,CPU参与全程IO操作
PageCache大家可以理解为进一步的缓存,后面零拷贝技术会介绍。
DMA
- 用户态:
- 用户进程 read()
- 上下文切换
- 内核态:
- 将 文件 从 磁盘 -> 磁盘控制器缓冲区___DMA
- 将 文件 从 磁盘控制器 -> PageCache___DMA
- 将 文件 从 PageCache -> 用户缓冲区___CPU
- 上下文切换
- 用户态
- read()返回结果
全程包含两次上下文切换,将耗时的IO操作交给DMA。
零拷贝技术
在大多数文件传输的背景下,程序从硬盘读取数据后,并不需要做处理,只需要向外传输到网卡即可,因此,在这种情况下,从内核copy到用户缓冲区是不必要的操作。
因此出现零拷贝技术。
零拷贝技术实现的方式通常有 2 种:
- mmap + write
- sendfile
下面就谈一谈,它们是如何减少「上下文切换」和「数据拷贝」的次数。
mmap + write
在前面我们知道,read() 系统调用的过程中会把 内核缓冲区 的数据拷贝到用户的缓冲区里。
于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。
mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。
具体过程如下:
- 应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区;
- 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据;
- 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。
我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程(PageCache->用户缓存区)。
但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次
sendfile
在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile()
首先,它可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。
其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝(少了PageCache->用户缓冲区)
但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
于是,从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下:
- 首先通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
- 将缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;
所以,这个过程之中,只进行了 2 次数据拷贝(磁盘->磁盘缓冲区(dma),磁盘缓冲区->socket缓冲区(dma))
这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。
所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。
PageCache
回顾前面说道文件传输过程,其中第一步都是先需要先把磁盘文件数据拷贝「内核缓冲区」里,这个「内核缓冲区」实际上是磁盘高速缓存(PageCache)。
由于零拷贝使用了 PageCache 技术,可以使得零拷贝进一步提升了性能,我们接下来看看 PageCache 是如何做到这一点的。
读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是,我们会通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘。
但是,内存空间远比磁盘要小,内存注定只能拷贝磁盘里的一小部分数据。
那问题来了,选择哪些磁盘数据拷贝到内存呢?
我们都知道程序运行的时候,具有「局部性」,所以通常,刚被访问的数据在短时间内再次被访问的概率很高,于是我们可以用 PageCache 来缓存最近被访问的数据,当空间不足时淘汰最久未被访问的缓存。
所以,读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存 PageCache 中。
还有一点,读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「顺序」读取数据,但是旋转磁头这个物理动作是非常耗时的,为了降低它的影响,PageCache 使用了「预读功能」。
比如,假设 read 方法每次只会读 32 KB 的字节,虽然 read 刚开始只会读 0 ~ 32 KB 的字节,但内核会把其后面的 32~64 KB 也读取到 PageCache,这样后面读取 32~64 KB 的成本就很低,如果在 32~64 KB 淘汰出 PageCache 前,进程读取到它了,收益就非常大。
所以,PageCache 的优点主要是两个:
- 缓存最近被访问的数据;
- 预读功能;
这两个做法,将大大提高读写磁盘的性能。
但是,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能
这是因为如果你有很多 GB 级别文件需要传输,每当用户访问这些大文件的时候,内核就会把它们载入 PageCache 中,于是 PageCache 空间很快被这些大文件占满。
另外,由于文件太大,可能某些部分的文件数据被再次访问的概率比较低,这样就会带来 2 个问题:
- PageCache 由于长时间被大文件占据,其他「热点」的小文件可能就无法充分使用到 PageCache,于是这样磁盘读写的性能就会下降了;
- PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次;
所以,针对大文件的传输,不应该使用 PageCache,也就是说不应该使用零拷贝技术,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,这样在高并发的环境下,会带来严重的性能问题
大文件传输
具体过程:
- 当调用 read 方法时,会阻塞着,此时内核会向磁盘发起 I/O 请求,磁盘收到请求后,便会寻址,当磁盘数据准备好后,就会向内核发起 I/O 中断,告知内核磁盘数据已经准备好;
- 内核收到 I/O 中断后,就将数据从磁盘控制器缓冲区拷贝到 PageCache 里;
- 最后,内核再把 PageCache 中的数据拷贝到用户缓冲区,于是 read 调用就正常返回了。
对于阻塞的问题,可以用异步 I/O 来解决
它把读操作分为两部分:
- 前半部分,内核向磁盘发起读请求,但是可以不等待数据就位就可以返回,于是进程此时可以处理其他任务;
- 后半部分,当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据;
而且,我们可以发现,异步 I/O 并没有涉及到 PageCache,所以使用异步 I/O 就意味着要绕开 PageCache。
绕开 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 则叫缓存 I/O。通常,对于磁盘,异步 I/O 只支持直接 I/O。
前面也提到,大文件的传输不应该使用 PageCache,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache。
于是,在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术。
直接 I/O 应用场景常见的两种:
- 应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启;
- 传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致「热点」文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 I/O。
另外,由于直接 I/O 绕过了 PageCache,就无法享受内核的这两点的优化:
- 内核的 I/O 调度算法会缓存尽可能多的 I/O 请求在 PageCache 中,最后「合并」成一个更大的 I/O 请求再发给磁盘,这样做是为了减少磁盘的寻址操作;
- 内核也会「预读」后续的 I/O 请求放在 PageCache 中,一样是为了减少对磁盘的操作;
于是,传输大文件的时候,使用「异步 I/O + 直接 I/O」了,就可以无阻塞地读取文件了。
所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:
- 传输大文件的时候,使用「异步 I/O + 直接 I/O」;
- 传输小文件的时候,则使用「零拷贝技术」;