当前位置: 首页 > Linux

如何实现一个WebServer

时间:2023-04-06 06:53:00 Linux

最近重构了一个轮子Vino去年搭建的。Vino旨在实现一个轻量级且性能有保证的WebServer,只专注于WebServer的本质部分。在重构的过程中,Vino借鉴了很多优秀的开源项目的思想,比如Nginx、Mongoose、Webbench。因此,与之前版本的Vino相比,当前的Vino不仅性能有所提升,而且设计也更加优雅和坚固:D。本文将对Vino目前具备的主要特性进行说明,并总结开发过程中的一些经验。单线程+Non-BlockingVino整体采用事件驱动的单线程+Non-Blocking模型。单线程模型避免了系统分配多个线程和线程间通信的开销,同时减少了内存消耗。由于是单线程模型,为了更好的提高线程利用率,Vino将默认的BlockingI/O设置为Non-BlockingI/O,即在线程读写数据的过程中,如果buffer为空/当缓冲区满时,线程不会阻塞,而是立即返回并设置了errno。Vino的灵感最初来自《ComputerSystems:AProgrammer'sPerspective》一书,该书描述了在网络编程中实现的简单Web服务器。每次有请求到来,WebServer都会fork一个进程来处理它。显然,这种模型在高并发场景下是不合理的。每个fork进程都会带来巨大的开销,而系统中的进程数是有限的。同时,多进程带来的进程调度开销也不容小觑,CPU会花费大量时间来决定调用哪个进程。进程调度引起的进程上下文切换也会消耗相当大的资源。很容易想到用多线程模型代替多进程模型。与多进程模型相比,多线程模型占用的系统资源会大大减少,但本质上并没有减少线程调度带来的开销。为了减少线程调度带来的开销,我们可以采用线程池模型,即固定线程数,但是问题依然存在:因为Linux中默认的I/O是阻塞的(Blocking),如果所有线程池中的线程同时阻塞,因为正在处理请求,没有线程去处理新进来的请求。因此,如果我们将默认的BlockingI/O换成Non-BlockingI/O,线程读写数据就不会被阻塞,问题就可以解决。HTTPKeep-AliveVino支持HTTPPersistentConnections,即多个请求可以复用同一个TCP连接,从而减少TCP连接建立/断开带来的性能开销。每次有请求过来,Vino都会解析请求,判断请求头中是否有Connection:keep-alive请求头。如果存在,则在处理完一个请求后保持连接,并重置数据缓冲区(用于保存请求内容和响应内容)和状态标志,否则关闭连接。关于HTTPKeep-Alive的优点,RFC2616有比较完整的总结,引用如下。通过打开和关闭更少的TCP连接,可以节省路由器和主机(客户端、服务器、代理、网关、隧道或缓存)中的CPU时间,并且可以节省主机中用于TCP协议控制块的内存。HTTP请求和响应可以在连接上进行流水线处理。流水线允许客户端发出多个请求而无需等待每个响应,允许更有效地使用单个TCP连接,消耗时间更短。通过减少由TCP打开引起的数据包数量,并通过允许TCP有足够的时间来确定网络的拥塞状态。由于TCP的连接打开握手没有花费时间,因此减少了后续请求的延迟。HTTP可以更优雅地发展,因为可以报告错误而不会关闭TCP连接。使用未来版本的HTTP的客户端可能会乐观地尝试新功能,但如果与older服务器,在报告错误后使用旧语义重试。TimerTimer如果一个请求在建立连接后没有发送数据,或者对方突然没电了,应该怎么办?我们需要实现定时器来处理加班请求。Vino定时器的实现参考了Nginx的设计。Nginx使用红黑树来存储每一次计时事件,不断寻找最小的(early)事件,如果超时则触发超时处理。为了简化实现,在Vino中,我实现了一个小的topheap来存储计时事件。如果处理的定时事件同时支持长连接,则在处理完请求后更新请求对应的定时器,即重启定时。计时器相关代码见vn_event_timer.h和vn_event_timer.c。由于网络的不确定性,HTTPParser不能保证一次性读取所有请求数据。因此,对于每一个请求,我们都会开辟一个缓冲区来保存已经读取的数据。同时,我们需要同时对读取到的数据进行分析,确保读取到的数据是合理的数据。例如,假设当前缓冲区中的数据是GET/index.htmlHTT,那么下次读取的字符一定是P,否则,应该立即检测到当前请求是异常请求,并主动关闭当前连接。基于以上分析,我们需要实现一个HTTP状态机(Parser)来维护当前的解析状态。Vino状态机的实现参考了Nginx的设计,简化了Nginx的实现。HTTPParser相关代码见vn_http_parse.h和vn_http_parse.c。内存池我们一般使用malloc/calloc/free来分配/释放内存,但是这些函数对于一些需要长时间运行的程序来说有一些缺点。频繁使用这些函数分配和释放内存会导致内存碎片,系统也不容易直接回收内存。一个典型的例子就是大并发时内存的频繁分配和回收,会导致进程内存碎片化,不会立即被系统回收。使用内存池分配内存可以在一定程度上提高内存分配的效率,而不需要每次都调用malloc/calloc函数。同时,使用内存池使得内存管理更加容易。在Vino中,对于每一个请求,Vino都会分配一个或多个内存池(每个内存池组成一个单向链表),请求处理完毕后,将所有内存一起释放。Vino内存池的实现仍然参考了Nginx的实现,并进行了简化。内存池相关代码见vn_palloc.h和vn_palloc.c。其他在开发Vino的过程中,有很多的考虑和取舍。在响应请求时,如果用户请求了一个大文件,写缓冲区满了,我们如何更好地设计响应缓冲区呢?如何更高效地设计底层数据结构(如字符串、链表、小顶堆等)?如何更优雅地解析命令行参数?如何处理特定信号?如何更稳健地处理错误信息?当代码数量达到一定程度,如何更快定位异常代码?Vino的开发重构暂时告一段落,源码放在GitHub上。当然,Vino有很多不足之处,还有未实现的功能。仅支持HTTPGET方法,暂不支持其他HTTP方法。当前不支持处理动态请求。受支持的HTTP/1.1功能有限。...写这篇文章,希望对初学者有所帮助。参考[1]Vino,https://github.com/tinylcy/vino。[2]计算机系统:程序员的视角,http://csapp.cs.cmu.edu/。[3]UNIX环境中的高级编程(第3版),https://www.amazon.ca/Advance...[4]Unix网络编程,第1卷,https://www.amazon.ca/Unix-Ne...[5]Nginx,https://github.com/nginx/nginx。[6]猫鼬,https://github.com/cesanta/mo...。[7]WebBench,http://home.tiscali.cz/~cz210...。[8]Zaver,https://github.com/zyearn/zaver。[9]RFC2616,https://tools.ietf.org/html/r...。[10]如何使用epoll?C中的完整示例,https://banu.com/blog/2/how-t...。