目录
🎂前言
🌼定时器设计
😀容器设计
🌼任务处理函数
🚩源码分析(定时器的使用)
🎂前言
PS:写博客 -- 巩固项目基础过程中,可以通过 GPT + Google + cppreference 的方式,加深对某些 专业名词 的理解
定时器处理 非活动连接 模块,分 2 部分👇
1)定时方法 与 信号通知流程
2)定时器 及其 容器设计,定时任务的处理
本博客,介绍第 2 部分,具体涉及,定时器设计 / 容器设计 / 定时任务处理函数 / 使用定时器
定时器设计
将 连接资源 和 定时事件 封装起来,具体包括:连接资源,超时时间,回调函数。
(回调函数 指向 定时事件)
定时器容器设计
将多个 定时器 串联起来统一处理,具体涉及 升序链表 的设计
定时任务处理函数
该函数封装在 容器类 中,具体的,函数遍历 升序链表容器,根据超时时间,处理对应的定时器
源码分析(定时器的使用)
🌼定时器设计
TinyWebServer 将 连接资源 / 定时事件 / 超时时间,封装为 定时器类,具体👇
- 连接资源 包括 -- 客户端套接字地址 + 文件描述符 + 定时器
- 定时事件 为 回调函数,将其封装起来,由用户自定义,这里删除 非活动 socket 上的注册事件,并关闭
- 定时超时时间 = 浏览器和服务器连接时刻 + 固定时间(TIMESLOT)
由此可见,定时器使用 绝对时间 作为 超时值,这里 alarm 设置为 5 秒,连接超时 15秒
// 连接资源 结构体成员 要用到 定时器类
// 前向声明
class util_timer;// 连接资源
struct client_data
{// 客户端 socket 地址sockaddr_in address;// socket 文件描述符int sockfd;// 定时器util_timer* timer; // 指向 util_timer
};// 定时器类
class util_timer
{
public:// 构造函数 成员初始化列表util_timer() : prev(NULL), next(NULL) {}public:// 超时时间time_t expire;// 回调函数void (*cb_func)(client_data*); // 函数指针// 连接资源client_data* user_data; // 指向 client_data// 前向定时器util_timer* prev;// 后继定时器util_timer* next;
};
关于上面代码中的 回调函数👆
1)回调函数
cb_func()
的作用是用来处理定时器超时事件时的特定操作2)具体地,当一个定时器到达设定的超时时间时,会调用与该定时器相关联的回调函数
cb_func()
,并将相应的client_data
结构体指针 作为参数传递给回调函数
3)在这个场景下,每个定时器实例都会有一个超时时间
expire
和一个 回调函数指针cb_func()
,用于指定在定时器超时时需要执行的特定操作4)通过回调函数的机制,可以实现在超时时执行不同的处理逻辑,例如关闭连接、释放资源、发送心跳包等操作
5)结合
client_data
结构体和util_timer
类,我们可以看到在client_data
结构体中包含了一个指向util_timer
类实例 的指针timer
,而util_timer
类中则包含了一个指向client_data
结构体的指针user_data
6)这种设计是为了在定时器超时时可以同时对连接资源和定时器进行操作,实现更灵活和完善的定时器管理功能
7)通过回调函数机制,在定时器超时时可以执行特定的操作,同时利用连接资源结构体和定时器类之间的关联,可以更方便地管理和操作定时器及其关联的连接资源
👇定时事件,具体的,从 内核事件表 删除时间,关闭 文件描述符,释放连接资源
// 定时器 回调函数
void cb_func(client_data *user_data)
{// 删除 非活动连接 在 socket 上的注册事件epoll_ctl(epollfd, EPOLL_CTL_DEL, user_data->sockfd, 0);assert(user_data);// 关闭文件描述符close(user_data->sockfd);// 减少连接数http_conn::m_user_count--;
}
👆解释
- 调用
epoll_ctl()
函数时,可以向 epoll 实例(由 epollfd 指定)添加、修改或删除一个指定文件描述符的事件assert()
是一个宏,用于在代码中插入一条条件判断语句
如果条件为假,则终止程序运行并打印错误信息
在这段代码中,assert(user_data)
用于确保 user_data 指针不为空,即确保传入的 client_data 结构体指针有效
如果 user_data 为空(即断言失败),程序将终止执行,同时会输出错误信息- 总结:回调函数
cb_func
主要实现了对非活动连接的事件删除、文件描述符关闭、连接数减少的操作
😀容器设计
- webserver 中,定时器容器 -- 带头尾节点的升序双向链表,具体为每个连接创建一个定时器,将其添加到链表中,并按照 超时时间 升序排列
- 执行定时任务时,将到期的定时器从链表删除
实现上,主要涉及:双向链表的插入,删除
其中 添加定时器的时间复杂度 O(n),删除定时器 O(1)
升序双向链表逻辑👇
- 创建头尾节点,其中头尾节点没有意义,仅方便调整
- add_timer() 函数,将目标定时器添加到链表,升序添加
- 链表只有头尾节点,直接插入
- 否则,升序插入
- adjust_timer() 函数,当定时任务发生变化,调整定时器在链表的位置
- 客户端在设定时间内,有数据收发,仅当前时刻,对该定时器重新设定时间,这里只是,往后延长超时时间
- 被调整的目标定时器在尾部,或定时器新的超时值,仍小于下一个定时器的超时,不用调整
- 否则将定时器从链表取出,重新插入链表
- del_timer() 函数,将超时的定时器从链表删除
- 常规双向链表删除节点
// 定时器容器类
class sort_timer_lst
{
public:sort_timer_lst() : head(NULL), tail(NULL) {}// 常规销毁链表~sort_timer_lst(){util_timer* tmp = head;while (tmp){head = tmp->next;delete tmp;tmp = head;}}// 添加定时器,内部调用私有成员 add_timer// 公有 add_timer() 是外部调用接口,只有一个参数,// 即要插入的 定时器对象void add_timer( util_timer* timer){if (!timer) return;if (!head) {head = tail = timer;return;}// 如果新的定时器 超时时间,小于当前头节点// 将当前定时器节点作为 头节点if (timer->expire < head->expire) {timer->next = head;head->prev = timer;head = timer;return;}// 否则调用私有成员,调整内部节点add_timer(timer, head);// ↑ 公有 add_timer() 调用了 私有 add_timer() }// 调整定时器,任务发生变化时,调整定时器在链表的位置void adjust_timer(util_timer* timer){if (!timer) return;util_timer* tmp = timer->next;// 被调整的定时器在 链表尾部// 定时器超时值,仍小于下一个定时器超时值,不调整if (!tmp || (timer->expire < tmp->expire))return// 被调整定时器,是链表头节点,将定时器取出,重新插入if (timer == head) {head = head->next;head->prev = NULL;timer->next = NULL;add_timer(timer, head);}// 被调整定时器在内部,将定时器取出,重新插入else {timer->prev->next = timer->next;timer->next->prev = timer->prev;add_timer(timer, timer->next);}}// 删除定时器void del_timer(util_timer* timer) {if (!timer) return;// 链表中只有一个定时器,直接删除if (timer == head || timer == tail) {delete timer;head = NULL;tail == NULL;return;}// 被删除定时器为头节点if (timer == head) {head = head->next;head->next->prev = NULL;delete timer;return;}// 为尾节点if (timer == tail) {tail = tail->prev;tail->next = NULL;delete timer;return;}// 被删除定时器在链表内部,常规删除// 符合前面情况的都 return 了,这里就不用 iftimer->next->prev = timer->prev;timer->prev->next = timer->next;delete timer;}private:// 私有成员,被公有成员 add_timer 和 adjust_timer 调用// 用于调整链表内部节点,// 即 私有 add_timer() 是类内部调用的函数,接受 2 个参数// 将 timer 插入到 lst_head 之后合适位置// timer -- 要插入的定时器void add_timer(util_timer* timer, util_timer* lst_head){util_timer* prev = lst_head; // 插入的起点util_timer* tmp = prev->next; // 起点下一位置// 遍历当前节点之后的链表,按照超时时间,升序插入while (tmp) {// 由于公有 add_timer() // 此时timer的超时时间,一定 > lst_headif (timer->expire < tmp->expire) {// 插入 prev 和 prev->next 之间prev->next = timer;timer->next = tmp;tmp->prev = timer;timer->prev;break; // 插入完毕}// prev 和 prev_next 一直往后移动prev = tmp; tmp = tmp->next;}// 如果此时 prev 为尾节点,tmp 为 空// timer 超时时间 > 尾节点 超时时间if (!tmp) { // timer需要作为新的尾节点prev->next = timer;timer->prev = prev;timer->next = NULL;tail = timer;}}private:util_timer* head; // 头util_timer* tail; // 尾
};
🌼任务处理函数
使用统一事件源,SIGALRM 信号每次被触发,主循环中调用一次 定时任务处理函数,处理链表容器到期的定时器
具体逻辑👇
- 遍历定时器升序链表容器,从头节点开始依次处理每个定时器,直到遇到尚未到期的定时器
- 若当前时间小于定时器超时时间,跳出循环,即未找到到期的定时器
- 若当前时间大于定时器超时时间,即找到了到期的定时器,执行回调函数,然后将它从链表删除,并继续遍历
// 定时任务处理函数
void tick()
{if (!head) return;// 获取当前时间time_t cur = time(NULL);util_timer* tmp = head;// 遍历定时器链表while (tmp) {// 链表容器为升序排列// 当前时间小于定时器超时时间,后面定时器也未到期if (cur < tmp->expire)break;// 当前定时器到期,则调用回调函数,执行定时事件tmp->cb_func(tmp->user_data);// 将处理后的定时器,从链表容器删除,并重置头节点head = tmp->next;if (head) head->prev = NULL;delete tmp;tmp = head;}
}
🚩源码分析(定时器的使用)
服务器首先创建 定时器容器链表,然后用统一事件源,将 异常事件 / 读写事件 / 信号事件
统一处理,根据不同事件的对应逻辑使用定时器
具体的👇
- 浏览器与服务器连接时,创建该连接 对应的定时器,并将该定时器添加到链表上
- 处理 异常事件 时,执行定时事件,服务器关闭连接,从链表移除对应定时器
- 处理 定时信号 时,将定时标志设置为 true
- 处理 读事件 时,若某连接上发生 读事件,将对应定时器 向后移动,否则,执行定时事件
- 处理 写事件 时,若 服务器 通过某连接给 浏览器 发送数据,将对应定时器向后移动,否则,执行定时事件
// 定时处理任务,重新定时以不断触发 SIGALRM 信号
void timer_handler()
{timer_lst.tick(); // 处理链表上到期的定时器alarm(TIMESLOT); // 重新定时以不断触发 SIGALRM 信号
}// 创建定时器容器链表
static sort_timer_lst timer_lst;// 创建连接资源数组
client_data *users_timer = new client_data[MAX_FD]; // 修正变量名错误// 超时默认为 false
bool timeout = false;// alarm定时触发 SIGALRM 信号
alarm(TIMESLOT);while (!stop_server)
{int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);if (number < 0 && errno != EINTR)break;for (int i = 0; i < number; i++) {int sockfd = events[i].data.fd;// 处理新到的客户连接if (sockfd == listenfd) {// 初始化客户端连接地址struct sockaddr_in client_address;socklen_t client_addrlength = sizeof(client_address);// 该连接分配的文件描述符int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);// 初始化该连接对应的连接资源users_timer[connfd].address = client_address;users_timer[connfd].sockfd = connfd;// 创建定时器临时变量util_timer *timer = new util_timer;// 设置定时器对应的连接资源timer->user_data = &users_timer[connfd];// 设置回调函数timer->cb_func = cb_func;time_t cur = time(NULL);// 设置绝对超时时间timer->expire = cur + 3*TIMESLOT; // 修正变量名错误// 创建该连接对应的定时器,初始化为前述临时变量users_timer[connfd].timer = timer;// 将该定时器添加到链表timer_lst.add_timer(timer);}// 处理异常事件else if (events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR)){// 服务器关闭连接,移除对应的定时器cb_func(&users_timer[sockfd]);util_timer *timer = users_timer[sockfd].timer;if (timer)timer_lst.del_timer(timer);}// 处理定时器信号else if ((sockfd == pipefd[0]) && (events[i].events & EPOLLIN)){// 接收到 SIGALRM 信号,timeout 设置为 truetimeout = true;}// 处理客户连接上接收到的数据else if (events[i].events & EPOLLIN) {// 创建定时器临时变量,将该连接对应的定时器取出来util_timer *timer = users_timer[sockfd].timer;if (users[sockfd].read_once()){// 若监测到读事件,将该事件放入请求队列pool->append(users + sockfd);// 若有数据传输,则将定时器往后延迟 3 个单位// 对其在链表上的位置进行调整if (timer) {time_t cur = time(NULL);timer->expire = cur + 3*TIMESLOT;timer_lst.adjust_timer(timer);}}else {// 服务器关闭连接,移除对应的定时器cb_func(&users_timer[sockfd]);if (timer)timer_lst.del_timer(timer);}}else if (events[i].events & EPOLLOUT){util_timer *timer = users_timer[sockfd].timer;if (users[sockfd].write()) {// 若有数据传输,则将定时器往后延迟 3 个单位// 并调整新定时器,在链表的位置if (timer) {time_t cur = time(NULL);timer->expire = cur + 3*TIMESLOT;timer_lst.adjust_timer(timer);}}else {// 服务器关闭连接,移除对应定时器cb_func(&users_timer[sockfd]);if (timer)timer_lst.del_timer(timer);}}}// 处理定时器为 非必须 事件,收到信号不是马上处理// 完成读写事件后,再进行处理if (timeout) {timer_handler(); // 处理超时定时器事件timeout = false;}
}
有人提出,连接资源中的 address 是不是没用?👇
虽然 webserver 中没用到,但是实际应用程序中,可能需要使用客户端的地址信息
比如:记录客户端的 IP 地址用于 日志 记录;用户追踪
此外,
还可根据 IP 地址进行一些业务逻辑,比如:限制异地登陆,区分不同地区用户。
以上,address 提供了一个扩展点,所以还是有潜在价值的