在上次的FreeBSD和linux的nginx静态文件性能对比测试 后,我萌发了自己动手做一个简单的Web Server来搞清楚nginx高性能背后的原理的想法。最后成功实现了一个基于epoll的简单的HTTP服务器,实现了200,404,400,304响应,并且性能比nginx高了一点点。本文主要介绍这个HTTP服务器的原理和设计过程。
阅读了一些文章后,我整理出了以下要点:
实现多并发的socket服务器有这样几个方法:
1. 多进程共享一个监听端口
bind之后使用fork()创建一份当前进程的拷贝,并启动子进程。子进程采用阻塞式accept、read、write,即这些操作会阻塞线程,直到操作完成才继续执行。缺点是进程之间通信速度慢,每个进程占用很多内存,所以并发数一般受限于进程数。
2. 多线程
类似多进程,只不过用线程代替了进程。主线程负责accept,为每个请求建立一个线程(或者使用线程池复用线程)。比多进程速度快,占用更少的内存,稳定性不及多进程。因为每个线程都有自己的堆栈空间,其占用的内存还是无法免除的,所以并发数一般受限于线程数。
一个阻塞式IO程序的流程示例图:
3. 事件驱动的非阻塞IO(nonblocking I/O)
单线程,将socket设置为非阻塞模式(accept、read、write会立即返回。如果已经accept完了所有的连接,或读光了缓冲区的数据,或者写满了缓冲区,会返回-1,而不是进入阻塞状态)。使用select或epoll等机制,同时监听多个IO操作有无事件发生。当其中的一个或多个处于Ready状态(即:监听的socket可以accept,tcp连接可以read等)后,立即处理相应的事件,处理完后立即回到监听状态(注意这里的监听是监听IO事件,不是监听端口)。相当于阻塞式IO编程中任意一处都可能回到主循环中继续等待,并能从等待中直接回到原处继续执行;而accept、读、写都不再阻塞,阻塞全部移动到了一个多事件监听操作中。
一个非阻塞式IO程序的流程示例图:
举例来说,如果在A连接的Read request的过程中,缓冲区数据读完了,而请求还没有结束,直接返回到主循环中监听其它事件。而这时如果发现另一个Send了一半的Response连接B变为了可写状态,则直接处理B连接Send Response事件,从上次B连接写了一半的地方开始,继续写入数据。这样一来,虽然是单线程,但A和B同时进行,互不干扰。
因为流程更加复杂,无法依靠线程的堆栈保存每个连接处理过程中的各种状态信息,我们需要自己维护它们,这种编程方式需要更高的技巧。比方说,原先我们可以在send_response函数中用局部变量保存发送数据的进度,而现在我们只能找一块其它的地方,为每一个连接单独保存这个值了。
nginx即使用事件驱动的非阻塞IO模式工作。
nginx支持多种事件机制:跨平台的select,Linux的poll和epoll,FreeBSD的kqueue,Solaris的/dev/poll等。在高并发的情况下,在Linux上使用epoll性能最好,或者说select的性能太差了。
事件机制分为水平触发,或译状态触发(level-triggered)和边缘触发(edge-triggered)。前者是用通过状态表示有事件发生,后者通过状态变化表示事件发生。打个比方来说,使用状态触发的时候,只要缓冲区有数据,你就能检测到事件的存在。而使用边缘触发,你必须把缓冲区的数据全部读完之后,才能进行下一次事件的检测,否则,因为状态一直处于可读状态,没有发生变化,你将永远收不到这个事件。显然,后者对编写程序的严谨性要求更高。
select和poll属于前者,epoll同时支持这两种模式。值得一提的是,我自己测试了一下,发现即使在20000并发的情况下,epoll使用这两种模式之前性能差异仍可以忽略不计。
另外需要注意的是,对于常规文件设置非阻塞是不起作用的。
4. 此外还有异步IO,一般在Windows上使用,这里就不谈了。
另外nginx使用了Linux的sendfile函数。和传统的用户程序自己read和write不同,sendfile接收两个文件描述符,直接在内核中实现复制操作,相比read和write,可以减少内核态和用户态的切换次数,以及数据拷贝的次数。