summary of server side network programming model.
本文按照我自己的编程经验和理解, 迅速的过一遍 5种网络IO模型
, 具体的 网络编程经验(细节)
,
可以参考我的 github 库 network_life.
本文要求读者拥有linux下基本的socket编程经验, 文章写给懂的人看.
引子
引入网络IO的最初原因, 其实是硬件问题想从软件上解决; 毕竟让计算速度非常快的cpu去等硬盘或者速度缓慢的网络IO, 资源浪费. 之后改进网络IO模型就是为了提升效率, 应对更多的客户端请求, 也就是常说的”高并发”, 当然并发只是一种(提高服务器应答能力的)手段.
下面按照不断改进的过程, 用我自己的话, 叙述一下网络IO模型的发展. 大致顺序是:
1 | 阻塞型(包括多进程和多线程, 池模型) --> 非阻塞型(带有简单异步机制) |
正文
阻塞型
简要说明: 小规模的请求量(一种古代的原始模型)
得益于socket api的良好设计, accept能返回一个响应socket fd, 去处理客户端的请求.
但是啊, 在没有完成请求之前, 就无法处理其他的请求, 这个就比较尴尬.(也就是说原始api模型其实是一种阻塞型应答模型)
由此可以引入了多线程或者多进程(取决你计算量或者占用资源的时间), 如果处理请求占用的实践比较长, 那么单独开一个进程吧.
这个方案虽然解决了多个请求以及部分性能问题, 但是请求数量一旦超过某个范围, 服务器还是受不了.
为此又引入了 池
, 包括“线程池”, “连接池”. 该机制一定程度上缓解了IO接口的资源占用问题.
但是还是治标不治本, 为啥呢?
因为啊是个池子就一定有大小, 有上限; 池子只是把资源维持在一个稳定水平, 减少频繁的创建&销毁或者最大限度的重用以前的连接数量, 但是一旦请求规模超过池子的能力, 也是不行的.
(或者你不断扩大池子, 但这本身也是需要开销的; 扩大了浪费, 少了还需要频繁扩大)
值得注意的是, 这里所说的阻塞包括:
1. 没有收到请求时的阻塞(没有数据报准备好), 同步等待;
2. 内核数据没有准备好时, 阻塞等待;
也就是说(服务端)用户进程 等待请求数据
以及 将数据从内核空间拷贝到用户空间
, 两方面都在等待.
补充说明: recvfrom等系统调用默认都是阻塞的.
下面的简单改进就是 非阻塞
, 让io系统调用检查, 检查, 再检查.
非阻塞
简单一句话: fcntl将事件句柄设置为异步, 即io函数异步调用.
recvfrom等网络io函数不断的调用, 如果数据没有准备好就马上返回, 不必等待用户请求或者数据达到或者数据准备(socket io).
单线程内询问一个线程内的连接请求(也就是说还是要结合多线程技术), 或者多进程. (该模型中多线程和多进程没有本质区别)
如果没有请求一直不断调用询问, 收到请求后开始阻塞(同步)执行拷贝动作(数据从内核空间到用户空间).
(也就是说, 仅仅处理“没有请求”时需要等待的时间被省下来了, 因为立刻返回; 但是有了请求, 但数据没有准备好(拷贝到用户态进程空间才叫准备好), 这部分等待还是没有省下来)
并且由于该模型通常还要结合多线程或者多进程的并发模式, 频繁调用socket io函数, 还引入了cpu占用率高的问题.
事件驱动IO
核心: 原来需要用socket io或者网络io系统调用检查数据是否到达的这事儿, 专门交给另外一个系统调用(函数吧), 它集中处理会高效一点儿呢.
具体说明如下:
(下面的select类系统调用, 还可能是 pselect或者epoll等, 根据不同posix系统的实现, 它们原理类似, 效率不一样)
之后引入了select 探测所有客户端的connect请求和响应(当然还是要先注册fd到需要检测的数组中啊), 整个模型在没有请求或者数据的时候, select不断的轮询(服务端阻塞在select函数/系统调用), 一旦有了请求, 就开始用recv或者send进行响应.
这就是事件驱动IO, 又称IO复用, 多路复用.
那么为啥会比上面那个异步机制的非阻塞IO函数调用高效呢?
原来的那个模型需要开多个线程/进程, 然后在每个进程或者线程里再用网络io函数/系统调用进行探测检查, 这开销就非常大了. 而select不用切换线程, 应对所有线程的检查.
缺点:
1. select需要耗费大量的时间去轮询相关fd; (轮询也要cpu时间资源的)
2. select将事件的监测和响应放在了一起
关于 1
的说明:
各个操作系统又提供了更加高效的接口: linux下面的epoll, bsd的kqueue, solaris提供的/dev/poll.如果想要实现高效的服务器程序, 建议用epoll, 但是epoll在Posix系统的实现差异很大, 所以要实现跨平台的服务器会比较困难
关于 2
的说明:
如果上一个事件的执行体迟迟没有执行完毕, 会大大降低对下一事件处理的及时性(libevent, libev都会根据系统的特点选择合适的接口进行探测, 并且加入了异步响应—探测和响应分离)(到这里也就发现, 其实事件驱动IO模型其实和操作系统内核有关, 至少是内核探测函数有关)所以啊, 在事件驱动IO模型之上, 加入异步IO模型, 分离事件与响应岂不更美?可以直接参考libev或者你使用aio_read, aio_write也行—linux内核2.6提供
但是你可以看到主动的轮询(监测)还是少不了, 轮询阻塞在select这边儿; 主动去调用相关读写调用(把数据从内核往用户空间拷贝)也少不了, 此时用户进程阻塞.
下面类对比一下select, poll, epoll: (一般现在直接使用epoll)
select:是最初解决IO阻塞问题的方法。用结构体fd_set来告诉内核监听多个文件描述符,该结构体被称为描述符集。由数组来维持哪些描述符被置位了。对结构体的操作封装在三个宏定义中。通过轮寻来查找是否有描述符要被处理,如果没有则返回.
存在的问题:
- 内置数组的形式使得select的最大文件数受限与FD_SIZE;
- 每次调用select前都要重新初始化描述符集(以及来回拷贝),将fd从用户态拷贝到内核态,每次调用select后,都需要将fd从内核态拷贝到用户态;
- 轮寻排查当文件描述符个数很多时,效率很低;
poll:通过一个可变长度的数组解决了select文件描述符受限的问题。数组中元素是结构体,该结构体保存描述符的信息,每增加一个文件描述符就向数组中加入一个结构体,结构体只需要拷贝一次到内核态。poll解决了select重复初始化的问题。轮寻排查的问题未解决。
epoll:轮寻排查所有文件描述符的效率不高,使服务器并发能力受限。因此,epoll采用只返回状态发生变化的文件描述符,便解决了轮寻的瓶颈。(文件描述符受限, 来回拷贝等问题也解决了)
缺点总结: 由于引入了除recvfrom之外的其他系统调用, 在请求数量少时真不见得比多线程同步模型高效; 但是请求数量多时, 该模型把并发数量提上去了.
异步IO模型
非阻塞IO或者说事件IO还是要求用户进程主动检查, 并且当内核数据准备完成后, 也是它去主动调用readvfrom把数据拷贝到用户进程中. 整个事件用户进程都在积极参与, 只是没有数据的时候, 直接返回了, 减少了等待的事件.
而异步IO则完全不同, 在这里就好像用户进程注册之后就不管了, 也不轮询了; 准备数据(收到请求)和拷贝数据到用户态进程的事儿直接交给内核了, 内核好了再调用相关回调,或者发信号通知.
就好像注册之后就不管了, 你好了通知我一下; 这样对于单个请求不会慢的同时, 并发数量就可以上去了.
该模型的关键在哪里关键在哪?---在于异步IO接口。
如果调用阻塞IO或者说同步IO, 那么在IO操作时会一直等待用户进程相关调用完成; 如果调用非阻塞IO, 如果没有请求虽然会立即返回, 但是真正读写的时候还是会阻塞. 多路复用不断监测所有IO请求, 没有请求时不断监测, 这本身就是一种用户进程的阻塞, 并且真正响应的时候还是会同步阻塞,直到完成后处理下一个请求; 而异步IO则是IO操作后直接返回(例如aio_read), 进程忙别的(而不是自己再去主动监测IO状态); 内核忙完了通知它就可以了.
根据通知方式的不同, 又可以把异步IO复用模型分为: 信号通知型, 以及注册回调型.
区别:
1. 回调时全程没有阻塞; (它通知用户进程IO操作都完成了, 全自动的, 根本不要用户进程担心)--终极懒
2. 信号通知时在数据拷贝时阻塞; 因为使用信号通知的情况不是全自动的, 它通知用户进程可以启动IO操作了
IO复用思想
(当然也还是没有图, 笑)
先构造一张或多张包含所有需要等待的描述符的表,然后调用一个函数,它要到这些描述符中的一个或多个已准备好进行I/O时才返回。在返回时,它告诉进程哪一个描述符已准备好进行I/O.
也就是: 如果一个或多个I/O条件满足(如输入已准备好, 或者描述字可以处理更多的输出)时, 我们(用户进程)就被通知. 这个能力就称为I/O复用.
IO复用技术本身是单线程的, 为了解决同事处理更多的请求这个场景下的问题, 一般 IO复用
比 其他并发技术
能处理更高的并发量. (当然最终还是受限于物理环境)
尾巴
下面问题本文没有涉及:
- select, pselect, poll, epoll (当然这要求你非常熟悉socket api)
- 网络库: libevent, libev, asio等.
- 其他模型: Reactor, Proactor等(在原来的基础上加上队列或者回调通知)
- 很多RPC库, 自己就实现了高性能IO, 例如 Thrift .
可以参见我的 network_life, 此外, 前辈的这篇 高性能IO模型浅析 比我写的好, 供你参考.
具体的IO模型, 其实可以在linux结合api进行实验. 网络编程是最有意思的, 有时间专门说说事件驱动IO (即select, pselect, poll, epoll等), 高性能IO.