vlambda博客
学习文章列表

从实践中理解IO模型


前言


IO顾名思义即Input&Output。打开一个文件进行读写是一次IO,打开一个网络连接进行数据传输也是一次IO,IO模型即是操作系统对输入输出的处理模型。当进行IO操作的时候,进程通常会被“中断”挂起,等待远端或者外围设备处理完毕,返回结果,进程才能继续往下跑。这种场景称之为“阻塞”。

关于IO有不少衍生的概念,如异步、非阻塞、多路复用、事件驱动…...它们是什么意思?接下来结合代码,分析IO模型的演化和不同方案的优缺点。





需求

做一个服务端程序,它的功能很简单,客户端发什么过来就返回什么,即回显服务器。



阻塞IO

使用Socket就可以完成需求:


指定端口,运行php server.php 1234,服务器开启了。

从实践中理解IO模型


接下来打开另外一个终端窗口作为客户端,这里我不再写代码了,直接用nc命令。  

从实践中理解IO模型

如上,服务器功能正常。

这时候再打开一个窗口,作为第二个客户端。

从实践中理解IO模型

问题来了,第二个客户端无法从服务器获取回显信息。只能把第一个客户端Ctrl+C结束进程,第二个客户端才能正常通信。也就是说,服务端程序不能同时支持两个客户端连接。这显然是不能接受的。

分析代码,注意两个函数:

从实践中理解IO模型

socket_accept()和socket_read()都是IO操作,且是阻塞IO。socket_accept()等待客户端连接,socket_read()等待客户端发送数据。如果没有新的连接或者是数据,进程就一直被阻塞。

上面的例子,进程阻塞在socket_read(),当然不能处理别的客户端请求了。
    如何解决?我们可以试试多进程或多线程。


多进程/多线程

多进程多线程的思路差不多,下面只展示用多进程实现,有兴趣的同学可以用多线程也实现一版。

代码也不复杂,多写几行:

从实践中理解IO模型

父进程负责处理连接客户端,子进程负责从每个客户端里读数据,所以各个客户端之间不会相互影响。同时开启两个客户端,都能获取回显数据。 

从实践中理解IO模型

从实践中理解IO模型


现在思考一下,用多进程/多线程处理多个客户端有什么问题?

进程/线程的创建是有开销成本的,如果每个客户端连接进来都创建一个进程/线程,当并发量高的时候系统开销很可观。而且在多进程/多线程之间切换也是有相当的成本。能否用尽量少的进程/线程来处理大批量的客户端?答案是肯定的。 

回顾最初的阻塞IO,之所以不能处理多个客户端,是因为进程被阻塞了。如果socket_accept()和socket_read()不会阻塞进程,那么理论上就可以处理多个客户端了。



非阻塞IO

阻塞跟非阻塞有什么区别?

阻塞IO会让进程挂起,等待IO完成。非阻塞型IO不会让进程挂起,函数立刻返回。如果客户端已连接或者数据已准备好,返回SUCCESS,没准备好就返回FAIL。所以程序需要循环地“询问”,直到返回SUCCESS。

使用socket_set_nonblock()可以把socket设置成非阻塞。 现在来改写一下阻塞IO的程序:

从实践中理解IO模型

分析代码,所有socket都设置成非阻塞,因此调用socket_accept()和socket_read()都会立刻返回。

第一个循环,获取客户端的连接socket,获取到新的连接就写入到$client数组。
第二个循环,把$client数组里面的所有连接socket遍历,找到可以读数据的socket,再回显。

执行效果如期,能同时接受多个客户端。但这样的代码有两个致命的问题:

1.第一个循环,因为没有阻塞,会把CPU给“累趴”,机器负载很容易飙高。
2.第二个循环,把所有的socket都遍历一遍,包括没有可读数据的socket,效率非常低。  

综上两个原因,这种IO模式基本不会被采用。那么能否改善?可以的,继续往下看。



IO多路复用

不纠结什么叫多路复用,先上代码。 select可以实现多路复用,现在我们把程序再改一版,使用select代替轮询。

从实践中理解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做了什么?

1.封装多路复用调用,支持select, poll, epoll, kqueue….开发者只需要业务逻辑开发。
2.实现了Reactor模式,可以用事件回调来编写IO处理程序,也就是所谓的“事件驱动”。
3.封装了一些其他功能,如buffer、http server、计时器等等。  

PHP安装libevent扩展或event扩展就可以使用libevent,下面我们用libevent来改写程序。

从实践中理解IO模型

非常浓厚的“事件”味道,因为上述代码写了两个回调函数,典型的事件处理方式。代码多了两个类:

1.EventBase: 表示事件管理器。
2.Event: 表示每个socket的读写事件。

核心逻辑并不复杂,先把socket的读写事件生成一个Event对象,同时指定一个回调函数,用于事件发生后执行业务逻辑。然后把Event对象注册进事件管理器EventBase对象,最后进行loop()监听。 

运行脚本,效果如期,同样能处理多个客户端。上述代码用了哪种多路复用方式?可以使用EventBase::getMethod()来查看。

从实践中理解IO模型


从实践中理解IO模型

我的操作系统是OSX,libevent优先选择kqueue。当然,可以指定使用epoll或者select。方法请查阅EventConfig类,这里不赘述。



Nginx性能很强

了解不同的IO模型后,回过头看看以高性能著称的Nginx,为什么Nginx号称能轻松应对C10K?文档给出的解释是Nginx在架构上实现了:

· Event-driven
· Asynchronous
· Non-blocking
打开nginx.config,看到有如下信息:

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上是性能很强的,尤其是做代理服务器和静态文件服务器。


总结


介绍完五种的IO模型,可以做个简单总结:

从实践中理解IO模型

如果我们要实现一些“异步”操作,如异步HTTP客户端,直接使用libevent就行。

排版 \ chuanrui

如下技术文章,你可能也感兴趣: