有关定时器的详细内容,见10. 定时器
简而言之,web 服务器需要处理定时事件,如定期检测一个客户连接的活动状态。服务器程序通常管理着众多定时事件,有效地组织这些定时事件,使其在预期的时间被触发且不影响服务器的主要逻辑,对于服务器的性能有至关重要的影响。为此,我们要将每个定时事件分别封装成定时器,并使用某种容器类数据结构,如链表、排序链表、时间轮,将所有定时器串联起来,以实现对定时事件的统一管理。
项目中的定时器实现在 timer
文件夹中:
timer
|------ lst_timer.cpp
|------ lst_timer.h
|------ README.md
主要功能是实现了一个基于双向链表的定时器管理系统(sort_timer_lst
类)。下面具体分析一下每个类的作用,lst_timer.h
中主要定义了三个类:
lst_timer.h
|------ util_timer
|------ sort_timer_lst
|------ Utils
util_timer
类,主要实现了一个定时器类,类的内部成员包括一个定时器的过期时间,指向前一个定时器的指针和指向后一个定时器的指针;sort_timer_lst
,这是一个双向链表,用于管理定时器,链表的每一个节点代表一个定时器,定时器按照到期时间升序排序,类的操作包括:添加、删除定时器,调整定时器在链表中的位置,tick()
函数:用于删除到期的定时器节点;Utils
类,用于管理定时任务、信号处理等,在服务器中承担了辅助管理功能,为高效的事件驱动服务器提供了一些重要的工具和支持,比如两个静态成员变量:
static int *u_pipefd;
static int u_epollfd;
前者是一个用于存储管道的文件描述符数组,在信号处理函数 sig_handler
中,将接收到的信号写入 u_pipefd[1]
,而事件主循环监听管道另一端u_pipefd[0]
的可读事件,以异步处理信号;
后者用于存储 epoll
实例的文件描述符, 整个服务器中共享一个 epoll
实例,通过 u_epollfd
监控所有连接的事件。
除此之外还有一些其他函数,比如:
addfd
用于将fd
注册到内核事件表;sig_handler
用于将接收到的信号写入管道;addsig
用于设置sig
信号的信号处理函数;timer_handler
用于调用定时器链表的tick()
,删除到期的定时器,并重置定时器信号。
epoll
实例和事件存储
这个应该是在下一节描述的内容,但是因为这章的内容涉及到了这部分,所以就提前写了。
当调用 epoll_create
或 epoll_create1
创建 epoll
实例时,系统会分配一个内核空间的数据结构,这一数据结构被称为内核事件表(epoll
实例),用于管理和存储事件。
使用 epoll_ctl
函数可以将文件描述符和对应的事件注册到 epoll
实例中。例如,将套接字 fd
注册到 epoll
实例 epollfd
时,可以设置需要监听的事件类型,如 EPOLLIN
(可读)、EPOLLOUT
(可写)、EPOLLERR
(错误)等。
epoll
的核心机制是事件触发。当文件描述符的状态发生(边缘触发)变化且与注册的事件匹配时,epoll
会将该事件标记为就绪。调用 epoll_wait
时,epoll
会返回所有当前就绪的事件,供应用程序处理。
在
epoll
实例中会存储所有注册的文件描述符和事件,并在事件就绪时通过epoll_wait
返回。这样的设计让epoll
能够高效地处理高并发连接,并且仅在事件发生时返回,避免了不必要的轮询。
为什么要重置定时器?
/* 处理定时任务,并重新设置定时器,以确保定时信号(SIGALRM)能够持续触发 */
void Utils::timer_handler() {
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGALRM);
sigprocmask(SIG_BLOCK, &mask, NULL);
m_timer_lst.tick(); /* 调用定时器链表 m_timer_lst 的 tick 方法,处理所有到期的定时器 */
alarm(m_TIMESLOT); /* 使用 alarm 函数重新设置定时器,使得下一个 SIGALRM 信号在 m_TIMESLOT 秒后触发 */
sigprocmask(SIG_UNBLOCK, &mask, NULL);
}
在 Utils::timer_handler
中重新设置定时器的目的是持续触发 SIGALRM
信号,
- 定时任务的周期性执行:
timer_handler
中的m_timer_lst.tick()
用于处理到期的定时器任务。通过调用tick
,可以遍历定时器链表,检查并执行所有到期的定时器任务。- 每当
SIGALRM
信号触发时,都会调用timer_handler
进行一次定时任务处理。如果不重新设置定时器,SIGALRM
信号只会触发一次,定时任务将无法周期性执行。
- 使用
alarm(m_TIMESLOT)
来重新设置定时器:
alarm(m_TIMESLOT)
将使SIGALRM
信号在m_TIMESLOT
秒后再次触发。- 通过每次在
timer_handler
中重新调用alarm
,实现了一个循环定时机制:定时器会在每m_TIMESLOT
秒触发SIGALRM
信号,系统捕获到信号后,timer_handler
会被调用,再次处理到期任务并重新设定定时器。
前置声明
class Utils;
/* 定时器回调函数 */
void cb_func(client_data *user_data) {
if (!user_data) {
std::cerr << "cb_func received null user_data" << std::endl;
return;
}
/* 从 epoll 中删除文件描述符 */
if (epoll_ctl(Utils::u_epollfd, EPOLL_CTL_DEL, user_data->sockfd, nullptr) == -1) {
std::cerr << "epoll_ctl DEL failed: " << strerror(errno) << std::endl;
}
assert(user_data);
if (close(user_data->sockfd) == -1) {
std::cerr << "close failed: " << strerror(errno) << std::endl;
}
http_conn::m_user_count--; /* 减少用户计数 */
}
在函数 cb_func
上方声明 class Utils;
的目的是为了向编译器前置声明Utils
类。这样做的原因是:Utils::u_epollfd
是 Utils
的静态成员,静态成员的访问不依赖于 Utils
类的实例,因此可以直接通过 Utils::u_epollfd
访问这个静态变量,前置声明通常用于减少编译依赖。前置声明告诉编译器,Utils
是一个类,但不需要立即包含其完整的定义。这样做可以减少编译时间。
shutdown
和 close
的区别
shutdown
用于关闭连接的传输方向,而不直接释放文件描述符。可以通过设置不同的标志来选择关闭的方向。close
是释放文件描述符的操作,无论它是套接字、文件还是其他资源。当文件描述符的引用计数降到零时,close
会彻底终止套接字连接。
统一事件源
什么是统一事件源?
统一事件源是一种设计模式/机制,用于集中处理不同类型的事件,如 I/O 事件、信号事件、定时器事件等。在统一事件源的设计中,所有类型的事件(如网络连接、定时任务、信号等)都被封装成文件描述符,并被统一注册到 epoll
等 I/O 多路复用接口上。这样,程序只需监听 epoll
的事件,不论是网络连接、信号还是定时任务等事件,均可以通过 epoll_wait
等接口统一管理,避免了单独为每种事件类型编写独立的处理逻辑。
统一事件源的好处?
通过将不同类型的事件统一到同一个事件处理机制(通常是epoll
),可以简化事件的管理和处理流程,从而提升系统的性能和可维护性。
改进
Utils::timer_handler()
函数中,如果SIGALRM
信号在tick
方法执行期间再次触发,可能导致定时器处理函数被重入,导致数据竞争或逻辑错误。
改进: 在处理定时器前,临时屏蔽SIGALRM
信号,确保tick
方法不会被中断。
void Utils::timer_handler() {
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGALRM);
sigprocmask(SIG_BLOCK, &mask, NULL);
m_timer_lst.tick(); /* 调用定时器链表 m_timer_lst 的 tick 方法,处理所有到期的定时器 */
alarm(m_TIMESLOT); /* 使用 alarm 函数重新设置定时器,使得下一个 SIGALRM 信号在 m_TIMESLOT 秒后触发 */
sigprocmask(SIG_UNBLOCK, &mask, NULL);
}
show_error
函数用于向客户端发送错误信息,并随后关闭连接,send
可能会因为网络问题而失败(返回 -1),添加检查可以帮助诊断发送失败的情况。在调用close
之前,可以使用shutdown(connfd, SHUT_WR)
先优雅关闭连接的写传输方向,然后再关闭连接,避免数据丢失并确保错误信息成功发送。
/* 向客户端发送错误信息,并关闭相应的连接 */
void Utils::show_error(int connfd, const char *info)
{
ssize_t bytes_sent = send(connfd, info, strlen(info), 0);
if (bytes_sent == -1) {
std::cerr << "send failed: " << strerror(errno) << std::endl;
}
/* 同时关闭连接的读和写两个方向 */
if (shutdown(connfd, SHUT_RDWR) == -1) {
std::cerr << "shutdown failed: " << strerror(errno) << std::endl;
}
if (close(connfd) == -1) {
std::cerr << "close failed: " << strerror(errno) << std::endl;
}
}
这样做的好处有:
- 如果服务器在
send
发送数据后立刻调用close
,由于网络传输存在延迟,客户端接收数据的速度可能较慢。服务器在客户端完成接收之前关闭连接,剩余数据可能会丢失。使用shutdown(connfd, SHUT_WR)
,服务器可以等待客户端确认已接收完所有数据,避免因直接close
导致的传输中断。 - TCP 连接是双向的,
shutdown
可以确保双方同步地进行连接关闭的过程。而直接close
时,连接会立即释放,可能对方仍认为连接有效,导致“半关闭”状态。使用shutdown(connfd, SHUT_WR)
表示服务器不再发送数据,但可以接收数据。这样,服务器可以等待客户端的FIN
,完成四次握手的完整关闭,确保数据安全传输。