咋样写一个Web服务器美学原理

新近两个月的业余时间在写一个亲信项目,目标是在Linux下写一个高性能Web服务器,名字叫Zaver。主体框架和基本效率已到位,还有一些高档效能日后会渐渐增添,代码放在了github。Zaver的框架会在代码量尽量少的事态下接近工业水平,而不像有的教科书上的toy
server为了教原理而放弃了不少原本server应该有的东西。在本篇著作中,我将一步步地表达Zaver的设计方案和开支进程中相遇碰着的孤苦以及对应的解决措施。

为啥要再次造轮子

差一点每个人天天都要或多或少和Web服务器打交道,相比显赫的Web
Server有Apache
Httpd、Nginx、IIS。那个软件跑在很多台机器上为我们提供稳定的劳务,当您打开浏览器输入网址,Web服务器就会把音信传给浏览器然后显示在用户面前。那既然有那么多现成的、成熟稳定的web服务器,为何还要再度造轮子,我以为理由有如下几点:

  • 美学原理,夯实基础。一个脍炙人口的开发者必须有踏实的底子,造轮子是一个很好的路线。学编译器?边看教科书变写一个。学操作系统?写一个原型出来。编程这多少个领域唯有协调出手实现了才敢说实在会了。现在正值学网络编程,所以就打算写一个Server。

  • 心想事成新职能。成熟的软件或者为了适应别克的需要导致不会太考虑你一个人的奇特需要,于是只能协调入手实现这么些奇特需求。关于这点Nginx做得至极得好了,它提供了让用户自定义的模块来定制自己需要的功能。

  • 帮衬初学者容易地操纵成熟软件的架构。比如Nginx,即使代码写得很美观,可是想全盘看懂它的架构,以及它自定义的部分数据结构,得查分外多的素材和参照书籍,而这一个架构和数据结构是为了进步软件的可伸缩性和频率所计划的,无关高并发server的华山真面目部分,初学者会头昏。而Zaver用最少的代码体现了一个高并发server应有的样板,即便从未Nginx性能高,得到的裨益是一向不Nginx那么复杂,server架构完全显露在用户眼前。

读本上的server

学网络编程,第一个例子可能会是Tcp
echo服务器。大概思路是server会listen在某个端口,调用accept等待客户的connect,等客户连接上时会重回一个fd(file
descriptor),从fd里read,之后write同样的数目到这多少个fd,然后再度accept,在网上找到一个分外好的代码实现,主题代码是这样的:

while ( 1 ) {

    /*  Wait for a connection, then accept() it  */

    if ( (conn_s = accept(list_s, NULL, NULL) ) < 0 ) {
        fprintf(stderr, "ECHOSERV: Error calling accept()\n");
        exit(EXIT_FAILURE);
    }


    /*  Retrieve an input line from the connected socket
        then simply write it back to the same socket.     */

    Readline(conn_s, buffer, MAX_LINE-1);
    Writeline(conn_s, buffer, strlen(buffer));


    /*  Close the connected socket  */

    if ( close(conn_s) < 0 ) {
        fprintf(stderr, "ECHOSERV: Error calling close()\n");
        exit(EXIT_FAILURE);
    }
}

总体兑现在这里
即便您还不太懂这个程序,可以把它下载到本地编译运行一下,用telnet测试,你会意识在telnet里输入什么,即刻就会来得怎么。假诺您前面还从未接触过网络编程,可能会突然精通到,这和浏览器访问某个网址然后信息体现在屏幕上,整个原理是一模一样的!学会了这一个echo服务器是怎么样工作的未来,在此基础上开展成一个web
server非凡简单,因为HTTP是创立在TCP之上的,无非多一些商谈的剖析。client在建立TCP连接之后发的是HTTP协议头和(可选的)数据,server接受到数码后先解析HTTP协议头,依照商事头里的音信发回相应的数目,浏览器把音讯彰显给用户,一遍呼吁就做到了。

这多少个主意是部分书本教网络编程的正经例程,比如《深切通晓统计机连串》(CSAPP)在讲网络编程的时候就用那个思路实现了一个最简易的server,代码实现在这里,十分短,值得一读,特别是这一个server即实现了静态内容又实现了动态内容,即便功能不高,但已高达教学的目标。之后那本书用事件驱动优化了这些server,关于事件驱动会在前边讲。

即便这一个顺序能健康办事,但它完全不可能投入到工业应用,原因是server在处理一个客户请求的时候无法承受其它客户,也就是说,这么些顺序不可能同时满意四个想博得echo服务的用户,这是无能为力容忍的,试想一下您在用微信,然后告诉你有人在用,你必须等丰富人走通晓后才能用。

下一场一个更上一层楼的化解方案被指出来了:accept将来fork,父进程继续accept,子进程来处理这个fd。那些也是一对教材上的正经示例,代码大概长这么:

/* Main loop */
    while (1) {
        struct sockaddr_in their_addr;
        size_t size = sizeof(struct sockaddr_in);
        int newsock = accept(listenfd, (struct sockaddr*)&their_addr, &size);
        int pid;

        if (newsock == -1) {
            perror("accept");
            return 0;
        }

        pid = fork();
        if (pid == 0) {
            /* In child process */
            close(listenfd);
            handle(newsock);
            return 0;
        }
        else {
            /* Parent process */
            if (pid == -1) {
                perror("fork");
                return 1;
            }
            else {
                close(newsock);
            }
        }
    }

一体化代码在
这里。表面上,这多少个顺序化解了前方只可以处理单客户的题目,但依据以下几点首要原因,仍旧不可能投入工业的高并发使用。

  • 老是来一个连接都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什么时候来多少又能够读了?这里要引出一个至关首要的定义,事件驱动/事件循环。

事件驱动(伊夫nt-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 {
    处理到期的 timers

    if (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

重组地点的座谈,我们得出了一个事变循环+ non-blocking I/O +
线程池的解决方案,这也是Zaver的大旨架构(同步的风波循环+non-blocking
I/O又被叫作Reactor模型)。
事件循环用作事件通报,固然listenfd上可读,则调用accept,把新建的fd插手epoll中;是普通的接连fd,将其进入到一个劳动者-消费者队列之中,等工作线程来拿。
线程池用来做总括,从一个劳动者-消费者队列里拿一个fd作为计量输入,直到读到EAGAIN停止,保存现在的处理意况(状态机),等待事件循环对这么些fd读写事件的下五遍通报。

开发中遭受的问题

Zaver的运行架构在上文介绍完毕,下边将总计一下自我在支付时遇上的一些困难以及一些缓解方案。把开发中境遇的忙碌记录下来是个很是好的习惯,假若遇上题目查google找到个缓解方案一向照搬过去,不做其他记录,也从没思考,那么下次你碰着同样的问题,依然会重复一回搜索的历程。有时我们要做代码的创立者,不是代码的“搬运工”。做笔录定期回顾遇到的题材会使和谐成长更快。

  • 比方将fd放入生产者-消费者队列中后,得到那一个任务的做事线程还未曾读完这么些fd,因为没读完数据,所以这多少个fd可读,那么下一回事件循环又回来这么些fd,又分给此外线程,怎么处理?

答:这里涉及到了epoll的两种工作形式,一种叫边缘触发(Edge
Triggered),另一种叫水平触发(Level
Triggered)。ET和LT的命名是这么些形象的,ET是象征在场所改变时才公告(eg,在边缘上从低电平到高电平),而LT表示在那些情况才通告(eg,只要处于低电平就通报),对应的,在epoll里,ET表示只要有新数据了就通报(状态的转移)和“只要有新数据”就径直会通报。

举个有血有肉的例证:要是某fd上有2kb的数据,应用程序只读了1kb,ET就不会在下两次epoll_wait的时候回来,读完事后又有新数据才回来。而LT每一趟都会回来那些fd,只要这些fd有多少可读。所以在Zaver里大家需要用epoll的ET,用法的格局是一直的,把fd设为nonblocking,假使回去某fd可读,循环read直到EAGAIN(假若read重返0,则远端关闭了连年)。

  • 当server和浏览器保持着一个长连接的时候,浏览器突然被关门了,那么server端怎么处理这么些socket?

答:此时该fd在事变循环里会重临一个可读事件,然后就被分配给了某个线程,该线程read会再次回到0,代表对方已关门那一个fd,于是server端也调用close即可。

  • 既是把socket的fd设置为non-blocking,那么只要有局部数量包晚到了,这时候read就会回去-1,errno设置为EAGAIN,等待下次读取。这是就遇上了一个blocking
    read不曾遭遇的问题,大家务必将已读到的数码保存下来,并保障一个情况,以象征是否还亟需多少,比如读到HTTP
    Request Header, GET /index.html HTT就得了了,在blocking
    I/O里假若继续read就足以,但在nonblocking
    I/O,我们必须保障这一个情景,下五回必须读到’P’,否则HTTP协议分析错误。

答:解决方案是保安一个状态机,在解析Request
Header的时候对应一个状态机,解析Header
Body的时候也保障一个状态机,Zaver状态机的时候参考了Nginx在解析header时的落实,我做了一些精简和计划上的变更。

  • 怎么较好的落实header的解析

答:HTTP
header有诸多,必然有诸三个解析函数,比如解析If-modified-since头和剖析Connection头是分别调用四个例外的函数,所以这里的筹划必须是一种模块化的、易拓展的规划,可以使开发者很容易地修改和定义针对不同header的解析。Zaver的落实形式参考了Nginx的做法,定义了一个struct数组,其中每一个struct存的是key,和呼应的函数指针hock,假设条分缕析到的headerKey
== key,就调hock。定义代码如下

zv_http_header_handle_t zv_http_headers_in[] = {
    {"Host", zv_http_process_ignore},
    {"Connection", zv_http_process_connection},
    {"If-Modified-Since", zv_http_process_if_modified_since},
    ...
    {"", zv_http_process_ignore}
};
  • 何以存储header

答:Zaver将享有header用链表连接了起来,链表的实现参考了Linux内核的双链表实现(list_head),它提供了一种通用的双链表数据结构,代码相当值得一读,我做了简化和改动,代码在这里

  • 压力测试

答:这一个有过多早熟的方案了,比如http_load, webbench,
ab等等。我最终选项了webbench,理由是大概,用fork来效仿client,代码唯有几百行,出问题得以即时依照webbench源码定位到底是哪个操作使Server挂了。另外因为背后提到的一个问题,我仔细看了下韦布ench的源码,并且特别推荐C初学者看一看,唯有几百行,不过涉及了命令行参数解析、fork子进程、父子进程用pipe通信、信号handler的登记、构建HTTP协议头的技术等部分编程上的技能。

  • 用韦布(Webb)ech测试,Server在测试截止时挂了

答:百思不得其解,不管时间跑多长时间,并发量开多少,都是在结尾webbench停止的天天,server挂了,所以我估量肯定是这一阵子发出了哪些“事情”。
起来调剂定位错误代码,我用的是打log的措施,后边的事实注解在这里这不是很好的艺术,在多线程环境下要通过看log的艺术固定错误是一件相比艰辛的事。最终log输出把错误定位在向socket里write对方伸手的公文,也就是系统调用挂了,write挂了难道不是再次回到-1的呢?于是唯一的演讲就是经过接受到了某signal,这多少个signal使进程挂了。于是用strace重新开展测试,在strace的出口log里发现了问题,系统在write的时候接受到了SIGPIPE,默认的signal
handler是终止进程。SIGPIPE发生的原因为,对方已经关闭了这么些socket,但经过还往里面写。所以自己臆度webbench在测试时间到了随后不会等待server数据的回来直接close掉所有的socket。抱着如此的多疑去看webbench的源码,果然是这般的,webbench设置了一个定时器,在正规测试时间会读取server重临的数量,并正常close;而当测试时间一到就间接close掉所有socket,不会读server重临的数码,这就招致了zaver往一个已被对方关闭的socket里写多少,系统发送了SIGPIPE。

缓解方案也异常简单,把SIGPIPE的信号handler设置为SIG_IGN,意思是忽视该信号即可。

不足

时下Zaver还有很多改进的地点,比如:

  • 现在新分配内存都是因此malloc的章程,之后会改成内存池的措施
  • 还不匡助动态内容,先前时期开首考虑扩大php的支撑
  • HTTP/1.1较复杂,最近只兑现了多少个重点的(keep-alive, browser
    cache)的header解析
  • 不移动连续的过期过期还没有做

总结

本文介绍了Zaver,一个布局简单,帮忙高产出的http服务器。基本架构是事件循环

  • non-blocking I/O +
    线程池。Zaver的代码风格参考了Nginx的风骨,所以在可读性上充裕高。其它,Zaver提供了布置文件和命令行参数解析,以及系数的Makefile和源代码结构,也得以扶助其他一个C初学者入门一个门类是怎么构建的。如今我的wiki就用Zaver托管着。

参考资料

[1]
https://github.com/zyearn/zaver

[2]
http://nginx.org/en/

[3] 《linux多线程服务端编程》

[4]
http://www.martinbroadhurst.com/server-examples.html

[5]
http://berb.github.io/diploma-thesis/original/index.html

[6] <a href=”http://tools.ietf.org/html/rfc2616
target=”_blank”>rfc2616</a>

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

[8] Unix Network Programming, Volume 1: The Sockets Networking API
(3rd Edition)