阻塞(Blocking I/O)、非阻塞(Non-Blocking I/O)、IO多路复用(I/O Multiplexing)、 信号驱动 I/O(Signal Driven I/O)(不常用)和异步(Asynchronous I/O)。网络IO操作主要涉及到内核和进程,其主要分为两个过程:
- 内核等待数据可操作(可读或可写)——阻塞与非阻塞
- 内核与进程之间数据的拷贝——同步与异步
基础概念
I/O请求可以分为两个阶段,分别为调用阶段和执行阶段。
- 第一个阶段为I/O调用阶段,即用户进程向内核发起系统调用
- 第二个阶段为I/O执行阶段。此时,内核等待I/O请求处理完成返回。该阶段分为两个过程:首先等待数据就绪,并写入内核缓冲区;随后将内核缓冲区数据拷贝至用户态缓冲区
① 阻塞(Blocking)和非阻塞(Non-blocking)
阻塞和非阻塞发生在内核等待数据可操作(可读或可写)时,指做事时是否需要等待应答。
- 阻塞: 内核检查数据不可操作,则不立即返回
- 非阻塞: 内核检查数据不可操作,则立即返回
② 同步(Synchronous)和异步(Asynchronous)
同步和异步发生在内核与进程交互时,进程触发IO操作后是否需要等待或轮询查看结果。
- 同步: 触发IO操作 → 等待或轮询查看结果
- 异步: 触发IO操作 → 直接返回去做其它事,IO处理完后内核主动通知进程
阻塞I/O(BIO)
阻塞IO情况下,当用户调用read
后,用户线程会被阻塞,等内核数据准备好并且数据从内核缓冲区拷贝到用户态缓存区后read
才会返回。阻塞分两个阶段:
- 等待CPU把数据从磁盘读到内核缓冲区
- 等待CPU把数据从内核缓冲区拷贝到用户缓冲区
应用进程向内核发起 I/O 请求,发起调用的线程一直等待内核返回结果。一次完整的 I/O 请求称为BIO(Blocking IO,阻塞 I/O),所以 BIO 在实现异步操作时,只能使用多线程模型,一个请求对应一个线程。但是,线程的资源是有限且宝贵的,创建过多的线程会增加线程切换的开销。
非阻塞I/O(NIO)
非阻塞的 read
请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,询问数据是否准备好,当数据没有准备好时,内核立即返回EWOULDBLOCK错误。直到数据准备好后,内核将数据拷贝到应用程序缓冲区,read
请求才获取到结果。
注意:这里最后一次 read
调用获取数据的过程,是一个同步的过程,是需要等待的过程。这里的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程。
注意,这里最后一次 read 调用,获取数据的过程,是一个同步的过程,是需要等待的过程。这里的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程。
应用进程向内核发起 I/O 请求后不再会同步等待结果,而是会立即返回,通过轮询的方式获取请求结果。NIO 相比 BIO 虽然大幅提升了性能,但是轮询过程中大量的系统调用导致上下文切换开销很大。所以,单独使用非阻塞 I/O 时效率并不高,而且随着并发量的提升,非阻塞 I/O 会存在严重的性能浪费。
I/O多路复用
非阻塞情况下无可用数据时,应用程序每次轮询内核看数据是否准备好了也耗费CPU,能否不让它轮询,当内核缓冲区数据准备好了,以事件通知当机制告知应用进程数据准备好了呢?应用进程在没有收到数据准备好的事件通知信号时可以忙写其他的工作。此时IO多路复用就派上用场了。像select、poll、epoll 都是I/O多路复用的具体的实现。
多路复用实现了一个线程处理多个 I/O 句柄的操作。多路指的是多个数据通道,复用指的是使用一个或多个固定线程来处理每一个 Socket。select、poll、epoll 都是 I/O 多路复用的具体实现,线程一次 select 调用可以获取内核态中多个数据通道的数据状态。多路复用解决了同步阻塞 I/O 和同步非阻塞 I/O 的问题,是一种非常高效的 I/O 模型。
信号驱动I/O
无论 read
和 send
是 阻塞I/O
,还是 非阻塞I/O
都是同步调用。因为在 read
调用时,内核将数据从内核空间拷贝到用户空间的过程都是需要等待的,即这个过程是同步的,如果内核实现的拷贝效率不高,read
调用就会在这个同步过程中等待比较长的时间。
信号驱动 I/O 并不常用,它是一种半异步的 I/O 模型。在使用信号驱动 I/O 时,当数据准备就绪后,内核通过发送一个 SIGIO 信号通知应用进程,应用进程就可以开始读取数据了。
异步I/O
真正的异步 I/O 是内核数据准备好
和 数据从内核态拷贝到用户态
这两个过程都不用等待。
当我们发起 aio_read
(异步 I/O) 之后,就立即返回,内核自动将数据从内核空间拷贝到用户空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。过程如下图:
异步 I/O 最重要的一点是从内核缓冲区拷贝数据到用户态缓冲区的过程也是由系统异步完成,应用进程只需要在指定的数组中引用数据即可。异步 I/O 与信号驱动 I/O 这种半异步模式的主要区别:信号驱动 I/O 由内核通知何时可以开始一个 I/O 操作,而异步 I/O 由内核通知 I/O 操作何时已经完成。
Reactor模式
Reactor 模式
即 I/O 多路复用监听事件,收到事件后根据事件类型分配(Dispatch)给某个进程/线程。其主要由 Reactor
和 处理资源池
两个核心部分组成:
- Reactor:负责监听和分发事件。事件类型包含连接事件、读写事件
- 处理资源池:负责处理事件。如:read -> 业务逻辑 -> send
Reactor 模式是灵活多变的,可以应对不同的业务场景,灵活在于:
- Reactor 的数量可以只有一个,也可以有多个
- 处理资源池可以是单个进程/线程,也可以是多个进程/线程
将上面的两个因素排列组设一下,理论上就可以有 4 种方案选择:
- 单 Reactor 单进程/线程
- 单 Reactor 多进程/线程
- 多 Reactor 单进程/线程:相比
单Reactor单进程/线程
方案不仅复杂而且没有性能优势,因此可以忽略 - 多 Reactor 多进程/线程
单Reactor单进程/单线程
一般来说,C 语言实现的是单Reactor单进程
的方案,因为 C 语编写完的程序,运行后就是一个独立的进程,不需要在进程中再创建线程。而 Java 语言实现的是「单 Reactor 单线程」的方案,因为 Java 程序是跑在 Java 虚拟机这个进程上面的,虚拟机中有很多线程,我们写的 Java 程序只是其中的一个线程而已。以下是「单 Reactor单进程
」的方案示意图:
可以看到进程里有 Reactor
、Acceptor
、Handler
这三个对象:
Reactor
对象的作用是监听和分发事件Acceptor
对象的作用是获取连接Handler
对象的作用是处理业务
对象里的 select
、accept
、read
、send
是系统调用函数,dispatch
和 业务处理
是需要完成的操作,其中 dispatch
是分发事件操作。
工作流程
Reactor
对象通过select
(IO多路复用接口) 监听事件,收到事件后通过dispatch
进行分发,具体分发给Acceptor
对象还是Handler
对象,还要看收到的事件类型- 如果是连接建立的事件,则交由
Acceptor
对象进行处理,Acceptor
对象会通过accept
方法 获取连接,并创建一个Handler
对象来处理后续的响应事件 - 如果不是连接建立事件, 则交由当前连接对应的
Handler
对象来进行响应 Handler
对象通过read
-> 业务处理 ->send
的流程来完成完整的业务流程
优缺点
-
优点
- 因为全部工作都在同一个进程内完成,所以实现起来比较简单
- 不需要考虑进程间通信,也不用担心多进程竞争
-
缺点
- 因为只有一个进程,无法充分利用 多核
CPU
的性能 Handler
对象在业务处理时,整个进程是无法处理其它连接事件,如果业务处理耗时比较长,那么就造成响应的延迟
- 因为只有一个进程,无法充分利用 多核
使用场景
单Reactor单进程的方案不适用计算机密集型的场景
,只适用于业务处理非常快速的场景
。如:Redis 是由 C 语言实现的,它采用的正是「单Reactor单进程」的方案,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案。
单Reactor多线程/多进程
如果要克服单 Reactor 单线程/单进程
方案的缺点,那么就需要引入多线程/多进程,这样就产生了单Reactor多线程/多进程的方案。具体方案的示意图如下:
工作流程
Reactor
对象通过select
(IO 多路复用接口) 监听事件,收到事件后通过dispatch
进行分发,具体分发给Acceptor
对象还是Handler
对象,还要看收到的事件类型- 如果是连接建立的事件,则交由
Acceptor
对象进行处理,Acceptor
对象会通过accept
方法获取连接,并创建一个Handler
对象来处理后续的响应事件 - 如果不是连接建立事件, 则交由当前连接对应的
Handler
对象来进行响应 Handler
对象不再负责业务处理,只负责数据的接收和发送,Handler
对象通过read
读取到数据后,会将数据发给子线程里的Processor
对象进行业务处理- 子线程里的
Processor
对象就进行业务处理,处理完后,将结果发给主线程中的Handler
对象,接着由Handler
通过send
方法将响应结果发送给client
单Reator多线程
-
优势:能够充分利用多核
CPU
的能力 -
缺点:带来了多线程竞争资源问题(如需加互斥锁解决)
单Reactor多进程
- 缺点
- 需要考虑子进程和父进程的双向通信
- 进程间通信远比线程间通信复杂
另外,单Reactor
的模式还有个问题,因为一个 Reactor
对象承担所有事件的 监听
和 响应
,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能瓶颈。
多Reactor多进程/多线程
要解决 单Reactor
的问题,就是将 单Reactor
实现成 多Reactor
,这样就产生了 多Reactor多进程/线程 方案。其方案的示意图如下(以线程为例):
工作流程
- 主线程中的
MainReactor
对象通过select
监控连接建立事件,收到事件后通过Acceptor
对象中的accept
获取连接,将新的连接分配给某个子线程 - 子线程中的
SubReactor
对象将MainReactor
对象分配的连接加入select
继续进行监听,并创建一个Handler
用于处理连接的响应事件 - 如果有新的事件发生时,
SubReactor
对象会调用当前连接对应的Handler
对象来进行响应 Handler
对象通过read
-> 业务处理 ->send
的流程来完成完整的业务流程
方案优势
多Reactor多线程
的方案虽然看起来复杂的,但是实际实现时比 单Reactor多线程
的方案要简单的多,原因如下:
- 分工明确:主线程只负责接收新连接,子线程负责完成后续的业务处理
- 主线程和子线程的交互很简单:主线程只需要把新连接传给子线程,子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端
应用场景
-
多Reactor多线程
:开源软件Netty
、Memcache
-
多Reactor多进程
:开源软件Nginx
。不过 Nginx 方案与标准的多Reactor多进程有些差异,具体差异:- 主进程仅用来初始化 socket,并没有创建 mainReactor 来 accept 连接,而由子进程的 Reactor 来 accept 连接
- 通过锁来控制一次只有一个子进程进行 accept(防止出现惊群现象),子进程 accept 新连接后就放到自己的 Reactor 进行处理,不会再分配给其他子进程
Proactor模式
Reactor 和 Proactor 的区别
- Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件
- 在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用
read
方法来完成数据的读取,也就是要应用进程主动将socket
接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据 - 简单理解:来了事件(有新连接、有数据可读、有数据可写)操作系统通知应用进程,让应用进程来处理(从驱动读取到内核以及从内核读取到用户空间)
- 在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用
- Proactor 是异步网络模式, 感知的是已完成的读写事件
- 在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据
- 简单理解:来了事件(有新连接、有数据可读、有数据可写)操作系统来处理(从驱动读取到内核,从内核读取到用户空间),处理完再通知应用进程
无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于 Reactor 模式是基于「待完成」的 I/O 事件,而 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:在
Linux
下的异步I/O
是不完善的,aio
系列函数是由POSIX
定义的异步操作接口,不是真正的操作系统级别支持的,而是在用户空间模拟出来的异步。并且仅仅支持基于本地文件的aio
异步操作,网络编程中的socket
是不支持的,这也使得基于Linux
的高性能网络程序都是使用Reactor
方案 - Windows :在
Windows
下实现了一套完整的支持socket
的异步编程接口,这套接口就是IOCP
,是由操作系统级别实现的异步I/O
,真正意义上异步I/O
,因此在Windows
里实现高性能网络程序可以使用效率更高的Proactor
方案。
select/poll/epoll
注意:遍历相当于查看所有的位置,回调相当于查看对应的位置。
select
select
本质上是通过设置或者检查存放 fd
标志位的数据结构来进行下一步处理。
缺点
- 单个进程可监视的
fd
数量被限制。能监听端口的数量有限,数值存在文件:cat /proc/sys/fs/file-max
- 需要维护一个用来存放大量fd的数据结构。这样会使得用户空间和内核空间在传递该结构时复制开销大
- 对fd进行扫描时是线性扫描。
fd
剧增后,IO
效率较低,因为每次调用都对fd
进行线性扫描遍历,所以随着fd
的增加会造成遍历速度慢的性能问题 select()
函数的超时参数在返回时也是未定义的。考虑到可移植性,每次在超时之后在下一次进入到select
之前都需要重新设置超时参数
优点
select()
的可移植性更好。在某些Unix
系统上不支持poll()
select()
对于超时值提供了更好的精度:微秒。而poll
是毫秒
poll
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
缺点
- 大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义
- 与select一样,poll返回后,需要轮询pollfd来获取就绪的描述符
优点
- poll() 不要求开发者计算最大文件描述符加一的大小
- poll() 在应付大数目的文件描述符的时候速度更快,相比于select
- 它没有最大连接数的限制,原因是它是基于链表来存储的
epoll
epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就需态,并且只会通知一次。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
优点
-
支持一个进程打开大数目的socket描述符(FD)
select最不能忍受的是一个进程所打开的FD是有一定限制的,由FD_SETSIZE设置,默认值是1024/2048。对于那些需要支持的上万连接数目的IM服务器来说显然太少了。这时候你一是可以选择修改这个宏然后重新编译内核。不过 epoll则没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。
-
IO效率不随FD数目增加而线性下降
传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是"活跃"的,但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll不存在这个问题,它只会对"活跃"的socket进行操作—这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有"活跃"的socket才会主动的去调用 callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个"伪"AIO,因为这时候推动力在Linux内核。
-
使用mmap加速内核与用户空间的消息传递
这点实际上涉及到epoll的具体实现了。无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就很重要,在这点上,epoll是通过内核与用户空间mmap同一块内存实现的。