如何写一个Web服务器

// 原文发表在 ,之后项目的改进说明和更新都将在原文进行。

最近两个月的业余时间在写一个私人项目,目的是在Linux下写一个高性能Web服务器,名字叫Zaver。主体框架和基本功能已完成,还有一些高级功能日后会逐渐增加,代码放在了github。Zaver的框架会在代码量尽量少的情况下接近工业水平,而不像一些教科书上的toy server为了教原理而舍弃了很多原本server应该有的东西。在本篇文章中,我将一步步地阐明Zaver的设计方案和开发过程中遇到的困难以及相应的解决方法。

为什么要重复造轮子

几乎每个人每天都要或多或少和Web服务器打交道,比较著名的Web Server有Apache Httpd、Nginx、IIS。这些软件跑在成千上万台机器上为我们提供稳定的服务,当你打开浏览器输入网址,Web服务器就会把信息传给浏览器然后呈现在用户面前。那既然有那么多现成的、成熟稳定的web服务器,为什么还要重新造轮子,我认为理由有如下几点:

教科书上的server

学网络编程,第一个例子可能会是Tcp echo服务器。大概思路是server会listen在某个端口,调用accept等待客户的connect,等客户连接上时会返回一个fd(file descriptor),,从fd里read,之后write同样的数据到这个fd,然后重新accept,在网上找到一个非常好的代码实现,链接点这里。如果你还不太懂这个程序,可以把它下载到本地编译运行一下,用telnet测试,你会发现在telnet里输入什么,马上就会显示什么。如果你之前还没有接触过网络编程,可能会突然领悟到,这和浏览器访问某个网址然后信息显示在屏幕上,整个原理是一模一样的!

学会了这个echo服务器是如何工作的以后,在此基础上拓展成一个web server非常简单,因为HTTP是建立在TCP之上的,无非多一些协议的解析。client在建立TCP连接之后发的是HTTP协议头和(可选的)数据,server接受到数据后先解析HTTP协议头,根据协议头里的信息发回相应的数据,浏览器把信息展现给用户,一次请求就完成了。

这个方法是一些书籍教网络编程的标准例程,比如《深入理解计算机系统》(CSAPP)在讲网络编程的时候就用这个思路实现了一个最简单的server,代码实现在这里,非常短,值得一读,特别是这个server即实现了静态内容又实现了动态内容,虽然效率不高,但已达到教学的目的。之后这本书用事件驱动优化了这个server,关于事件驱动会在后面讲。

虽然这个程序能正常工作,但它完全不能投入到工业使用,原因是server在处理一个客户请求的时候无法接受别的客户,也就是说,这个程序无法同时满足两个想得到echo服务的用户,这是无法容忍的,试想一下你在用微信,然后告诉你有人在用,你必须等那个人走了以后才能用。

然后一个改进的解决方案被提出来了:accept以后fork,父进程继续accept,子进程来处理这个fd。这个也是一些教材上的标准示例,示例代码在这里。表面上,这个程序解决了前面只能处理单客户的问题,但基于以下几点主要原因,还是无法投入工业的高并发使用。

每次来一个连接都fork,开销太大。任何讲Operating System的书都会写,线程可以理解为轻量级的进程,那进程到底重在什么地方?《Linux Kernel Development》有一节(Chapter3)专门讲了调用fork时,系统具体做了什么。地址空间是copy on write的,所以不造成overhead。但是其中有一个复制父进程页表的操作,这也是为什么在Linux下创建进程比创建线程开销大的原因,而所有线程都共享一个页表(关于为什么地址空间是COW但页表不是COW的原因,可以思考一下)。

进程调度器压力太大。当并发量上来了,系统里有成千上万进程,相当多的时间将花在决定哪个进程是下一个运行进程以及上下文切换,这是非常不值得的。

在heavy load下多个进程消耗太多的内存,在进程下,每一个连接都对应一个独立的地址空间;即使在线程下,每一个连接也会占用独立。此外父子进程之间需要发生IPC,高并发下IPC带来的overhead不可忽略。

换用线程虽然能解决fork开销的问题,但是调度器和内存的问题还是无法解决。所以进程和线程在本质上是一样的,被称为process-per-connection model。因为无法处理高并发而不被业界使用。

一个非常显而易见的改进是用线程池,线程数量固定,就没上面提到的问题了。基本架构是有一个loop用来accept连接,之后把这个连接分配给线程池中的某个线程,处理完了以后这个线程又可以处理别的连接。看起来是个非常好的方案,但在实际情况中,很多连接都是长连接(在一个TCP连接上进行多次通信),一个线程在收到任务以后,处理完第一批来的数据,此时会再次调用read,天知道对方什么时候发来新的数据,于是这个线程就被这个read给阻塞住了(因为默认情况下fd是blocking的,即如果这个fd上没有数据,调用read会阻塞住进程),什么都不能干,假设有n个线程,第(n+1)个长连接来了,还是无法处理。

怎么办?我们发现问题是出在read阻塞住了线程,所以解决方案是把blocking I/O换成non-blocking I/O,这时候read的做法是如果有数据则返回数据,如果没有可读数据就返回-1并把errno设置为EAGAIN,表明下次有数据了我再来继续读(man 2 read)。

这里有个问题,进程怎么知道这个fd什么时候来数据又可以读了?这里要引出一个关键的概念,事件驱动/事件循环。

事件驱动(Event-driven)

如果有这么一个函数,在某个fd可以读的时候告诉我,而不是反复地去调用read,上面的问题不就解决了?这种方式叫做事件驱动,在linux下可以用select/poll/epoll这些I/O复用的函数来实现(man 7 epoll),因为要不断知道哪些fd是可读的,所以要把这个函数放到一个loop里,这个就叫事件循环(event loop)。一个示例代码如下:

while (!done){ int timeout_ms = max(1000, getNextTimedCallback()); int retval = epoll_wait(epds, events, maxevents, timeout_ms); if (retval < 0) {处理错误 } else {处理到期的 timersif (retval > 0) {处理 IO 事件} }}

在这个while里,调用epoll_wait会将进程阻塞住,直到在epoll里的fd发生了当时注册的事件。 这里有个非常好的例子来展示epoll是怎么用的。 需要注明的是,select/poll不具备伸缩性,复杂度是O(n),而epoll的复杂度是O(1),在Linux下工业程序都是用epoll(其它平台有各自的API,比如在Freebsd/MacOS下用kqueue)来通知进程哪些fd发生了事件,至于为什么epoll比前两者效率高,请参考这里。

事件驱动是实现高性能服务器的关键,像Nginx,lighttpd,Tornado,NodeJs都是基于事件驱动实现的。

Zaver即使爬到最高的山上,一次也只能脚踏实地地迈一步。

如何写一个Web服务器

相关文章:

你感兴趣的文章:

标签云: