浅谈网络事件的响应机制

一、先举个例子:

你在网易严选上买了一件1999元超暖超赞的秋冬加厚羽绒服,很迫切希望早点收到这件宝贝的快递,但又不清楚是发了哪一家快递公司,于是你可能:

  • 方式1:你坐在小邮局的快递入仓口,什么事情都不做,就干守着,等快递员来。。
  • 方式2:你找到EMS、顺丰、圆通、芝麻开门等快递公司的快递员电话号码,然后逐个拨通,问“我的某某快递是否送到了?”。每隔一段时间就拨打询问一遍,直到某个快递员说货到了别打了。。
  • 方式3:你该干嘛干嘛去,快递员送货到小邮局后,小邮局管理员会通知你来取。

乍一看,方式1/2很无脑,方式3最好。但其实孰优孰劣,还得仁者见仁。方式1在日常生活中确实很傻,但它其实就是网络编程中的阻塞模式,在真实代码作品中并不少见。下面我们就来浅谈一番网络编程中那些常见的事件响应机制。


二、方式1/2/3的对比:

先翻译一下这个例子:


例子场景

对应的计算机场景

常见的技术

快递包裹

网络I/O事件

Read,Write,Exception

快递员

网络事件的请求方

Socket Client

网络事件的接收方

Socket Server

方式1:一直守着,且不能干其它事

阻塞式I/O

Socket默认

方式2:询问每一家快递公司

遍历所有Socket fd描述符去处理网络事件

select,poll

方式3:由快递员/小邮局主动通知你

网络事件驱动模型

epoll,kqueue

例子中的这3种不同的处理方式,体现的是网络编程中不同的事件响应和处理机制。让我们用计算机语言分别解读一下这3种处理方式。


方式1是阻塞式I/O,没有处理完数据函数不返回。你在小邮局门口守株待兔,这段期间你不能干其它任何事情,包括上厕所和玩手机。这看起来很傻,但在真实编程世界中,却随处可见这种阻塞式I/O,尤其是在一些同步通信的应用中。它的缺点很明显,但也并非一无是处:业务逻辑实现简单,系统依赖较少,方便处理异常错误,一旦阻塞线程被挂起不再进入操作系统内核的调度队列(不再消耗CPU。当然,可以通过socket参数设置为非阻塞模式(如Pythonsock.setsockopt()函数)


方式2是轮询,遍历句柄描述符集合中的每一个描述符fd,检查是否有就绪的网络事件需要处理。Linux/Windows网络编程所提供的select()poll()函数,就是采用轮询遍历的方式。就如同方式2中你打电话询问每一家快递公司我的包裹到了没,哪怕全世界只有一家快递公司,不仅你很累,那个快递员也会被你烦死(相当于CPU一直在工作),更别说有成百上千家快递公司,对你的电话费和快递员们都是负担(应用程序和操作系统都有开销)。对于并发请求量小的网络应用来说,轮询机制问题不大。但对于那些高吞吐高并发的热门应用来说,C10K问题(甚至C100K问题)是他们的家常便饭,如果此时还使用轮询遍历的方式,性能之差可想而知。这也是大家熟知的ApacheNginx超越的根本原因。


方式3是事件驱动的方式,也称消息通知方式。Linux内核提供的epollFreeBSD提供的kqueue都是采用该方式,本质都是一种多路复用I/O技术。简单说就是应用程序将需要关注的描述符fd登记到操作系统内核中,一旦有就绪的网络I/O事件(读/写)则由操作系统主动通知应用程序。以epoll为例,轮询方式的select()poll()都只有一个调用函数,而epoll却提供了三个调用函数,epoll_create()用于新建epoll描述符,epoll_ctl()用于往描述符设置要关注的事件EPOLL_CTL_ADD添加、EPOLL_CTL_DEL删除、EPOLL_CTL_MOD修改),最后用epoll_wait()来等待网络就绪事件,这些就绪事件由操作系统来主动通知,而非应用程序主动轮询。FreeBSDkqueue也是类似。就如同你不用蹲守小邮局门口,也不需要挨家快递公司打电话去问,你该码代码码代码去,该画图画图去,该做PPTPPT去,一旦快递到了(网络事件就绪了),自然有快递员或小邮局主动通知你。相比其他方式,事件驱动方式的优势很明显,对于编写低开销高并发高吞吐的网络程序非常有用,流行的nginxredismemcache、lighttpd等开源产品皆受益于此。同时对编程要求较高(譬如epoll分为Edge Triggered和Level Triggered两种不同的触发机制,代码实现不同,别用混了),尤其是异常处理方面(譬如如何对待EAGAIN异常)


三、select/poll/epoll/kqueue主要特点:

select:

  • Linux和Windows都支持。
  • 为每一类网络事件(读,写,异常)创建好要关注的描述符集合fd_set,通常会创建3个集合。接着调用select()函数等待事件的发生,本质是轮询fd_set集合中的每一个fd。遇到海量fd时性能极差。
  • select与阻塞和非阻塞是没有必然关系的,可以通过select()函数的timeout参数来指定每次等待的时间,设置为0则有事件发生则立刻返回。

poll:

  • 在Linux 2.1.23引入,Windows不支持。
  • 与select基本相同,也是先创建一个关注事件的描述符集合,然后就调用poll()函数等待事件的方式,本质也是轮询遍历整个描述符集合中检查是否有就绪事件。遇到海量fd时性能也极差。
  • Poll和select的主要区别:select需要创建好三个集合(读/写/异常),轮询时也要分别轮询这三个集合。而poll只需要创建一个集合,对每个描述符设置读/写/异常事件,轮询时只需要遍历这一个集合,可以同时检查这三种网络事件。

epoll:

  • 在Linux 2.5.44引入,Windows不支持。
  • Event poll,简称epoll,属于poll的一个变种。
  • 从事件响应方式的角度区分,Select/poll都是属于轮询机制,epoll和下面的kqueue则是更高效的事件驱动机制。
  • 从函数调用的角度区分,select/poll都只提供了一个函数,而epoll则有三个调用函数(参见上文第二段落)

kqueue:

  • 在FreeBSD中支持。
  • 与epoll很相似,都是属于网络事件驱动机制。
  • 与epoll在调用细节上有一些不同,譬如kqueue以<socket fd, filter>为key,对同一个键可以重复add添加(后者覆盖前者),而epoll以fd为key,对同一个fd重复add时会报“File exists”错误。


四、小结:

在网络编程中,blockingnon-blocking谁更好?selectpollepollkqueue谁更好?这些都是伪命题,脱离了具体需求和业务场景都无从谈起。简单说,

  • 阻塞式比较简单,方便,稳定,适合简单的客户端程序。
  • 非阻塞式需处理好重复读取数据的逻辑,对于服务器端程序更适用。
  • 如果对于低并发的服务器端程序,简单的select/poll是足够的,必要时考虑结合非阻塞、多线程等方式。
  • 而如果对于海量并发的服务器端程序,则考虑I/O复用技术(如Linux下的epoll、FreeBSD下的kqueue或者异步I/O技术(如Windows下的IOCP来实现。

 提个醒:网易严选的快递都发顺丰哈 :-D

 

五、参考资料:


http://baike.baidu.com/view/2877739.htm

http://www.wuzesheng.com/?p=660

https://www.zhihu.com/question/20122137

http://man7.org/linux/man-pages/man7/epoll.7.html

https://banu.com/blog/2/how-to-use-epoll-a-complete-example-in-c/

http://www.ibm.com/developerworks/cn/aix/library/1105_huangrg_kqueue/

https://people.eecs.berkeley.edu/~sangjin/2012/12/21/epoll-vs-kqueue.html

网易云新用户大礼包:https://www.163yun.com/gift

本文来自网易实践者社区,经作者陈俊平授权发布。