好久没来更新了,从上次更新到现在忙了很多事情,而且忙了半天也不知道自己忙了些啥,很丧。做的为数不多的比较扎实的事情就是仿照着GitHub上的开源项目写了一个WebServer,目前已经完成了一个可以传输静态页面的Demo,还需要优化以及添加一些别的功能。先记录一下,免得以后忘了。
基本框架
程序采用的是“半同步半反应堆式”的线程池,也就是:
主线程负责监听文件描述符上发生的事件,并针对事件进行IO。
子线程负责处理逻辑,解析收到的HTTP报文,准备好要发送的数据,并通知主线程可以进行IO了。
具体实现时,程序分为了若干个模块:线程同步模块、日志模块、MySQL连接池模块、HTTP处理模块、mmap模块、线程池模块、epoll监听模块。
线程同步
是整个项目最简单的一部分,也是上手完成的第一个部分,就是把Linux底层提供的一些线程同步机制进行了封装,使用的时候会更加方便一些。把init()
和destroy()
分别写进了构造函数和析构函数,随取随用,让代码更加简洁,应该也属于所谓的RAII
机制。
RAII机制:资源获取就是初始化(Resource Acquisition Is Initialization),这是一种管理资源的方式,C++保证任何情况下,已构造的对象最终都会销毁,即它的析构函数一定会被调用。所以只要把资源的获取和释放分别封装进一个类的构造函数和析构函数,就可以保证资源不会发生“泄露”。
声明:
互斥量
1 | class locker |
条件变量
1 | class cond |
信号量
1 | class sem |
条件变量与信号量的区别:个人理解中,信号量sem本质上是一个计数的功能,post就是让信号量加一,wait就是让信号量减一,post和wait调用先后问题不大;而条件变量cond则必须先wait再signal或broadcast,否则就会发生丢失信号的现象,特定情况下甚至会造成死锁。另外,信号量可以在进程之间共享,而条件变量只能在进程内部、线程之间共享。
日志
日志系统实现了同步写日志和异步写日志两种模式。同步写日志就是调用了写日志函数之后就等着写完了才退出函数,异步写日志则是在日志系统中维护了一个“生产者-消费者”模型,写日志的函数只负责生产任务,而真正的写入工作则由异步线程完成。为了实现这个“生产者-消费者”模型,我编写了一个阻塞队列,就是把STL中带有的队列模板进行进一步封装,让它的每一个操作都是线程安全的。这样生产者只管push,消费者只管pop就行了。
回到日志系统本身。日志类是在单例模式下编写的。所谓的单例模式,就是把这个类的构造函数和析构函数都私有化,只有类自己能调用,程序的其它部分不能创建新的对象;然后在这个类里静态地内置一个自己,保证整个程序只有这一个对象,并可通过类的静态方法来获取这个唯一对象。
代码摘录:
1 | class Log{ |
MySQL连接池
由于建立和释放MySQL连接是非常消耗资源的,所以用到了临时建立连接太低效了,我们可以维护一个连接池,在初始化时建立一些连接,程序在需要时就可以直接从池里获得一个连接,用完还回来就行。同样采用单例的编写模式。连接池模块有两个类,一个是连接池本体,一个是RAII
的接口,接口初始化时从池里获取连接,析构时自动归还。
连接池本体声明:
1 | class connection_pool |
HTTP处理
这个模块暂时只实现了传输静态页面,图片、文件的传输还有待研究。类里有一个process()
函数,用于处理输入、获得待写输出。process()
函数由线程池异步调用,处理好再通知主线程进行一个数据的写。
声明:
1 | class http{ |
这里用到了iovec
,就是分布式IO,可以把需要IO的部分(用起始地址和偏移量表示)存进一个向量里,然后调用readv()
或writev()
一次性读写多个缓冲区,相当优雅。
mmap
对底层的mmap API进行了封装。mmap还没用明白,所以暂时还没用进程序里,不过理论上可以大幅度提高程序的IO性能。
mmap是一种内存映射文件的方式。普通的文件读写,需要先open(),把文件在读取进操作系统内核的内存里,然后再read()和write(),将内核的内存拷贝进用户态的内存里,造成了效率的浪费;而使用mmap,则可以让内核和用户共享一块内存,省去了第二步的拷贝,更加高效。
声明:
1 | class mmap_file |
输入一个文件路径,获取这个文件mmap后的起始内存和偏移量。不过好像iovec不能直接用,还没来得及研究和调试。
线程池模块
复用了日志模块的阻塞队列,将已经完成了读操作的http对象加入到工作队列中,由线程池维护的若干个线程竞争获取任务,然后在子线程中异步地完成处理,并注册对应文件描述符上的写事件,通知主线程进行一个写。同样采用单例模式编写。
声明:
1 | class threadpool |
EPOLL 监听
EPOLL是一种IO多路复用的模型,类似的模型还有SELECT和POLL,但他俩都不如EPOLL好使。在我写的项目里就以单例模式维护了一个EPOLL的监听池,封装了一些API,方便程序直接调用。
IO多路复用
简单地理解就是在一个线程同时监听一大堆文件描述符是否有可写事件和可读事件发生,这样程序可以同时处理来自多个事件流中的事件。
SELECT、POLL和EPOLL的区别
最早被写出来的IO多路复用模型是SELECT,实现思路也非常耿直:就是维护一个数组,调用wait()
的时候就去遍历一遍这个数组,看看每个文件描述符是否有事件发生,如果有的话就拎出来告诉调用者。因为用的是数组,所以监听的文件描述符数量有上限,大概是1024个。
而POLL所作出的改进是用链表代替了普通的数组,突破了1024个的上限。但由于还是采用遍历的方式来判断是否有事件发生,依然是线性的复杂度。
然后EPOLL就闪亮登场了。EPOLL底层维护了一棵红黑树和一个链表,红黑树用于保存文件描述符,链表用于保存已经发生了事件的文件描述符。与前两代模型不同,EPOLL不是主动地去遍历,而是给每个文件描述符设置一个“回调函数”,当有事件发生时就自动调用,把文件描述符存进链表中,这样就可以不用遍历了,调用wait()
时只需要返回那个链表就行,时间复杂度是常量级的。