目录
🍍函数指针
🌼基础知识
🐙整体概述
🎂基础API
sigaction 结构体
sigaction()
sigfillset()
SIGALRM, SIGTERM 信号
alarm()
socketpair()
send()
📕信号通知流程
统一事件源
信号处理机制
🌼源码分析
信号处理函数
信号通知逻辑
代码
🍍函数指针
函数指针是指向函数的指针变量。在C和C++中,函数被存储在内存中的某个位置,函数指针可以指向这个内存位置,从而允许通过指针间接调用函数。
函数指针的声明方式👇
返回类型 (*指针变量名)(参数列表);
例如,如果有一个函数 void myFunction(int x)
void (*funcPtr)(int); // 声明一个指向函数的指针变量 funcPtr
funcPtr = &myFunction; // 将函数的地址赋给指针变量
// 通过函数指针调用函数
funcPtr(10); // 调用 myFunction(10)
🌼基础知识
非活跃
客户端(即浏览器)与服务器建立连接后,长时间不交换数据,一直占用服务器的文件描述符,导致连接资源浪费
定时事件
固定一段时间后触发某段代码,由该代码处理一个事件
eg: 从内核表删除事件,并关闭文件描述符,释放连接资源
定时器
利用结构体 / 其他形式,将多种 定时事件 封装。
具体的,这里只涉及一种定时事件,即 -- 定期检测非活跃连接,
这里将该定时事件与连接资源,封装为一个 结构体定时器
定时器容器
使用某种容器类 数据结构,将上述多个定时器组合起来,便于对定时事件统一管理
比如,项目中使用 升序链表 ,将所有定时器串联起来
🐙整体概述
简介
- 定时器处理非活动连接是一种机制,用于检测和关闭长时间没有活动的客户端连接
- 定时器会周期性地检查连接的活动状态,并在连接超过一定时间没有任何数据传输时,将其标记为非活动连接并关闭
- 具体来说,当客户端与服务器建立连接后,服务器会启动一个定时器来监视该连接的活动状态
- 每当服务器接收到客户端发送的数据或发送数据给客户端时,定时器会被重置,表示该连接是活动的
- 如果在一段时间内没有任何数据传输,定时器将超时并关闭该连接
TinyWebServer 中,服务器 主循环 为每一个连接创建一个 定时器,并对每个连接进行定时
此外,升序事件链表容器,将所有定时器串联起来
若 主循环 收到定时通知,则在链表中依次执行 定时任务
Linux提供 3 种定时方法👇
- socket 选项 SO_RECVTIMEO 和 SO_SNDTIMEO
- SIGALRM信号
- I / O 复用系统调用的超市参数
TinyWebServer 使用 SIGALRM 信号
具体的,利用 alarm() 函数周期性地触发 SIGALRM 信号,信号处理函数利用 管道 通知 主循环
主循环 接收到信号后,处理 升序链表 的所有定时器
若这段时间内,没有交换数据,则将该连接关闭,释放占用的资源
由此可见,定时器处理 非活动连接模块,分 2 部分:
1)定时方法 与 信号通知流程
2) 定时器 及其 容器设计与定时任务 的处理
总览
定时方法 + 信号通知流程
涉及 基础API,信号通知流程,代码实现
基础API
sigaction 结构体,SIGALRM 信号, SIGTERM 信号
函数👇
sigaction(),sigfillset(),alarm(),socketpair(),send()
信号通知流程
统一事件源 + 信号处理机制
🎂基础API
更好的源码阅读体验~
sigaction 结构体
- sa_handler() -- 函数指针,指向信号处理函数
- sa_sigaction() -- 信号处理函数,3个参数,获得关于信号更详细的信息
- sa_mask -- 信号处理函数执行期间,需要被屏蔽的信号
- sa_falgs -- 信号处理行为
- SA_RESTART:使被信号打断的系统调用,重新自动发起
- SA_NOCLDSTOP:使父进程,在其子进程 暂停/继续运行 时,不会收到 SIGCHLD 信号
- SA_NOCLDWAIT:使父进程,在其子进程 退出 时,不会收到 SIGCHLD 信号,此时 子进程 退出不会成为僵尸进程
- SA_NODEFER:使对信号的屏蔽无效,即,在信号处理函数期间,仍能发出这个信号
- SA_RESETHAND:信号处理之后,重新设置为默认的处理方式
- SA_SIGINFO:使用 sa_sigaction 成员,而不是 sa_handler 作为信号处理函数
- sa_restorer -- 一般不使用
// 定义一个结构体sigaction,用于处理信号
struct sigaction {
// 指向处理信号的函数指针,接受一个int参数
void (*sa_handler)(int);
// 指向处理信号的函数指针,接受3个参数
void (*sa_sigaction)(int, siginfo_t*, void*);
// 信号掩码
sigset_t sa_mask;
// 标志位
int sa_flags;
// 恢复处理程序的函数指针,不接受参数,不返回任何值
void (*sa_restorer)(void);
};
sigaction()
- signum 操作的信号
- act 对信号设置新的处理方式
- oldact 信号旧的处理方式
- 返回值,0 成功,-1 有错误发生
#include<signal.h>
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);
sigfillset()
信号集
👆在 Linux 系统中,通常使用
sigset_t
类型来表示信号集。这个类型通常是一个整数数组,每个元素对应一个信号,用来表示该信号是否被设置
👇将参数 set 信号集 初始化,然后把所有信号加入此信号集
#include<signal.h>
int sigfillset(sigset_t *set);
SIGALRM, SIGTERM 信号
#define SIGALRM 14 // alarm 系统调用 产生timer时钟信号
#define SIGTERM 15 // 终端发送的终止信号
alarm()
设置信号传送闹钟,即,设置信号 SIGALRM,经过参数 seconds 秒后,发送给目前进程
如果未设置信号 SIGALRM 的处理函数,那么 alarm() 默认处理终止进程
#include<unistd.h>
unsigned int alarm(unsigned int seconds);
socketpair()
Linux 下,使用 socketpair() 函数,创建一对套接字进行通信,TinyWebServer 使用管道通信
- domain -- 协议族(PF_UNIX 或 AF_UNIX)
- type -- 协议(SOCK_STREAM 或 SOCK_DGRAM),SOCK_STREAM 基于 TCP,SOCK_DGRAM 基于 UDP
- protocol -- 类型(只能为 0)
- sv[2] -- 套接字柄对,两个句柄作用相同,均可独写双向操作
- 返回结果,0 成功,-1 创建失败
#include<sys/types.h>
#include<sys/socket.h>
int socketpair(int domain, int type, int protocol, int sv[2]);
send()
套接字 发送缓冲区 变满时,send 通常会阻塞,除非 套接字 设置成 非阻塞模式
当缓冲区变满,返回 EAGAIN 或 EWOULDBLOCK 错误,此时可调用 select() 函数,来监视何时可以发送数据
#include<sys/types.h>
#include<sys/socket.h>
ssize_t send(int sockfd, const void *buf,
size_t len, int flags);
📕信号通知流程
Linux 下信号,采用 异步处理机制,信号处理函数 和 当前进程 是 2 条不同的执行路线
异步处理机制👇解释
在Linux系统中,信号就像是一种突然发生的事件通知,比如按下Ctrl+C键发送的中断信号。当这个事件发生时,操作系统会中断当前正在进行的工作,去执行与之对应的处理函数,处理完后再回到原来的工作。
这个处理过程是异步的,也就是说,处理信号的函数和当前正在执行的程序是两条不同的路线。处理函数负责响应信号事件,而当前程序则会在收到信号时被中断,等待处理完毕后再继续执行。这样可以保证及时响应各种突发事件,确保系统的稳定和安全。
当进程收到信号时,操作系统会中断当前的正常流程,转而进入信号处理函数执行操作,完成后再返回中断的地方继续执行
为了避免 信号竞态 的发生,信号处理期间,系统不会再次触发它
所以,为确保该信号不被屏蔽太久,信号处理函数,需要尽可能快地执行完毕
信号处理函数,需要处理该信号对应的逻辑,当该逻辑较复杂,信号处理函数执行时间过长,会导致信号屏蔽太久
解决方案
信号处理函数,仅发送信号,通知程序主循环,将信号对应的处理逻辑,放在主循环中
由主循环执行信号对应的逻辑代码
统一事件源
指的是,将 信号事件 与 其他事件 一样被处理
eg:信号处理函数使用 管道 将信号传递给 主循环
信号处理函数往 管道的写端 写入信号值
主循环则从 管道的读端 读出信号值
使用 I / O 复用系统调用来监听 管道读端 的可读事件
此时,信号事件 与 其他文件描述符 都可以通过 epoll 来监测,从而实现统一处理
信号处理机制
每个进程中,都存在一个表,里面存着每种信号所代表的含义,内核通过设置表项中每一个位,来标识对应的信号类型
- 信号接收
- 接收信号的任务是由 内核 代理的,当内核接收到信号后,会将其放到对应进程的信号队列中,同时向进程发送一个中断,使其陷入内核态。注意,此时信号还只是在队列中,对进程来说,暂时不知道信号到来了
- 信号检测
- 进程从内核态返回到用户态前,进行信号检测
- 进程在内核态中,从睡眠被唤醒时,进行信号检测
- 进程陷入内核态后,有 2 种场景对信号进行检测
- 当发现新信号时,会进入下一步,信号的处理
- 信号处理
- (内核)信号处理函数,运行在 用户态,调用处理函数前,内核会将当前的内核栈的内容备份,拷贝到用户栈上,并修改指令寄存器(eip),将其指向信号处理函数
- (用户)接下来,进程返回用户态,执行相应的信号处理函数
- (内核)信号处理函数 执行完毕后,还要返回内核态,检查是否还有其他信号未处理
- (用户)所有信号处理完后,内核栈就会恢复(从用户栈的备份拷贝),同时恢复指令寄存器(eip),将其指向中断前的运行位置,最后返回用户态,继续执行进程
到此,一个完整的 信号处理流程 就结束了
如果同时有多个信号到达,上面的处理流程,会在第 2 步和第 3 步间循环
🌼源码分析
信号处理函数
自定义信号处理函数,创建 sigaction 结构体变量,设置信号函数
// 信号处理函数
void sig_handler(int sig)
{
// 为保证函数的可重入性,保留原来的errno
// 可重入性:中断后,再次进入该函数,环境变量与之前相同
// 不会丢失数据
int save_errno = errno;
int msg = sig;
// 信号值从 管道写端 写入,传输字符类型,而非 整型
send(pipefd[1], (char*)&msg, 1, 0);
// 原来的 errno 赋值为当前的 errno
errno = save_errno;
}
信号处理函数中,仅通过管道发送信号值,不处理信号对应的逻辑,缩短异步执行时间,减少对主程序影响
void addsig(int sig, void(handler)(int), bool restart = true);
👆解释
sig
:要注册的信号。handler
:信号处理函数。restart
:标志,指示是否在中断系统调用后自动重启该调用。函数声明中使用了函数指针作为参数,这意味着
handler
参数必须是一个指向函数的指针,该函数接受一个整数参数并返回void
类型
// 设置信号函数
void addsig(int sig, void(handler)(int), bool restart = true)
{
// 创建 sigaction 结构体变量
struct sigaction sa;
// 起始地址, 初值,字节数
memeset(&sa, '\0', sizeof(sa));
// 信号处理函数中仅发送 信号值,不做对应逻辑处理
sa.sa_handler = handler; // 函数指针
// 对结构体变量 按位或,SA_RESTART 标志位变1
// 表示需要自动重启
if (restart)
sa.sa_flags |= SA_RESTART;
// 所有信号添加到 信号集 中
sigfillset(&sa.sa_mask);
// 执行 sigaction() 函数
assert(sigaction(sig, &sa, NULL) != -1);
}
项目中设置信号函数,仅关注 SIGTERM 和 SIGALRM 两个信号
信号通知逻辑
- 创建管道,管道写端 写入信号值,管道读端 通过 I / O 复用系统 检测读事件
- 设置信号处理函数 SIGALRM(时间到了触发)和 SIGTERM(kill 会触发,ctrl + c)
- 通过 struct sigaction 结构体 和 sigaction() 函数,注册信号捕捉函数(结构体和函数进行关联)
- 在结构体的 handler 参数,设置信号处理函数,具体的,从 管道写端 写入信号名字
- 利用 I /O 复用系统,监听 管道读端 文件描述符的可读事件
- 信息值 传递给主循环,主循环再根据接收到的 信号值,执行目标信号对应的逻辑代码
代码
// 创建管道套接字
ret = socketpair(PF_UNIX, SOCK_STREAM, 0, pipefd);
assert(ret != -1);
// 设置管道写端非阻塞,为什么写端要非阻塞?
setnonblocking(pipefd[1]);
// 设置管道读端为 ET 非阻塞
addfd(epollfd, pipefd[0], false);
// 传递给主循环的信号值,这里只关注 SIGALRM 和 SIGTERM
addsig(SIGALRM, sig_handler, false);
addsig(SIGTERM, sig_handler, false);
// 循环条件
bool stop__server = false;
// 超时标志
bool timeout = false;
// 每隔 TIMESLOT 时间,触发 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;
// 管道读端 对应的 文件描述符 发生读事件
/*
按位与 & 运算符
判断某个特定的标志位是否在 events[i].events 中被设置
(是否发生了读事件)
如果结果为真,表示发生了读事件
程序将会执行相应的逻辑来处理该事件
*/
if ( (sockfd == pipefd[0]) &&
(events[i].events & EPOLLIN) )
{
int sig;
char signals[1024];
// 从 管道读端 读出信号值,成功-返回字节数,失败-返回-1
// 一般,这里的ret返回值总是 1
// 只有 14,15 两个 ASCII码 对应的字符
ret = recv(pipefd[0], signals, sizeof(signals), 0);
if (ret == -1) // handle the error
continue;
else if (ret == 0)
continue;
else {
// 处理 信号值 对应的逻辑
for (int i = 0; i < ret; ++i) {
switch (signals[i]) { // char
case SIGALRM: // int
{
timeout = true;
break;
}
case SIGTERM:
stop_server = true;
}
}
}
}
}
}
补充解释
ret = recv(pipefd[0], signals, sizeof(signals), 0);
pipefd[0]
是管道的读端文件描述符signals
是一个缓冲区,用于存储接收到的数据sizeof(signals)
表示signals
缓冲区的大小,即要接收的数据的最大字节数0
是可选的参数,用于指定接收数据时的额外标志。在此处,它表示没有任何特殊的处理要求函数执行过程👇
recv()
函数阻塞等待,直到管道读端文件描述符 (pipefd[0]
) 中有数据可读- 一旦有数据可读,
recv()
函数将读取数据并将其存储在signals
缓冲区中recv()
函数返回读取的字节数,并将其赋值给变量ret
,以便后续处理
问题1:为什么 管道写端 要 非阻塞?
send() 将信息发送给套接字缓冲区,如果缓冲区满了,就会阻塞
这时会进一步增加 信号处理函数 的执行时间,为此,将其修改为 非阻塞
👆补充解释
- 代码中的
setnonblocking(pipefd[1])
调用将管道写端设置为非阻塞模式,这意味着写入管道时不会被阻塞,而是立即返回。这是因为在主循环中,当有事件发生时,程序会将相应的数据写入管道以触发 SIGALRM 或 SIGTERM 信号,从而实现定时和停止服务器的功能。如果管道写端是阻塞的,则当数据无法立即写入管道时,程序会被阻塞等待,直到数据写入成功或出现错误。这可能会导致定时事件失效或无法及时停止服务器。因此,将管道写端设置为非阻塞模式是必要的
问题2:没有对 非阻塞返回值 处理,如果 阻塞 是不是意味着这一次 定时事件 失效 了?
对,但 定时事件 不是必须立即处理的事件,可以允许这样的情况发生
👆补充解释
- 代码中的管道写端是非阻塞的,因此如果写入的数据无法立即发送,则会立即返回,并且不会阻塞等待。如果没有处理非阻塞返回值,则会导致写入失败而没有得到及时处理。如果这种情况经常发生,则可能会导致定时事件失效,因为无法在规定的时间内将数据写入管道
- 定时事件不是必须立即处理的事件。在这个例子中,定时事件是通过 SIGALRM 信号实现的,每隔 TIMESLOT 时间就会触发一次该信号。即使写入管道失败,也不会导致 SIGALRM 信号失效,因为下一次定时事件仍然会在规定的时间内触发。因此,在这种情况下,可以允许写入管道失败并且不处理非阻塞返回值
问题3:管道 传递的是什么类型?switch-case 的变量冲突?
信号 本身是 整型,管道中传递的是 ASCII码 表中整型对应的字符
switch 的变量,一般为 字符或整型,当 switch 变量为字符,case 中可以为字符,也可以是字符对应的 ASCII 码