基本的I/O分为阻塞式和非阻塞式,而在非阻塞的情况下,又可进一步分为同步和异步,归类下来,分为三种:
l 阻塞式:以read()为例,它是停等式的读,在fd不可读时,会阻塞住线程继续等下去。一般高并发服务器比较少用。
l 同步非阻塞:它解决了阻塞式中停等的问题,在fd不可读时,会立即返回失败。一般结合一个统一的I/O复用机制(比如select、epoll)来对多fd做一个统一的等,而不消耗每一个线程的时间。
l 异步非阻塞:同步非阻塞解决了停等的问题,但是在实际读这一步,需要同步去读取数据,如果读的时间过长,同样会消耗工作线程大量时间。因此又出现了异步非阻塞的I/O方式。具体来说,异步需要在事件发生前向操作系统注册fd和回调函数,然后映射一段缓冲到操作系统,等待被回调即可。Linux采用了aio来支持这种读写,windows采用的是IOCP完成端口的概念。
我们不过多的讨论阻塞式I/O。同步非阻塞和异步非阻塞可以进一步抽象出两个常用的I/O事件模型,Reactor和Proactor:
l Reactor:即反应式事件模型。反应式事件模型的读操作步骤如下:
1. 注册读事件
2. 事件分离器等待可读事件(分离器可以理解为select/epoll)
3. 事件到来,激活分离器,分离器调用事件处理器(事件的handle对象或者函数)
4. 事件处理器完成实际的读操作,处理读到的数据
抽象成生活中的例子——送快递:
1. 你的朋友发快递给你,写上你的地址(注册事件)
2. 快递公司分派快件
3. 快件到来,快递大叔通知你下楼拿快件(可读)
4. 你下楼拿快件然后上楼(读操作),拆快件(事件处理)
l Proactor:即前摄式事件模型。它的读操作步骤如下:
1. 事件处理器发起异步操作,提供缓冲区和回调对象/函数
2. 事件分离器等待读操作完成事件(不一定需要epoll/select)
3. 分离器调用事件处理器
4. 处理已读到的数据
继续是送快递那个例子:
1. 你打电话给快递公司,说快递到了给你送到楼上
2. 快递公司分派快件
3. 快件到了,快递大叔非常友好的把快件给你送上了楼
4. 你直接拿到就可以拆了
从上面的送快递例子我们可以很简单的看出,Reactor仅需要你简单的等快递,快递到了,到楼下拿一下,整个过程,你只需要干一件事情,就是下楼收快递,但下楼比较耗时。Proactor需要我们有两个动作:打电话和收快递,但不需要消耗时间的下楼动作。
这里收快件的我们就相当于工作线程,Reactor模型直观易于理解,但需要处理一次I/O。Proactor增加了编程的复杂度,但给工作线程带来了更高的效率:Proactor可以在系统态将读写优化,利用I/O并行能力,提供一个高性能单线程模型。在windows上,由于没有epoll这样的机制,因此提供了IOCP来支持高并发, 由于操作系统做了较好的优化,windows较常采用Proactor的模型利用完成端口来实现服务器。在linux上,在2.6内核出现了aio接口,但aio实际效果并不理想,它的出现,主要是解决poll性能不佳的问题,但实际上经过测试,epoll的性能高于poll+aio,并且aio不能处理accept,因此linux主要还是以Reactor模型为主。
在不使用操作系统提供的异步I/O接口的情况下,还可以使用Reactor来模拟Proactor,差别是:使用异步接口可以利用系统提供的读写并行能力,而在模拟的情况下,这需要在用户态实现。具体的做法只需要这样:
1. 注册读事件(同时再提供一段缓冲区)
2. 事件分离器等待可读事件
3. 事件到来,激活分离器,分离器(立即读数据,写缓冲区)调用事件处理器
4. 事件处理器处理数据,删除事件(需要再用异步接口注册)
Boost::asio在linux上就采用了这样的方式:以epoll实现的Reactor来模拟Proactor,并且另外开了一个线程来完成读写调度。
实际的应用中,两者的性能差异并不是太大。因此大家不必纠结于模型的选择,更多的放在对模型的理解上或许更为重要。后面的讨论中,除非特殊的需求,我们将不明确的指明采用哪一种模型,都以分离器相称。