自己动手实现并发 http 服务器

https://blog.csdn.net/ooB0Boo/article/details/106737979

开源链接:https://github.com/ttoobne/bohttpd

(一)整体设计:

        主循环采用多路复用,如果连接到达则集中accept,在已连接描述符上如果有就绪事件,则分配任务给线程池执行I/O操作。已连接描述符和监听描述符都设置为非阻塞。

1. 为什么用线程池?

        如果每个连接到达就 create 一个新的线程,并发量较大的情况下创建和销毁线程的开销不可忽视,并且如果创建的线程数量没有限制,将会消耗大量 CPU 资源用来切换线程。这时如果采用线程池预先分配若干个线程,那么在执行请求时消除了创建销毁线程的开销。线程数量固定,通过维护一个工作队列,有请求到达时将请求放入工作队列,每个处于空闲中的线程都可以取出任务然后执行,执行完毕又阻塞等待新的任务。

2. 为什么采用非阻塞I/O?

        网络中的状况不可预测,如果网络有延迟,表现为当前服务器读取数据还没有读到 EOF 但是接受缓冲区没有数据可读,这时线程会阻塞在 read() 上,如果设置成非阻塞,那么 read() 会返回 EAGAIN ,表示当前接受缓冲区没有数据,还需要下一次读,这时线程可以先保存状态返回去执行其他的任务,如果延迟的数据到达了就又分配给一个线程继续读取然后执行。http 持久连接时情况类似,总的来说就是如果当前描述符没有数据可读,那么不会一直阻塞而是返回先去执行其他任务。

3. 为什么 epoll 要使用边缘触发(ET)?

        如果是水平触发,那么考虑如果当前有一个线程在执行请求,读到一半的时候主循环 epoll wait 返回可读事件,这时正在执行的那个请求也会被返回,因为描述符上有数据还没读完,这时就会导致两个线程同时执行同一个请求。

4. 为什么监听描述符也设置为非阻塞?

        监听描述符上的可读事件可能包含多个连接到达,由于采用了边缘触发,则需要一次性处理完所有的连接请求,由于不能确定具体有多少连接到达,就需要一个循环来处理,那么这时最后一个 accept 就会阻塞,导致无法执行后续的可读请求。那么在设置为非阻塞之后当没有连接到达时,accept 返回 EAGAIN,退出循环继续等待新的事件到达即可。

(二)线程池的实现(threadpool)

        整个机制比较简单,预先分配若干个线程,一个工作队列。添加任务或者线程取任务用一个互斥锁和两个条件变量维持同步。每个工作线程阻塞在 nempty_cond 条件变量上,等待任务队列非空的条件成立就执行对应的任务。添加任务的工作由主循环完成,如果工作队列满,那么阻塞在 nfull_cond 条件变量上,等待任务队列非满就添加任务到任务队列。

(三)定时器(http_timer, rbtree)

        定时器用来清理超时的已连接事件,实现参考 nginx 的红黑树实现,向定时器中添加事件就是向红黑树中添加节点,删除事件就是删除节点,处理超时事件就是不断取出红黑树中的键最小的节点,如果超时就执行超时事件的回调函数。在主循环中每次取出定时器中距离最近的时间,设置为 epoll 的最长阻塞时间,如果 epoll 直到返回都没有事件到达,那么代表需要清理超时事件了,接着执行超时处理即可。由于每个请求执行开始先需要删除定时器,如果是长连接则需要继续添加定时器,而执行请求又是多线程的,那么所有对红黑树进行改动的地方都需要加锁。

        红黑树的实现较为复杂,尤其是删除操作,先类似于普通二叉搜索树那样删除节点,删除完之后还需要考虑是否失去性质,如果性质不满足那么需要进行调整。情况比较复杂,但网上有很多讲解。

(四)解析和处理 http 请求(http_request, http_parse, http)

        这个部分就是根据协议来实现就行,http_request_t 结构体保存读取到的和解析出来的请求信息,由于使用到了非阻塞I/O,有可能读取了一部分之后线程返回了,那么就需要维护一个状态来保存当前解析到了那个步骤,http_parse 就做了这个工作,实现逻辑比较简单,就是不断读取、切换状态。http_request 中解析完的请求行信息会保存在 http_request_t 结构体中,解析完的请求首部会保存在 http_header_t 中,每个首部组成一个链表,链表头在 http_request_t 中保存,解析完的准备发送的首部信息保存在 http_headers_out_t 结构体中。响应请求就是根据解析出来的信息发送指定资源或者发送错误消息。

(五)封装 EPOLL(epoll)

        把有关于 epoll 的操作都封装起来,简化调用。将返回的事件数组封装在内部,申请内存在创建 epoll 描述符的时候就完成,后续操作只需要操作 epoll_t 结构体即可。

(六)日志库(log)

        这个日志库其实是一开始就要先完成的,但是开始的时候出于方便,就随便写了几个宏定义来代替,debug也还能做,也就一直用着了。后来看之前的宏定义只有ERROR和DBG,想着还是需要一个更加完备一点的日志库,所以就实现了一个超简易日志库。定义了六个日志级别,将日志信息输出到标准错误。

(七)配置文件(config)

        支持自定义配置信息,实现了一个简单的配置文件解析,逻辑也比较简单,就是针对每行的语法进行简单分析和出错处理。配置文件的配置信息包含线程池大小、工作队列大小、持久连接的超时时间、端口号、http 主目录、默认文件,还支持以 # 开头的注释。

(八)make

        作为一个项目当然需要一个 Makefile 来方便编译,make 会根据指定的命令编译项目。用法也不难,规定要构建哪个文件、它依赖哪些源文件和头文件,当那些文件有变动时,如何重新构建它。

猜你喜欢

转载自www.cnblogs.com/Bo0oB/p/13372487.html