目录
一、DMA技术
1.1、为什么要有DMA技术
1.2、传统的文件传输
1.3、如何优化文件传输的性能
1.4、PageCache 有什么用
1.5、大文件的传输方式
二、I / O 多路复用
2.1、select/poll
2.2、epoll
三、高性能网络模式:Reactor 和 Proactor
3.1、Reactor
3.1.1、单 Reactor 单进程 / 线程
3.1.2、单 Reactor 多线程 / 进程
3.1.3、多 Reactor 多进程 / 线程模式
3.2、Proactor
在没有 DMA 技术之前,I/O 的过程是这样的:
DMA技术就是在进行 IO 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事物。
使用 DMA 控制器进行数据传输的过程究竟是怎么样的呢?
可以看到 CPU 不再参与【将数据从磁盘控制器缓冲区搬运到内核空间】的工作,这部分工作全程由 DMA 完成。
传统的 IO 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 IO 接口从磁盘读取或写入。如图:
首先此期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read()一次是 write() ,每次系统调用都得从用户态切换到内核态,等内核完成任务后,再从内核态切换到用户态。上下文切换的成本并不小,一次切换就需要耗时几十纳秒到几微秒,虽然时间看上去短,但是在高并发的场景下,这类时间容易被积累,从而影响系统性能。
在传输期间共发生了四次拷贝,其中两次是 DMA 拷贝,另外两次是通过 CPU 访问:
所以想要提高文件传输的性能,就需要减少【用户态与内核态的上下文切换】和【内存拷贝】的次数
这就是所谓的零拷贝技术,因为我们没有在内存的层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行运输的。
零拷贝技术的文件传输相比传统文件传输的方式,减少了两次上下文切换和数据拷贝次数,只需要两次上下文切换和数据拷贝次数,就可完成文件的传输,而且两次的数据拷贝过程都不需要通过 CPU ,两次都是由 DMA 来搬运,总体上看,零拷贝技术可以把文件传输的性能提高至少一倍以上。
回顾前面说到文件传输过程,其中第一步都是先需要把磁盘文件数据拷贝【内核缓冲区】里,这个【内核缓冲区】实际上是磁盘告诉缓存。
如果零拷贝使用了 PageCache 技术,可以使得零拷贝进一步提升性能。
我们都知道读写磁盘相比读写内存速度慢太多了,所以我们会通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘,但是内核空间远比磁盘要小,内存只能拷贝磁盘里的一小部分数据。程序在运行的时候具有【局部性】,所以通常刚被访问的数据在短时间内再次被访问的概率很高,于是我们可以用 PageCache 来缓存最近被访问的数据,当空间不足时淘汰最久未访问的缓存,所以读取磁盘数据的时候,优先在PageCache 找,如果数据存在则直接返回,没有则从磁盘中读取,然后缓存到 PageCache 中。并且 PageCache还用了预读功能进一步提高读写磁盘的效率。
如果是传输大文件呢?
例如在 GB 级别的文件,Pagecache 会不起作用,那就会白白浪费 DMA 多做出来的一次数据拷贝,造成性能下降,即使使用了 PageCache 的零拷贝也会损失性能。
由于文件太大,某些部分的文件数据被再次访问的概率比较低,这会带来两个问题:
所以针对大文件的传输,不应该使用 PageCache ,也就是说不应该使用零拷贝技术,因为可能由于 PageCache 被大文件占据,而导致【热点】小文件无法利用到 PageCache ,这样在高并发的环境下,会带来严重的性能问题。
将读操作分为两部分:
而且,异步 IO 并没有涉及到 PageCache ,所有使用异步 IO 就意味着要绕开 PageCache。
绕开 PageCache 的 IO 叫直接 IO ,使用 PageCache 的 IO 则叫缓存 IO ,通常,对于磁盘,异步 IO 只支持直接 IO。前面也提到大文件传输不能使用 PageCache,于是在高并发的场景下,针对大文件的传输的方式,应该使用【异步 IO + 直接 IO】来替代零拷贝技术。
直接 IO 的应用场景:
另外,由于直接 IO 绕过了 PageCache ,就无法享受到内核的两点优化:
在 TCP 连接的过程中,服务器的内核实际上未每个 Socket 维护了两个队列:
只使用一个进程来维护多个 socket ,这个就是 IO 多路复用
IO多路复用适用场景:
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 通过两个方面很好的解决了 select/poll 的问题
①、epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述符,把需要监控的 socket 通过 epoll_ctl() 函数加入到内核中的红黑树里,红黑树是一个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都需要传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入待检测的 socket ,减少内核与用户空间大量的数据拷贝和内存分配。
它在内核中维护了一个时间表,并提供了一个独立的系统调用函数 epoll_ctl 来控制住该内核表往该内核表中添加,删除,修改事件。这样,每次调用 epoll_wait() 函数时,都是直接从内核事件表中取得用户注册的事件,而无需反复从用户空间将这些注册事件读取到内核区中,节省了复制的系统开销。epoll_wait() 系统调用中的 events 指针参数仅用来返回就绪的事件,这使得应用程序索引就绪文件描述符的时间复杂度为O(1)。需要注意的是,epoll 与 poll 一样,也是将文件描述符和与其关联的事件是绑定在一起的,好处是程序接口变得简洁。
②、epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪队列中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
边缘触发和水平触发:
epoll 分别支持两种事件触发模式,分别是边缘触发(dege-triggered,ET)和水平触发(level-triggered,LT)。
如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知之后,没必要一次执行尽可能多的读写操作。
如果使用边缘触发模式,IO 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后尽可能的读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数里,程序就没办法往下执行,所以边缘触发模式一般和非阻塞 IO 搭配使用,程序会一直执行 IO 操作,直到系统调用返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK。
一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait() 的系统调用次数,系统调用也是有一定的开销的,毕竟也存在上下文的切换。
select/poll 只有水平触发模式,epoll 默认的是水平触发,但是可以减少 epoll_wait() 的系统调用次数,系统调用也是由一定的开销的,毕竟也存在上下文切换。
使用 IO 多路复用的时候,最好和非阻塞 IO 一起使用,因为多路复用 API 返回的时间并不一定是可读写的,如果使用阻塞 IO,那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 IO,以便应对极少数的特殊情况。
Reactor 模式是灵活多变的,可以应对不同的应用场景,灵活在于:
Reactor 的数量可以只有一个,也可以有多个
处理资源池可以是单个进程 / 线程,也可以是多个进程 / 线程,将此两种情况排列组合一下,理论上就有 4 种方案:
其中,【多 Reactor 单进程 / 线程】实现方案相比【单 Reactor 单进程 / 线程】方案,不仅复杂而且也没有性能优势,因此在实际中并没有应用。
直接上图:
进程里面有 Reactor、Acceptor、Handler 这三个对象:
简单介绍一下【单 Reactor 单进程】这个方案:
单 Reactor 单进程的方案会因为全部工作都在同一进程内完成,所以实现起来比较简单,不需要考虑进程间的通信,也不用担心多进程竞争。但是存在两个缺点:
所以,单 Reactor 单进程的方案不适用于计算机密集型的场景,只适用于业务处理非常快速的场景。
简单说一下这个方案:
上三个步骤与 单 Reactor 单线程的方案是一致的,接下来的就不同了:
单 Reactor 多线程的方案优势在于能够充分利用多核 CPU ,但是也带来了多线程竞争的资源的问题。
例如:子线程完成业务处理后,要把结果传递给主线程的 Handler 进行发送,这里涉及共享数据的竞争。要避免多线程由于竞争共享资源而导致数据错乱的问题,就需要在操作共享资源前加上互斥锁,以保证任意时间里只有一个线程在操作共享资源,待该线程操作完释放互斥锁后,其他线程才有机会操作共享数据。
方案如下:
主线程中的 MainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 对象中的 accept 获取连接,将新的连接分配给某个子线程
子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 select 继续进行监听,并创建一个 Handler 用于处理连接的响应事件
如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应
Handler 对象通过 read -> 业务处理 -> send 的流程完成完整的业务流程
多 Reactor 多线程的方案虽然看起来是复杂的,但是实际实现时比单 Reactor 多线程的方案要简单得多,原因如下:
主线程和子线程分工明确,主线程只负责接受新连接,子线程负责完成后续的业务处理
主线程和子线程的交互很简单,主线程只需要把新连接传给子线程,子线程无法返回数据,直接就可以在子线程将处理结果发送给客户端
Reactor 是非阻塞同步网络模式,而 Proactor 是异步网络模式
Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生(比如可都就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据
Proactor 是异步网络模式,感知的是已完成的读写事件。在发起异步读写请求的时候,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read / write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据
因此,Reactor 可以理解为【来了事件操作系统通知应用进程,让应用进程来处理】,而 Proactor 可以理解为【来了事件操作系统来处理,处理完再通知应用进程】。这里的【事件】就是有新连接、有数据可读、有数据可写的这些 IO 事件这里的【处理】包含从驱动读取到内核以及从内核读取到用户空间。
举个生动的:Reactor 模式就是快递员在楼下,给你打电话告诉你快递到你家小区了,你需要自己下楼来拿快递。而在 Proactor 模式下,快递员直接将快递送到你家门口,然后通知你。
无论是 Reactor 模式还是 Proactor 模式,都是一种基于【事件分发】的网络编程模式,区别在于 Reactor 模式是基于【待完成】的 IO 事件,而 Proactor 模式则是基于【已完成】的 IO 事件
示意图如下:
介绍一下 Proactor 模式的工作流程:
可惜的是,在 Linux 下的异步 IO 是不完善的,aio 系列函数是由 POSIX 定义的异步操作接口,不是真正的操作系统级别支持的,而是用户空间模拟出来的异步,并且仅仅支持基于本地文件的 aio 异步操作,网络编程中的 socket 是不支持的,这也使得基于 Linux 的高性能网络程序都是使用 Reactor 方案。
而 Windows 里实现了一套完整的支持 socket 的异步编程接口,这套接口就是 IOCP ,是由操作系统级别实现的异步 IO,真正意义上异步 IO,因此在 Windows 里实现高性能网络程序可以使用效率更高的 Proactor 方案。