的初衷是为了在阅读TLPI和深入了解计算机系统后学习如何使用linux系统api。想在写代码的过程中加深对知识的理解,想利用这些知识做更酷的事情,而不仅仅是教科书上简单的服务器。并且在实施的过程中,往往可以学到课本之外的东西。我个人认为面向项目是学习编程最好的方式。没有什么比自己创造东西更好的了。“将一个实际的浏览器指向您自己的服务器并观看他显示一个包含文本和图片的复杂网页,这非常令人兴奋。”使用方法首先下载源码:源码地址然后添加网页需要的内容html文件放在/var/www目录下$cd/src,进入src目录$make,生成可执行文件fileHttpServer$./HttpServer\\\\例如:./HttpServer127.0.0.1808051000,这一步是启动web-server服务。本服务器支持:目前只支持HTTP/1.1的GET方式。目前不支持动态内容。完整的Http消息请求行和头部解析简单的连接池、进程池和内存池管理简单的负载均衡。支持HTTP/1.1长连接,实现二叉堆管理时序(目前只有超时连接事件)。运行环境Unbtun16.04.2内核版本为4.8如何实现一个web服务器:1.本服务器使用进程池、epoll和非阻塞I/O实现高效的半同步/半异步模式。如下图所示:主进程只管理监听socket,连接socket由进程池中的worker管理。当有新的连接到来时,主进程会通过socketpair创建的socket与worker进程通信,通知子进程接收新的连接。子进程正确接收到连接后,会将socket上的读写事件注册到自己的epll内核事件表中。该套接字上的任何后续I/O操作都由选定的工作程序处理,直到客户端关闭连接或超时。2、每个子进程都是一个reactor,使用epoll和非阻塞I/O实现事件循环。如下图所示:epoll负责监控事件的发生。当一个事件到来时,它会调用相应的事件处理单元对i进行处理。对于一个连接,主要监听的是读就绪事件和写就绪事件。1).通过非阻塞I/O和事件循环分解阻塞过程方法。例如:每次recv新数据,如果recv返回EAGAIN错误,recv不会一直循环,而是先处理已有数据,然后记录当前连接状态,然后放置read事件在epoll队列中监听等待A数据到来。因为不会每次都尽可能多的读取I/O上的数据,所以我用的是水平触发,而不是边沿触发。发送是一样的。二.统一事件源:1)。信号:信号是一个异步事件。信号处理函数和程序的主循环是两条不同的执行路线。显然,需要尽可能多地执行信号处理函数,以保证信号不被阻塞(信号不排队)。一个典型的解决方案是将信号的主要处理逻辑放在事件循环中。当信号处理函数被触发时,信号只通知主循环通过管道接收和处理信号。只有与信号处理函数通信的管道需要将Readable事件添加到epoll中。这样就可以像处理任何其他I/O事件一样处理信号。A)。忽略SIGPIPE信号(读写被对端关闭的连接时),设置SIGINT、SIGTERM、SIGCHILD的信号处理函数(对于父进程来说,子进程的状态发生了变化,一般结束子进程)。2).定时器事件。使用timefd,事件源也是通过监听timefd上的可读事件来统一的。设置为边沿触发,否则timefd电平触发会一直告诉事件。A)。超时会通过连接池回收连接。b.连接池和内存池的实现:i.连接池:连接池由一个map和一个set实现。连接池构造时,会根据传入的参数new有固定数量的Conn(Conn的构造器不会申请定时器,接收发送缓冲空间),后续数量不变。然后将连接结构的地址放入集合中。当有新的连接到来时,会从集合中取出一个空闲的连接,然后进行初始化,放入映射中。map中保存了连接地址对应的socket和键值对。当连接关闭时,连接会被回收,从mao中移除,然后放入set中。二.内存池:内存池的实现是通过连接类完成的。第一次初始化连接类时,会第一次使用,会申请相应的timer,receive和sendcache。之后,请求的内存不会被销毁,直到进程结束。这样就减少了申请和释放内存的次数,减少内存碎片,节省时间。C。连接:每个连接都应该有一个boolInit(intconnfd,size_trecv_buffer_size,size_tsend_buffer_size);函数,一个Return_Codeprocess(OptTypestatus)函数。前一个函数在第一次调用时会分配内存,后一个函数会根据操作的类型来决定是进行读操作还是写操作。同时根据运行结果返回相应的状态来决定将什么事件添加到epoll中。d.时间堆的实现(当前定时器的精度为s):使用最小堆实现。每次使用所有定时器中超时时间最短的定时器的超时间隔作为心跳间隔。删除和更新定时器的时间复杂度是O(logK)k是它在堆中的位置。e.负载均衡:当一个事件循环结束,子进程的连接数发生变化时,会通过与父进程的通信通道通知父进程的连接数。当有新的连接到来时,父进程会选择一个连接数最少的进程,将新的连接发送给他。F。Http消息请求行和头部分析:i.通过状态机实现对HTTP报文的解析。因为一个请求可能没有到达一个tcp数据包,所以需要记录状态机的状态和最后检查的位置。解析HTTP报文后,还需要保存解析结果,然后根据解析结果生成相应的响应。这部分实现参考《Linux高性能服务器编程》中的实现。为什么这样设计1、为什么使用多进程而不是单进程多线程:a.虽然多线程的切换开销比多进程低。如果每个进程都工作在一个cpu上,那么就可以完全省去切换的开销,而且因为我们使用了进程池,所以进程的数量可以在启动时设置,不会在程序执行过程中频繁的开新进程旧进程被销毁,因此也避免了进程销毁和这种开销。b.同时,多进程的编码难度远低于多线程,无需过多考虑线程安全。C。综上,我选择了多进程。2、为什么要用时间堆?A。首先,和双链表相比,最小堆的时间复杂度比他好。与时间轮相比,虽然添加和删除定时器的时间复杂度是O(1),但是执行一次计时的时间复杂度是O(n),其精度与时间轮的时隙间隔有关。最小堆更适合处理各个定时器模块需要频繁的寻找最小的key(最早的超时事件)处理完再删除的场景。它删除一个timer是O(lgk)(如果考虑延迟删除就是O(1),但是考虑到我要复用timer,所以进行严格删除),add是O(lgn),而执行则为O(1)。Nigix使用的是红黑树,但是“内存局部性比堆差,实际速度稍慢”,也就是最小堆更容易命中缓存。libev使用更高效的4-fork堆。为了简化实现,我使用了二叉堆来实现定时器功能。3、为什么要用连接池和内存池?A。上面说了,为了更好的利用资源,减少内存碎片,减少频繁申请和销毁内存的开销。测试我写了一个简单的Echo类来测试时间堆、连接池和进程池。然后测试http_conn。最后,将模块组合起来进行测试。最大的体会就是在多模块编程的时候,一定要进行单元测试,对应的模块没问题后,才能组合起来测试。同时,在代码完成后,编写相应类的接口和功能描述,然后自己review代码,这也是一个很重要的错误检测方法。最奇怪的错误通常是最愚蠢的错误的结果。例子:我在调用epoll_ctl(intepollfd,intoption,intfd,structepoll_event*evlist)的函数中替换了option和fd参数位置,导致epoll_ctl一直失败。调试了一天,终于发现是参数位置写错了,但是其他地方的调用位置都写对了。调试工具:使用GDB进行调试,使用valgrind进行内存泄漏检测。A。因为我申请了很多内存却没有释放,放到了内存池和连接池中,所以一块内存还是可以到达的,但是当进程结束的时候,会被操作系统回收,所以就是不是真正的记忆。只有申请了一块内存,丢了指针,才是真正的内存泄漏。当然,目前还没有进行压力测试,下一步计划进行压力测试。到目前为止,仅测试了200个连接。第一个缺点是只支持get方法,不支持动态内容。可以增加配置文件的读取,而不是通过启动时设置的参数日志系统。目前只是简单封装了printf,调试时打开,不调试时关闭。真正的在线服务器会需要一个高效的日志系统,同时又不影响运行。可修改性。比如在不重启服务器的情况下,为用户提供不用的功能,比如动态修改进程数,动态修改并发限制等。模块化设计。仍然需要尽量减少模块之间的耦合。Conn类虽然只需要两个函数接口,但实际上内存池的管理是由Conn来完成的,是否可以将内存池交给连接池来管理。还有一个定时器设计。目前只适用于连接超时事件。要把server进程当成daemon进程等等。。。第一个收获一定是增加了自己的编码能力。加深了对linux系统api的理解,对linux服务器有了更多的了解。同时也惊叹于各位大师的智慧。我只是一个站在巨人肩膀上重新发明轮子的小人物。想太多是没有用的,先考虑实现,再考虑性能。在编写代码之前考虑太多是没有意义的。话不多说,给我看看代码。代码审查的重要性!即使您检查自己的代码,也可以发现一些明显的错误。文档文档文档!记录自己的实现并组织自己的api将帮助您自己思考和编码。学习使用GDB和valgrind,并学习如何编写makefile。参考资料:感谢,感叹大牛的智慧。在编码的路上,我们还需要继续努力。《linux多线程服务端编程》《深入理解计算机系统》《Linx/unix编程手册》《linux高性能服务器编程》《深入理解Ngix模块开发与架构解析》