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

 约 10 分钟

初衷

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

使用方法

首先下载源码:源码地址

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

  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服务。

这个服务器支持了:

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

运行环境

Unbtun 16.04.2 内核版本是4.8

如何实现一个Web服务器:

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

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

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

  • 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模块开发与架构解析》

阅读 7.3k

推荐阅读
Lmagic的专栏
用户专栏

一个前端er的学习笔记~

4 人关注
9 篇文章
专栏主页
目录