用c++开发一个简版http服务器

初衷

在阅读了TLPI和深刻理解计算机系统以后,学会了如何使用linux系统api,想在写代码的过程当中来加深本身对知识的理解,更想用这些知识来去作一个更酷的东西,而不只仅是教课书上的简单服务器。并且在实现过程当中每每能学到教科书外的东西。
私觉得项目为导向是学习编程的最好方法。并且没有什么比本身创造一个东西有趣。
“将一个实际的浏览器指向本身的服务器,看着他显示一个复杂的带有文本和图片的web页面,真是很是使人兴奋。"html

使用方法

首先下载源码:源码地址react

而后将web页面所需的html文件放在/var/www目录下linux

  1. $ cd /src , 进入到src目录
  2. $ make , 产生可执行文件HttpServer
  3. $ ./HttpServer \<ipv4 address> \<port number> \<process number> \<connect number per process>

    例如:./HttpServer 127.0.0.1 8080 5 1000 ,这一步是开启web-server服务。git

这个服务器支持了:

  1. 目前仅仅支持HTTP/1.1的GET方法。
  2. 暂时不支持动态内容。
  3. 完整的Http报文请求行和头部解析
  4. 简单的链接池,进程池和内存池管理
  5. 简单的负载均衡。
  6. 支持HTTP/1.1长链接
  7. 实现了一个二叉堆,对定时时间进行管理(目前只有超时链接事件)。

运行环境

Unbtun 16.04.2 内核版本是4.8github

如何实现一个Web服务器:

1.本服务器采用进程池,epoll和非阻塞I/O实现高效的半同步/半异步模式。以下图:
web

主进程只管理监听socket,链接socket都由进程池中的worker进行管理。当有新的链接到来时,主进程会经过socketpair建立的套接字和worker进程通讯,通知子进程接收新链接。子进程正确接收链接以后,会把该套接字上的读写事件注册到本身的epll内核事件表中。以后该套接字上的任何I/O操做都由被选中的worker来处理,直到客户关闭链接或超时。编程

2.每一个子进程都是一个reactor,采用epoll和非阻塞I/O实现事件循环。以下图:
api

  • a. epoll负责监听事件的发生,有事件到来将调用相应的事件处理单元进行处理浏览器

    • i. 对一个链接来讲,主要监听的就是读就绪事件和写就绪事件。缓存

      • 1). 经过非阻塞I/o和事件循环来将阻塞进程的方法分解。例如:每次recv新数据时,若是recv返回EAGAIN错误,都不会一直循环recv,而是将现有数据先处理,而后记录当前链接状态,而后将读事件接着放到epoll队列中监听等待下一个数据到来。由于每次都不会尽量的将I/O上的数据读取,因此我采用了水平触发而不是边沿触发。send同理。
    • ii. 统一事件源:

      • 1). 信号:信号是一种异步事件,信号处理函数和程序的主循环是两条不一样的执行路线,很显然,信号处理函数须要尽量的执行完成,以确保信号不被屏蔽(信号是不会排队的)。一个典型的解决方案是把信号的主要处理逻辑放到事件循环里,当信号处理函数被触发时只是经过管道将信号通知给主循环接收和处理信号,只须要将和信号处理函数通讯的管道的可读事件添加到epoll里。这样信号就能和其余I/O事件同样被处理。

        • a).忽略SIGPIPE信号(当读写一个对端关闭的链接时),将为SIGINT,SIGTERM,SIGCHILD(对父进程来讲标识有子进程状态发生变化,通常是子进程结束)设置信号处理函数。
      • 2). 定时器事件。使用timefd,一样经过监听timefd上的可读事件来统一事件源。将其设置为边沿触发,否则timefd水平触发将一直告知该事件。

        • a). 超时将经过链接池回收链接。
  • b. 链接池和内存池的实现:

    • i. 链接池:链接池采用一个map<int,Conn>和一个set<Conn>实现。链接池在构造时,将根据传入的参数new固定数目的Conn(Conn的构造函数并不会为定时器,接收和发送缓冲申请空间),且后续数目不可变。而后链接结构的地址放入到set里。新链接到来时将从set里取出一个空闲链接,而后将其初始化,并放入map,map里保存的时套接字和对应链接地址的key-value对。链接关闭时,将回收链接,从mao中移除,而后放入到set里。
    • ii. 内存池:内存池的实现是经过链接类来完成的。链接类在第一次被初始化时即第一次被使用,将申请相应的定时器,接收和发送缓存。以后将不会将申请的内存销毁,直到进程结束。经过这样来下降申请和释放内存的次数来减小内存碎片以及节约时间。
  • c. 链接:
    每一个链接都应该有一个bool Init(int connfd,size_t recv_buffer_size,size_t send_buffer_size);函数,一个Return_Code process(OptType status)函数。前一个函数会在第一次被调用时分配内存,后一个函数将根据操做类型,来决定要进行的是读仍是写操做。同时根据操做结果返回相应的状态,来决定要给epoll添加什么事件。
  • d. 时间堆的实现(定时器的精度目前为s):
    采用最小堆来实现。每次都将全部定时器中的超时时间最小的定时器的超时间隔做为心博间隔。删除和更新定时器的时间复杂度都是O(logK)k是其在堆中的位置。
  • e. 负载均衡:
    当一次事件循环结束,子进程的链接数目有变化时,将经过和父进程通讯的管道来通知自父进程本身的链接数。当新的链接到来时,父进程将选取链接数最少的一个进程,将新的链接发送给他。
  • f. Http报文请求行和头部解析:

    • i. 经过状态机来实现HTTP报文的解析。由于一个请求有可能不是在一个tcp包中到来,因此须要记录状态机的状态,以及上次check到的位置。在解析完HTTP报文后,还须要保存解析的结果,而后根据解析结果,来产生相应response。该部分实现参考了《Linux高性能服务器编程》中的实现。

为何这么设计

1. 为何采用多进程而不是单进程多线程:

a. 虽说多线程的切换开销比多进程低。若是每个进程都工做在一个cpu上,那么切换的开销彻底能够省去,并且由于咱们采用的是进程池,进程的数目在启动时是能够设置的,并且并不会在程序的执行过程当中频繁的开新进程和销毁就进程,因此进程销毁和产生这块开销也避免了。
b. 同时,多进程的编码难度比多线程要低的多,并且也不用过多的考虑到线程安全问题。
c. 综上,我选择了多进程。

2. 为何采用时间堆?

a. 首先和双链表相比,最小堆的时间复杂度是优于他的。和时间轮比,虽然添加和删除定时器的时间复杂度是O(1),可是其执行一个定时的时间复杂度是O(n),同时其精度和时间轮的槽间隔有关。而最小堆则更适合处理这种每次timer模块须要频繁找最小的key(最先超时的事件)而后处理后删除的场景。其删除一个定时器是O(lgk)(若是考虑延迟删除的话,会是O(1),可是考虑到我要复用定时器,因此执行了严格的删除),添加是O(lgn),执行则时O(1)。nigix使用的是红黑树,可是“memory locality比heap要差一些,实际速度略慢”,即便用最小堆更容易命中cache。libev使用的是更高效的4叉堆。为了简化实现,我采用了二叉堆来实现timer的功能。

3. 为何采用链接池和内存池?

a. 和上面所说的同样,为了更好的利用资源,减小内存碎片,下降频繁的申请和销毁内存的开销。

测试

  1. 我编写了一个简单的Echo类,来测试时间堆,链接池和进程池。而后测试http_conn。最后再将各个模块结合起来进行测试。
  2. 最大的体会就是,在多模块编程的时候,必定必定必定要进行单元测试,再相应的模块没问题了以后,再联合起来进行测试。
  3. 同时,代码完成以后,写相应的类的接口和函数说明,再本身code review一遍,也是很重要的检错方法。
  4. 最诡异的bug每每都是由于最愚蠢的错误。例:我i在某个调用epoll_ctl(int epollfd,int option,int fd,struct epoll_event *evlist)函数中,将option和fd参数位置换了,致使一直epoll_ctl失败。调试了一天,最后才发现,参数位置写错了,然而其余地方的调用位置都写对了。
  5. 调试工具:使用GDB进行调试,使用valgrind进行内存泄漏的检测。
    a. 由于我是申请了不少内存都没有释放,并且放在内存池和链接池中,因此致使一个内存依旧reachable的,可是当进程结束时,其会被操做系统回收因此它不算是真正的memory 。只有当你申请了一块内存,而又丢失了指针以后,才是真正的内存leak。
  6. 固然,尚未进行压力测试,打算下一步进行压力测试。目前只测试过200个链接而已。

不足

  1. 首先就是只支持get方法,也不支持动态内容。
  2. 能够增长配置文件的读取,而不是经过启动时候设置的参数
  3. 日志系统,目前还只是简单的封装了一下printf,在调试的时候打开,不调试的时候关闭。真正上线的服务器是会须要一个高效而又不影响运行的日志系统的。
  4. 可修改性。好比作到在不重启服务器的状况下,提供给用户不用的功能,好比动态修改进程数目,动态修改并发限制等等等等
  5. 模块化设计。仍是须要尽可能下降模块之间的耦合度。虽然对Conn类只要求提供两个函数接口,可是其实内存池的管理是Conn作的,能否将内存池也交给链接池来管理。还有定时器的设计。目前只比较适合于链接超时事件。
  6. 须要将服务器进程该为守护进程等等等等

收获

  1. 首先确定是增长了本身的编码能力。
  2. 加深了本身对linux系统api的了解
  3. 学到了更多关于linux服务器的知识。同时也惊叹于各类大师的智慧。我只是一个站在巨人的肩膀上重复造轮子的小人儿。
  4. 想太可能是没用的,先考虑实现,再考虑性能。在写代码前想太可能是没有意义的。Talk is cheap,show me the code。
  5. Code review的重要性!就算是本身review本身的代码,都能发现一些显而易见的错误。
  6. 文档文档文档!记录本身实现,整理本身的api,都有助于本身思考和编码。
  7. 学习GDB和valgrind使用,学习了makefile的编写。

参考资料:

感谢和感叹于大牛的智慧,编码的路上,还须要继续努力。 《linux多线程服务端编程》 《深刻理解计算机系统》 《Linx/unix编程手册》 《linux高性能服务器编程》 《深刻理解Ngix模块开发与架构解析》

相关文章
相关标签/搜索