概述
目前常见的面向IO操作编程模型有以下几种:
- Blocking IO:阻塞IO
- Non-Blocking IO:非阻塞IO
- IO Multiplexing:IO多路复用
- Asynchronous IO:异步IO
IO多路复用技术(又称:事件驱动IO),是为了解决传统同步阻塞IO模式下,服务端的每个线程(进程)只能给一个Client提供服务的问题,避免服务器创建大量线程(占用大量内存、线程切换开销)的问题而设计的。本文对各种网络IO编程模型进行简要的介绍。
本文主要讨论的场景是Linux下的网络IO,主要参考了Richard Stevens的《UNIX® Network Programming Volume 1, Third Edition: The Sockets Networking API》和网络上的相关文章。
IO模型分类
先来了解下IO过程相关的同步、异步,阻塞、非阻塞等概念。在IO操作过程中涉及的对象包括:
- 调用IO的用户程序(线程或进程)
- 处理IO的内核程序(Kernel)
IO操作过程中包括两个阶段(以Read操作为例),分别是:
- 数据准备阶段:Kernel等待数据就绪
- 数据拷贝阶段:数据就绪后把数据从Kernel空间拷贝到用户程序空间
关于同/异步,非阻塞/阻塞,就是围绕两个对象和两个阶段展开的。
同步异步
同步、异步主要是指用户程序和 内核 的交互模式。
同步:用户程序触发IO操作后,通过一直等待或轮询的方式去检查内核IO操作是否就绪,直到IO就绪。
异步:用户程序触发IO操作后,干其他事情去了,当内核IO操作就绪后,内核把数据写入用户程序指定的Buffer。
从代码实现表现来看,IO操作后,同步模式下,用户需要自己不断去轮询内核获取结果;而异步模式下内核会回写数据到用户程序Buffer并通知用户程序。同步异步的关键:IO设备状态需要自己轮询,还是内核主动通知。
阻塞非阻塞
阻塞非阻塞,是程序执行IO操作的系统调用时,根据IO设备操作的就绪状态采取不同处理方式。
阻塞:当程序试图进行读写IO操作时,如果暂时没有可读数据或者不可写,程序就一直处于等待状态,直到可读或可写为止。
非阻塞:当程序试图进行读写IO操作时,如果无可读数据或不可写,程序马上返回,而不会等待。
从代码实现表现来看,IO操作后,阻塞模式下,方法挂起不返回,直到IO设备就绪操作成功,方法才返回;而非阻塞模式,调用方法不等待IO设备就绪就直接返回。阻塞非阻塞的关键:方法调用后是否立即返回。
阻塞IO(Blocking IO)
Linux中默认的Socket IO都是Blocking的,读数据的流程如下:
当用户程序调用recvfrom系统调用时,Kernel开始IO的第一个阶段:数据准备。对于网络IO,此时数据很可能还在网络上传送,因此Kernel需要等待数据传输完成。
此时,用户程序会阻塞,等待Kernel返回。当Kernel接收到数据后,需要把数据拷贝的用户内存空间。然后Kernel(recvfrom)才返回,用户程序才能继续往下执行。
所以,Blocking IO在的数据准备和数据拷贝两个阶段,用户程序都是Blocking的。Socket编程提供的listen/send/recv等接口,都是Blocking模式的。
Blocking模式的主要优点时程序结构/流程清晰;缺点时每个线程同时只能给一个客户端提供服务,只能采用多线程(多进程)的方式来提供并发服务。采用多线程的方式有以下缺点:
- 每个线程(进程)占用一定的内存;
- 线程的创建删除的资源开销;
- 多线程调度会带来额外CPU开销;
对于多线程的问题,可以通过线程池来避免线程重复创建回收的开销,通过维护一定数量的线程,防止服务器资源耗尽等问题,通过请求排队处理,保持适当的并发服务等。但是对于成千上万的请求时,线程池模式还是会存在瓶颈。
非阻塞IO(Non-Blocking IO)
通过给Socket设置setblocking(False)把Socket设置为非阻塞模式,非阻塞模式下,通过recvform系统调用读取数据的流程如下:
在Non-Blocking模式下,调用recvfrom时,如果Kernel还没有准备好数据,不会Block,而是直接返回Error。用户程序通过判断返回值,可以确定数据是否Ready。如果Kernel数据未准备好,用户程序可以再发起调用,或者趁这段时间做些其他操作,然后再回来调用,直到方法返回成功。
一旦用户再次调用recvfrom,并且Kernel数据已经Ready,就会把数据拷贝到用户内存空间,并返回。所以在Non-Blocking模式,用户程序需要通过不断调用Kernel(即轮询)来查询数据是否Ready,而且数据拷贝阶段还是Block的。
由于需要轮询,虽然用户程序在这段时间可以做其他操作,但是通常还是只是不断的执行轮询操作,轮询会大量占用CPU时间,而且期间去执行其他操作,会让程序执行逻辑混乱,所以被阻塞模式一般不被推荐使用,而是采用Kernel自带轮询的IO多路复用技术。
IO多路复用(IO Multiplexing)
IO多路复用,又叫事件驱动IO(Event Driver IO),底层使用了Kernel提供的select/poll/epoll(为什么有3种技术,也是从老到新一步步优化的过程)等系统调用。此技术的主要方法是在单个线程中,由select函数负责对多个Socket轮询,一旦其中有Socket数据Ready了,就会通知用户程序进行处理。
当调用了select后,用户程序会Block,同时Kernel监控此select负责的所有Socket,当有一个或多个Socket数据Ready事件发生时(事件驱动IO),select就会返回。用户程序再逐个调用Ready的Socket的recvfrom函数,把数据从Kernel拷贝到用户内存。
从流程上看,IO多路复用和Blocking IO区别不大,都会Block用户程序。IO多路复用的主要优点是一个线程可以Handle多个Socket。但如果IO数据接收后的处理任务是计算密集型业务时,由一个线程处理多个任务,性能上会无法胜任,客户端出现卡顿和延迟。
select模型的另外一个问题是虽然用户程序不在需要轮询了,但是Kernel还是需要去轮询Socket,消耗大量CPU,系统设置Socket的上限是1024。许多系统提供了更高效的接口,如Linux的epoll,BSD的kqueue等,但这些接口的主要问题是不能跨平台。
select | poll | epoll | |
---|---|---|---|
实现年代 | 1984 | 1997 | 2002 |
底层数据结构 | 数组 | 链表 | 哈希表 |
连接数 | 1024 | 无限制 | 无限制,OS支持最大FD数 |
事件检测方法 | 遍历FD(File Descriptor) | 遍历FD | IO就绪自动callback,检查就绪链表即可 |
时间复杂度 | O(n) | O(n) | O(1) |
FD处理 | 每次调用,需要把FD从用户内存拷贝到内核 | 每次调用,FD拷贝 | epoll_ctl一次拷贝FD,无需重复拷贝 |
函数 | select | poll | epoll_create创建句柄,epoll_ctl注册IO就绪监听事件,epoll_wait等待事件就绪 |
IO多路复用模式,对于单个Socket,是非阻塞的,以便在一个线程里同时处理多个Socket,但是select在处理轮询IO设备状态时,是阻塞的。
异步IO(Asynchronous I/O)
异步IO是Linux2.6内核引入的新功能,使用的不多,流程如下:
用户程序发起aio_read调用后,程序立即返回。从Kernel的角度,流程如下:
- 当Kernel它收到aio_read之后,会立刻返回,不会对用户程序产生Block
- Kernel等待数据准备完成,把数据拷贝到用户内存空间
- Kernel给用户程序发送一个signal,告诉它read操作以完成
说实话,异步IO功能我也没用过,而且Linux底层还是使用epoll实现的,性能上并没有优势。Java著名NIO框架Netty就使用了IO多路复用,而非异步IO。
IO模型比较
直接上大神的图。
从上图可见,阻塞式IO和IO多路复用,特点还行比较明细的,本质上都是同步IO,就不展开了。
Non-Blocking虽然在数据准备阶段不阻塞了,但是用户程序还是需要轮询Check数据状态;而异步IO,发完请求后,用户程序就返回了,数据准备和拷贝,都由Kernel来完成。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。