高并发服务器编程经历了从同步IO到异步IO,从多进程或多线程模型到事件驱动的演变,基于事件的并发编程依赖于操作系统提供的IO多路复用技术。这篇文章从什么是IO多路复用谈起,列举基于事件的高并发服务器,并且对比了select,poll和epoll三种事件通知机制,libevent,libev和libuv三个事件框架,最后给出了分别使用select和epoll实现的echo server示例。
什么是IO多路复用(I/O Multiplexing)
这个概念来自通信领域,指一个信道上传输多路信号的技术,在计算机里表示使用一个线程监视多个描述符的就绪状态。IO多路复用的技术是由操作系统提供的功能,比如POSIX标准下的select或linux特有的的epoll以及BSD特有的kqueue。首先向操作系统注册一个描述符集合的可读或可写事件,如果某个(或某些)描述符就绪时,操作系统会通知。这样,多个描述符就能在一个线程内并发通信。
这里的描述符通常是socket,I/O多路复用也就是很多网络连接(多路),共(复)用一个线程。
有哪些事件
以select和tcp socket为例,所谓的可读事件是指:
- socket内核接收缓冲区中的可用字节数大于或等于其低水位SO_RCVLOWAT;
- socket通信的对方关闭了连接,这个时候在缓冲区里有个文件结束符EOF,此时读操作将返回0
- 监听socket的backlog队列有已经完成三次握手的连接请求,可以调用accept
- socket上有未处理的错误,此时可以用getsockopt来读取和清除该错误。
所谓可写事件,则是指:
- socket的内核发送缓冲区的可用字节数大于或等于其低水位SO_SNDLOWAIT;
- socket的写端被关闭,继续写会收到SIGPIPE信号;
- 非阻塞模式下,connect返回之后,发起连接成功或失败;
- socket上有未处理的错误,此时可以用getsockopt来读取和清除该错误。
select vs poll vs epoll
select
select是一种古老稳定的IO多路复用技术,最大的优点是兼容性好,几乎所有的平台都支持,它的缺点是:
- 当可读或可写事件发生时,需要通过手动遍历所有的描述符,判断FD_ISSET位是否被设置的方式来找出是哪个描述符就绪
- 描述符集合数有上限,由FD_SETSIZE定义,linux上是1024
- select会修改fd_sets,导致它们不能被复用。每次调用select都需要重新创建描述符集合
- 不能在另一个线程修改描述符,比如close
poll
poll是为了解决select的缺陷而发明的,它有以下优点:
- 没有描述符数的限制
- 不会修改pollfd,多次poll可以直接复用描述符集合
它同样包括以下缺点:
- 需遍历找出触发事件的描述符
- 处于监听的描述符不能被close
epoll
epoll是linux上特有的IO多路复用技术,它的内部机制与select或poll很不同,相比之下,它具有很多性能和功能方面的优势:
- 返回触发事件的描述符集合,不需要遍历
- 可以在被监视的事件上绑定额外的数据
- 任何时候都可以移除或加入socket
- 支持edge triggering 模式
但它并不是poll的改进版,相比poll它也有缺点:
- 修改事件的flag需要调用epoll_ctl,会带来用户态和内核态切换的开销
尽管epoll很高效,但并不是任何场景都适合,在以下场景应该使用poll而不是epoll:
- 不只在linux上使用
- socket数不超过1000
- socket数超过1000,这些连接的生命都很短暂
那么问题来了,epoll是怎么实现的?这是一个很好的面试题。
事件驱动与多线程对比
服务端程序的特点是IO操作频繁,大部分时间都在等待IO上。 多线程不仅占用更多的内存,而且线程切换也会带来一定的开销。另一方面,多个线程修改共享的数据会产生竞争条件,需要加锁,这就容易导致另一个严重的问题:死锁。
使用事件驱动则只有一个线程,没有线程切换的开销,效率高,并且不用考虑竞争条件。redis就是使用单进程单线程模型,所有的命令自然就是原子的。
这里有一个有趣的问题,如果大部分进程阻塞在socket I/O上,操作系统会继续调度一直处于阻塞状态的线程吗?
基于事件的应用举例
最成功的例子莫过于nginx。nginx内部封装了poll,epoll等各种事件模型,它会自动选择当前平台支持的最高效的方式,在linux上是epoll,也可以通过use指令手动指定。nginx最擅长的是做反向代理服务器,即转发用户的请求到后端服务器。一个work进程在等待后端返回时并不会被阻塞,而是继续处理其他请求,当后端返回数据时,该事件自动触发回调函数进行处理。
同样的,redis也实现了自己的事件框架。除此之外,翻墙软件shadowsocks的C语言版就是基于libev,memcached使用了libevent,nodejs使用libuv。
以上都是服务器端程序,作为客户端的爬虫框架scrapy,底层使用了twisted,同样是由事件驱动的。
如今,事件驱动的网络应用已经取得了巨大的成功。事实上,单进程单线程的事件驱动并发编程并不能充分利用多核CPU,因此很多情况下都是混合使用,比如nginx使用多个worker进程,每个进程内部是基于事件的,而memcached使用多线程,每个线程内部又是基于事件的。
libev vs libevent vs libuv
这些库为不同平台的事件模型编程封装了统一的接口。
libevent
libevent封装了现有的polling方法,使你只需要写一遍代码就可以在很多系统上编译运行。
libev
libev最初是为了解决libevent中的设计问题而开发的,它的设计哲学是”do one thing only”。与libevent相比,libev不使用全局变量,可以安全的在多线程环境使用;不同的事件类型使用不同的数据结构,比如有I/O,时间和信号等类型;移除了额外的组件,比如http服务器和DNS客户端。因此,libev是一个轻量的库。
libuv
libuv则是专门为node.js开发,在libev的基础上加入了对windows的支持,具有很好的跨平台兼容性。
无论是什么库,底层都是用了由操作系统提供的系统调用,比如select或epoll。
echo server示例
前面讲的都是理论,是时候来点干货了。下面分别使用select和epoll实现echo server,展示IO多路复用的用法。echo server就是服务端发回客户端发送的任何文本。CMU的课程网站上有一个简单的示例:http://www.cs.cmu.edu/afs/cs/academic/class/15213-f00/www/class24code/echoserver.c,它一次只能连接一个客户端。
echo server using epoll
这个示例来自How to use epoll? A complete example in C,原始的程序将客户端发送的内容打印出来,我将它改成发回给客户端。
echo server using select
这个示例来自Socket Programming in C/C++: Handling multiple clients on server without multi threading,我修复了一个bug,并且支持通过参数指定端口号。