从实践中理解IO模型
前言
IO顾名思义即Input&Output。打开一个文件进行读写是一次IO,打开一个网络连接进行数据传输也是一次IO,IO模型即是操作系统对输入输出的处理模型。当进行IO操作的时候,进程通常会被“中断”挂起,等待远端或者外围设备处理完毕,返回结果,进程才能继续往下跑。这种场景称之为“阻塞”。
关于IO有不少衍生的概念,如异步、非阻塞、多路复用、事件驱动…...它们是什么意思?接下来结合代码,分析IO模型的演化和不同方案的优缺点。
需求
做一个服务端程序,它的功能很简单,客户端发什么过来就返回什么,即回显服务器。
阻塞IO
问题来了,第二个客户端无法从服务器获取回显信息。只能把第一个客户端Ctrl+C结束进程,第二个客户端才能正常通信。也就是说,服务端程序不能同时支持两个客户端连接。这显然是不能接受的。
socket_accept()和socket_read()都是IO操作,且是阻塞IO。socket_accept()等待客户端连接,socket_read()等待客户端发送数据。如果没有新的连接或者是数据,进程就一直被阻塞。
多进程/多线程
多进程多线程的思路差不多,下面只展示用多进程实现,有兴趣的同学可以用多线程也实现一版。
代码也不复杂,多写几行:
父进程负责处理连接客户端,子进程负责从每个客户端里读数据,所以各个客户端之间不会相互影响。同时开启两个客户端,都能获取回显数据。
现在思考一下,用多进程/多线程处理多个客户端有什么问题?
进程/线程的创建是有开销成本的,如果每个客户端连接进来都创建一个进程/线程,当并发量高的时候系统开销很可观。而且在多进程/多线程之间切换也是有相当的成本。能否用尽量少的进程/线程来处理大批量的客户端?答案是肯定的。
回顾最初的阻塞IO,之所以不能处理多个客户端,是因为进程被阻塞了。如果socket_accept()和socket_read()不会阻塞进程,那么理论上就可以处理多个客户端了。
非阻塞IO
阻塞IO会让进程挂起,等待IO完成。非阻塞型IO不会让进程挂起,函数立刻返回。如果客户端已连接或者数据已准备好,返回SUCCESS,没准备好就返回FAIL。所以程序需要循环地“询问”,直到返回SUCCESS。
使用socket_set_nonblock()可以把socket设置成非阻塞。 现在来改写一下阻塞IO的程序:
分析代码,所有socket都设置成非阻塞,因此调用socket_accept()和socket_read()都会立刻返回。
第二个循环,把$client数组里面的所有连接socket遍历,找到可以读数据的socket,再回显。
执行效果如期,能同时接受多个客户端。但这样的代码有两个致命的问题:
2.第二个循环,把所有的socket都遍历一遍,包括没有可读数据的socket,效率非常低。
综上两个原因,这种IO模式基本不会被采用。那么能否改善?可以的,继续往下看。
IO多路复用
分析代码,关键点在socket_select()函数,参数$read, $write, $except都是socket数组。调用socket_select()会发生阻塞。如果$read里有可读的socket或$write有可写的socket或$except里有异常的socket,进程恢复,继续往下执行。
这样代码就很好理解了,socket_select()告诉我们有哪些socket是可读/可写/异常,接下来我们把这些socket遍历处理即可。不需要像非阻塞IO,需要把所有socket都遍历,因此高并发下性能好很多。同时,由于socket_select()是阻塞的,不会像非阻塞IO一样把CPU“累趴”。这就是多路复用,可以简单理解为一个“代理”,它告诉我们哪些socket已经准备好,不用程序挨个去问。
要实现多路复用,除了可以用select,还可以用poll和epoll。后面两者是select的改进版,如select只能监听1024个socket,poll和epoll就没限制了。还有一些内核的机制优化,有兴趣的同学可以看文档,这里不多作展开。
多路复用貌似够好了,还有没有改进空间?有的。
1.在Linux系统的多路复用有select, poll, poll,某些Unix系统有kqueue,Windows系统自己的方式,用select/epoll写的代码不一定能移植到别的平台。 上面只是展示了select程序代码,还比较简单。但如果用epoll实现会稍微复杂一点。
2.我们需要的只是多路复用,但要熟悉每个函数太麻烦了,能否都把他们封装起来?
libevent
相信很多同学都听过libevent,因为安装很多软件的时候都会提醒先安装libevent,可见其普遍性。
libevent做了什么?
PHP安装libevent扩展或event扩展就可以使用libevent,下面我们用libevent来改写程序。
非常浓厚的“事件”味道,因为上述代码写了两个回调函数,典型的事件处理方式。代码多了两个类:
2.Event: 表示每个socket的读写事件。
核心逻辑并不复杂,先把socket的读写事件生成一个Event对象,同时指定一个回调函数,用于事件发生后执行业务逻辑。然后把Event对象注册进事件管理器EventBase对象,最后进行loop()监听。
运行脚本,效果如期,同样能处理多个客户端。上述代码用了哪种多路复用方式?可以使用EventBase::getMethod()来查看。
我的操作系统是OSX,libevent优先选择kqueue。当然,可以指定使用epoll或者select。方法请查阅EventConfig类,这里不赘述。
Nginx性能很强
了解不同的IO模型后,回过头看看以高性能著称的Nginx,为什么Nginx号称能轻松应对C10K?文档给出的解释是Nginx在架构上实现了:
· Asynchronous
· Non-blocking
user www www;
worker_processes 8;
...
events
{
use epoll;
worker_connections 65535;
}
...
因为服务器CPU有8核,所以Nginx服务开了8个worker进程,这样可以充分利用CPU资源。每个worker进程使用epoll,最大连接数数65535。Nginx很核心的一点是使用了多路复用epoll,虽然没有用libevent,但Nginx自己也实现了一套事件驱动的机制。因此Nginx在处理IO上是性能很强的,尤其是做代理服务器和静态文件服务器。
总结
排版 \ chuanrui
如下技术文章,你可能也感兴趣: