从0到服务器开发——TinyWebServer(中):https://developer.aliyun.com/article/1508285
返回响应报文
在完成请求报文的解析之后,明确用户想要登录/注册,需要跳转到对应的界面、添加用户名、验证用户等等,并将相应的数据写入相应报文,返回给浏览器,流程图如下:
这个在process_read()中完成请求报文的解析之后,状态机会调用do_request()函数,该函数是处理功能逻辑的。该函数将网站根目录和url文件拼接,然后通过stat判断该文件属性。url,可以将其抽象成ip:port/xxx,xxx通过html文件的action属性(即请求报文)进行设置。m_url为请求报文中解析出的请求资源,以/开头,也就是x,项目中解析后的m_url有8种情况,见do_request()函数,部分代码如下:
//功能逻辑单元 http_conn::HTTP_CODE http_conn::do_request() { strcpy(m_real_file, doc_root); int len = strlen(doc_root); //printf("m_url:%s\n", m_url); const char *p = strrchr(m_url, '/'); //处理cgi if (cgi == 1 && (*(p + 1) == '2' || *(p + 1) == '3')) { //根据标志判断是登录检测还是注册检测 char flag = m_url[1]; char *m_url_real = (char *)malloc(sizeof(char) * 200); strcpy(m_url_real, "/"); strcat(m_url_real, m_url + 2); strncpy(m_real_file + len, m_url_real, FILENAME_LEN - len - 1); free(m_url_real); //将用户名和密码提取出来 //user=123&passwd=123 char name[100], password[100]; int i; for (i = 5; m_string[i] != '&'; ++i) name[i - 5] = m_string[i]; name[i - 5] = '\0'; int j = 0; for (i = i + 10; m_string[i] != '\0'; ++i, ++j) password[j] = m_string[i]; password[j] = '\0'; if (*(p + 1) == '3') { //如果是注册,先检测数据库中是否有重名的 //没有重名的,进行增加数据 ...... if (users.find(name) == users.end()) { m_lock.lock(); int res = mysql_query(mysql, sql_insert); users.insert(pair<string, string>(name, password)); m_lock.unlock(); if (!res) strcpy(m_url, "/log.html"); else strcpy(m_url, "/registerError.html"); } else strcpy(m_url, "/registerError.html"); } ...... }
其中,stat函数用于获取文件的类型、大小等信息;mmap用于将文件等映射到内存,提高访问速度,详见mmap原理;iovec定义向量元素,通常,这个结构用作一个多元素的数组,详见社长微信;writev为聚集写,详见链接;
执行do_request()函数之后,子线程调用process_write()进行响应报文(add_status_line、add_headers等函数)的生成。在生成响应报文的过程中主要调用add_reponse()函数更新m_write_idx和m_write_buf。
值得注意的是,响应报文分为两种,一种是请求文件的存在,通过io向量机制iovec,声明两个iovec,第一个指向m_write_buf,第二个指向mmap的地址m_file_address ;另一种是请求出错,这时候只申请一个iovec,指向m_write_buf 。
其实往响应报文里写的就是服务器中html的文件数据,浏览器端对其进行解析、渲染并显示在浏览器页面上。
另外,用户登录注册的验证逻辑代码在do_request()中,通过对Mysql数据库进行查询或插入,验证、添加用户。
以上就是对注册/登录模块的详细介绍,之后分模块对该项目的线程池、日志、定时器等进行细节探究。
四、线程池
这个部分着重介绍该项目的线程池实现。整体框架如下:
定义
线程池其定义如下:
template <typename T> class threadpool { public: /*thread_number是线程池中线程的数量,max_requests是请求队列中最多允许的、等待处理的请求的数量*/ threadpool(int actor_model, connection_pool *connPool, int thread_number = 8, int max_request = 10000); ~threadpool(); bool append(T *request, int state); bool append_p(T *request); private: /*工作线程运行的函数,它不断从工作队列中取出任务并执行之*/ static void *worker(void *arg);//为什么要用静态成员函数呢-----class specific void run(); private: int m_thread_number; //线程池中的线程数 int m_max_requests; //请求队列中允许的最大请求数 pthread_t *m_threads; //描述线程池的数组,其大小为m_thread_number std::list<T *> m_workqueue; //请求队列 locker m_queuelocker; //保护请求队列的互斥锁 sem m_queuestat; //是否有任务需要处理 connection_pool *m_connPool; //数据库 int m_actor_model; //模型切换(这个切换是指Reactor/Proactor) };
注意到该线程池采用模板编程,这是为了增强其拓展性:各种任务种类都可支持。
线程池需要预先创建一定的线程,其中最重要的API为:
#include <pthread.h> //返回新生成的线程的id int pthread_create (pthread_t *thread_tid,//新生成的线程的id const pthread_attr_t *attr, //指向线程属性的指针,通常设置为NULL void * (*start_routine) (void *), //处理线程函数的地址 void *arg); //start_routine()中的参数
函数原型中的第三个参数,为函数指针,指向处理线程函数的地址。该函数,要求为静态函数。如果处理线程函数为类成员函数时,需要将其设置为静态成员函数(因为类的非静态成员函数有this指针,就跟void*不匹配)。进一步了解请看。
线程池创建
项目中线程池的创建:
threadpool<T>::threadpool( int actor_model, connection_pool *connPool, int thread_number, int max_requests) : m_actor_model(actor_model),m_thread_number(thread_number), m_max_requests(max_requests), m_threads(NULL),m_connPool(connPool) { if (thread_number <= 0 || max_requests <= 0) throw std::exception(); m_threads = new pthread_t[m_thread_number]; //pthread_t是长整型 if (!m_threads) throw std::exception(); for (int i = 0; i < thread_number; ++i) { //创建成功应该返回0,如果线程池在线程创建阶段就失败,那就应该关闭线程池了 if (pthread_create(m_threads + i, NULL, worker, this) != 0) { delete[] m_threads; throw std::exception(); } //主要是将线程属性更改为unjoinable,便于资源的释放,详见PS if (pthread_detach(m_threads[i])) { delete[] m_threads; throw std::exception(); } } }
PS:注意到创建一个线程之后需要调用pthread_detech(),原因在于: linux线程有两种状态joinable状态和unjoinable状态。
如果线程是joinable状态,当线程函数自己退出都不会释放线程所占用堆栈和线程描述符(总计8K多)。只有当调用了pthread_join,主线程阻塞等待子线程结束,然后回收子线程资源。
而unjoinable属性可以在pthread_create时指定,或在线程创建后在线程中pthread_detach(pthread_detach()即主线程与子线程分离,子线程结束后,资源自动回收), 如:pthread_detach(pthread_self()),将状态改为unjoinable状态,确保资源的释放。其实简单的说就是在线程函数头加上 pthread_detach(pthread_self())的话,线程状态改变,在函数尾部直接 pthread_exit线程就会自动退出。省去了给线程擦屁股的麻烦。
加入请求队列
当epoll检测到端口有事件激活时,即将该事件放入请求队列中(注意互斥),等待工作线程处理:
//proactor模式下的请求入队 bool threadpool<T>::append_p(T *request) { m_queuelocker.lock(); if (m_workqueue.size() >= m_max_requests) { m_queuelocker.unlock(); return false; } m_workqueue.push_back(request); m_queuelocker.unlock(); m_queuestat.post(); return true; }
上面是Proactor模式下的任务请求入队,不知道Reactor和Proactor模式的请回到第一章、IO复用。本项目所实现的是一个基于半同步/半反应堆式的并发结构,以Proactor模式为例的工作流程如下:
- 主线程充当异步线程,负责监听所有socket上的事件
- 若有新请求到来,主线程接收之以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件
- 如果连接socket上有读写事件发生,主线程从socket上接收数据,并将数据封装成请求对象插入到请求队列中
- 所有工作线程睡眠在请求队列上,当有任务到来时,通过竞争(如互斥锁)获得任务的接管权
即是如下原理:(图片来自)
线程处理
在建立线程池时,调用pthread_create指向了worker()静态成员函数,而worker()内部调用run()。
//工作线程:pthread_create时就调用了它 template <typename T> void *threadpool<T>::worker(void *arg) { //调用时 *arg是this! //所以该操作其实是获取threadpool对象地址 threadpool *pool = (threadpool *)arg; //线程池中每一个线程创建时都会调用run(),睡眠在队列中 pool->run(); return pool; }
run()函数其实也可以看做是一个回环事件,一直等待m_queuestat()信号变量post,即新任务进入请求队列,这时请求队列中取出一个任务进行处理:
//线程池中的所有线程都睡眠,等待请求队列中新增任务 void threadpool<T>::run() { while (true) { m_queuestat.wait(); m_queuelocker.lock(); if (m_workqueue.empty()) { m_queuelocker.unlock(); continue; } T *request = m_workqueue.front(); m_workqueue.pop_front(); m_queuelocker.unlock(); if (!request) continue; // ......线程开始进行任务处理 } }
**注:**每调用一次pthread_create就会调用一次run(),因为每个线程是相互独立的,都睡眠在工作队列上,仅当信号变量更新才会唤醒进行任务的竞争。
五、定时器
原理解析
如果一个客户端与服务器长时间连接,并且不进行数据的交互,这个连接就没有存在的意义还占据了服务器的资源。在这种情况下,服务器就需要一种手段检测无意义的连接,并对这些连接进行处理。
除了处理非活跃的连接之外,服务器还有一些定时事件,比如关闭文件描述符等。
为实现这些功能,服务器就需要为各事件分配一个定时器。
该项目使用SIGALRM信号来实现定时器,首先每一个定时事件都处于一个升序链表上,通过alarm()函数周期性触发SIGALRM信号,而后信号回调函数利用管道通知主循环,主循环接收到信号之后对升序链表上的定时器进行处理:若一定时间内无数据交换则关闭连接。
有关这一部分的底层API解析,建议直接阅读我所添加的源码注释或者参考社长的文章。
代码与框图
由于定时器部分在源代码中调用比较复杂,可以结合该框图进行理解:
文字性叙述:
服务器首先创建定时器容器链表,然后用统一事件源将异常事件,读写事件和信号事件统一处理,根据不同事件的对应逻辑使用定时器。
具体的,浏览器与服务器连接时,创建该连接对应的定时器,并将该定时器添加到定时器容器链表上;
处理异常事件时,执行定时事件,服务器关闭连接,从链表上移除对应定时器;
处理定时信号时,将定时标志设置为true,以便执行定时器处理函数;
处理读/写事件时,若某连接上发生读事件或某连接给浏览器发送数据,将对应定时器向后移动,否则,执行定时事件。
六、日志系统
为了记录服务器的运行状态,错误信息,访问数据的文件等,需要建立一个日志系统。本项目中,使用单例模式创建日志系统。该部分的框图如下(原图来自社长):
由上图可知,该系统同步和异步两种写入方式。
其中异步写入方式,将生产者-消费者模型封装为阻塞队列,创建一个写线程,工作线程将要写的内容push进队列,写线程从队列中取出内容,写入日志文件。对于同步写入方式,直接格式化输出内容,将信息写入日志文件。
该系统可以实现按天分类,超行分类功能。
这个部分建议直接结合源码,从log.h入手进行阅读,先查看同步写入的方式,在进行异步写入日志以及阻塞队列的阅读。
或是参考社长的:日志系统。
七、其他
数据库连接池
该项目在处理用户连接时,采用的是:每一个HTTP连接获取一个数据库连接,获取其中的用户账号密码进行对比(有点损耗资源,实际场景下肯定不是这么做的),而后再释放该数据库连接。
那为什么要创建数据库连接池呢?
据库访问的一般流程为:当系统需要访问数据库时,先系统创建数据库连接,完成数据库操作,然后系统断开数据库连接。——从中可以看出,若系统需要频繁访问数据库,则需要频繁创建和断开数据库连接,而创建数据库连接是一个很耗时的操作,也容易对数据库造成安全隐患。
在程序初始化的时候,集中创建多个数据库连接,并把他们集中管理,供程序使用,可以保证较快的数据库读写速度,更加安全可靠。
其实数据库连接池跟线程池的思想基本是一致的。
在该项目中不仅实现了数据库连接池,还将数据库连接的获取与释放通过RAII机制封装,避免手动释放。
这一部分比较易懂,建议直接阅读源码。
封装同步类
为便于实现同步类的RAII机制,该项目在pthread库的基础上进行了封装,实现了类似于C++11的mutex、condition_variable。
可以阅读文件夹lock中的源码进行这方面的学习。
参考资料
(主要资料)社长本人的文章:
https://github.com/qinguoyi/TinyWebServer#%E5%BA%96%E4%B8%81%E8%A7%A3%E7%89%9B
(力荐)一文读懂TinyWebServer:
https://book.douban.com/subject/24722611/
https://baike.baidu.com/item/WEB%E6%9C%8D%E5%8A%A1%E5%99%A8/8390210?fr=aladdin
主流服务器对比:
https://www.cnblogs.com/sammyliu/articles/4392269.html
https://blog.csdn.net/u010066903/article/details/52827297/
项目地址: