TinywebServer代码详解– 定时器处理非活动连接-上(7)

该blog内容转自:最新版Web服务器项目详解 - 07 定时器处理非活动连接(上)

部分内容参考自:爱编程的大丙(点击跳转)

该blog对上述内容进行补充(在本人的角度)

结合此前记录的blog一起学习:牛客WebServer项目实战(点击跳转)

原项目地址(点击跳转)

博主添加注释后项目地址(点击跳转)



一、基础知识

1.定时器定时方法

本项目中,服务器主循环为每一个http连接创建一个定时器,并对每个连接进行定时。另外,利用升序时间链表容器将所有定时器串联起来,若主循环接收到定时通知,则在链表中依次执行定时任务

Linux下提供的三种定时方法

  • socket选项SO_RECVTIMEOSO_SNDTIMEO
  • SIGALRM信号
  • I/O复用系统调用的超时参数

三种方法没有一劳永逸的应用场景,也没有绝对的优劣。由于项目中使用的是SIGALRM信号,这里仅对其进行介绍,另外两种方法可以查阅游双的Linux高性能服务器编程 第11章 定时器

具体的,利用alarm函数周期性地触发SIGALRM信号,信号处理函数利用管道通知主循环,主循环接收到该信号后对升序链表上所有定时器进行处理,若该段时间内没有交换数据,则将该连接关闭,释放所占用的资源

从上面的简要描述中,可以看出定时器处理非活动连接模块,主要分为两部分,其一为定时方法与信号通知流程,其二为定时器及其容器设计与定时任务的处理



2.相关概念

非活跃

  • 是指客户端(这里是浏览器)与服务器端建立连接后,长时间不交换数据,一直占用服务器端的文件描述符,导致连接资源的浪费

定时事件

  • 是指固定一段时间之后触发某段代码,由该段代码处理一个事件,如从内核事件表删除事件,并关闭文件描述符,释放连接资源

定时器

  • 是指利用结构体或其他形式,将多种定时事件进行封装起来。具体的,这里只涉及一种定时事件,即定期检测非活跃连接,这里将该定时事件与连接资源封装为一个结构体定时器

定时器容器

  • 是指使用某种容器类数据结构,将上述多个定时器组合起来,便于对定时事件统一管理。具体的,项目中使用升序链表将所有定时器串联组织起来


3.本文内容

本篇将介绍定时方法与信号通知流程,具体的涉及到基础API、信号通知流程和代码实现

基础API,描述sigaction结构体、sigaction函数、sigfillset函数、SIGALRM信号、SIGTERM信号、alarm函数、socketpair函数、send函数

信号通知流程,介绍统一事件源和信号处理机制

代码实现,结合代码对信号处理函数的设计与使用进行详解



4.可重入函数

函数的可重入性(reentrancy)指的是在任何时刻,一个函数可以安全地被多个执行线程并发调用,而不会产生不良的副作用。可重入函数通常不依赖于任何共享的、可变的状态(如全局变量或静态变量),并且不使用对其他可重入函数不安全的资源(如动态内存分配和文件操作)

可重入函数特点:

无全局变量或静态变量: 函数内部不使用或修改全局变量或静态变量,或者在修改前对其进行适当的保护

无共享资源: 不依赖或修改共享的资源,除非对其进行适当的同步保护

不调用不可重入函数: 函数内部不调用其他不可重入的函数

避免可重入函数:

避免使用全局或静态变量: 使用局部变量或传递参数来代替

使用线程本地存储(Thread-Local Storage): 在需要全局变量时,使用线程本地存储,使每个线程都有自己独立的变量副本

保护共享资源: 使用同步机制(如互斥锁)保护对共享资源的访问

避免调用不可重入函数: 如果必须调用不可重入函数,可以使用同步机制来保护这些调用



5.管道

管道必须凑齐读写双方才可以实现

管道可以看作为队列。先入先出,队首出,队尾入

在C语言中,使用管道(pipes)进行进程间通信是一种经典方法。管道主要用于进程间传递数据

管道由内核提供,单工,具有自同步机制

自同步机制:管道的自同步机制指的是管道在进程间通信时内置的同步特性,这些特性确保了数据的一致性和顺序性。管道通常是阻塞的,这意味着在特定情况下,读操作和写操作会被阻塞,直到某些条件得到满足。这种行为形成了管道的基本自同步特性。

管道分为:匿名管道与命名管道


(1)匿名管道

匿名管道是一种简单的进程间通信(IPC)机制,主要用于有父子关系的进程间的通信。在Unix和类Unix系统(如Linux)中,匿名管道非常常见。它们是单向的通信方式,通常用于一个进程向另一个进程(具有父子关系的进程)发送数据

基本特性

  • 单向通信:数据只能在一个方向上流动,要么是从父进程到子进程,要么是从子进程到父进程

  • 临时通信通道:管道在使用完毕后会被销毁

  • 数据流:管道中的数据是按照先进先出(FIFO)的顺序流动的

  • 内存中的存在:匿名管道不对应于文件系统中的任何文件,它们仅存在于内存中

创建与使用

  • 在C语言中,可以通过pipe()系统调用来创建一个匿名管道。这个调用会创建管道并返回两个文件描述符:一个用于读(pipefd[0]),另一个用于写(pipefd[1]

(2)pipe()

在Unix和类Unix系统中,pipe() 函数用于创建一个匿名管道,用于在进程间进行通信。这个函数是进程间通信(IPC)的基础工具之一,特别是在需要在有父子关系的进程之间传递数据时。

函数原型

在 C 语言中,pipe() 函数的原型定义在 <unistd.h> 头文件中,如下所示:

1
2
3
#include <unistd.h>

int pipe(int pipefd[2]);

参数

  • pipefd:一个包含两个整型元素的数组。函数成功执行后,pipefd[0] 会被设置为管道的读端,而 pipefd[1] 会被设置为管道的写端

返回值

  • 成功时,返回 0
  • 失败时,返回 -1 并设置 errno 以指示错误原因

使用方式

当你调用 pipe() 函数时,它会创建一个管道并提供两个文件描述符(0端作为读取端口,1端作为写入端口):一个用于读取管道(pipefd[0]),另一个用于写入管道(pipefd[1])。这些文件描述符可以在后续的读写操作中使用

实例程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
/*
* 使用匿名管道实现父子进程间的通信
* 父进程在管道中进行读取(0端--队首进行出队)
* 子进程在管道中进行写入(1端--队尾进行入队)
* */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

#define BUFSIZE 1024

int main() {
pid_t pid;
char buf[BUFSIZE];
int len;

// 创建管道
// pipe成功后,它会创建一个管道并提供两个文件描述符
int pd[2];
if(pipe(pd)<0)
{
// 创建管道失败
perror("pipe()");
exit(1);
}

// 创建子进程
pid = fork();
if(pid < 0)
{
// 创建子进程失败
perror("fork()");
exit(1);
}

// fork函数在子进程中返回0
if(pid == 0)
{
// 子进程的操作
// 0端进行读取--出队
close(pd[1]); // 读管道则不需要写端,将写端关闭
// 将管道中读取的内容放入buf中,长度为BUFSIZE
len = read(pd[0],buf,BUFSIZE);
// 将读取的内容,即buf中的内容写入终端,标准输出文件描述符为1
write(1,buf,len);
// 关闭0端
close(pd[0]);
// 退出子进程
exit(0);
}else{
// 父进程的操作
close(pd[0]); // 写管道则不需要读端,将读端关闭
// 1端进行写入--入队
write(pd[1],"HELLO!\n",7);
// 关闭1端口
close(pd[1]);
// 阻塞回收子进程资源
wait(NULL);
}
// 退出父进程
exit(0);
}



二、基础API

1.信号的概念

信号是软件层面的中断,信号的响应依赖于中断

signal)是一种用于异步通知进程某个事件已经发生的机制。当进程收到一个信号时,它可以有几种不同的反应,包括忽略该信号、执行默认的信号处理动作,或者调用一个用户定义的处理函数



2.信号集

(1)阻塞/未决信号集

PCBProcess Control Block,进程控制块)中有两个非常重要的信号集。一个称之为阻塞信号集,另一个称之为未决信号集。这两个信号集体现在内核中就是两张表。但是操作系统不允许我们直接对这两个信号集进行任何操作,而是需要自定义另外一个集合,借助信号集操作函数来对PCB中的这两个信号集进行修改

未决信号集(pending signal set)是一个与进程相关的数据结构,用于跟踪那些已经被发送但尚未被处理的信号。每个进程都有一个未决信号集,用于记录哪些信号已被送达但还没有执行对应的信号处理程序.当一个信号被发送给进程时,如果该信号被屏蔽(即进程当前不处理此信号),则信号会被添加到未决信号集中.如果进程解除对某个信号的屏蔽,并且该信号在未决信号集中,那么该信号将从未决信号集中移除,并调用相应的信号处理程序.当信号处理程序运行时,处理该信号并从未决信号集中移除该信号

阻塞信号集(blocked signal set)用于管理当前进程不允许处理的信号集合。进程可以通过修改其阻塞信号集来控制哪些信号被屏蔽(阻塞)而不被立即处理。被阻塞的信号不会丢失,而是进入未决信号集,当解除阻塞时这些信号才会被处理

未决信号集与阻塞信号集的关系

信号发送和阻塞

  • 当一个信号发送给进程时,操作系统首先检查该信号是否在进程的阻塞信号集中
  • 如果该信号在阻塞信号集中,则该信号不会被立即处理,而是被添加到未决信号集中

信号解除阻塞

  • 进程解除对某个信号的阻塞时,如果该信号在未决信号集中,操作系统将从未决信号集中移除该信号,并立即处理该信号(即调用相应的信号处理程序或采取默认动作)

信号优先级

  • 在解除阻塞时,如果有多个未决信号,通常信号按照优先级顺序进行处理。不同的操作系统可能有不同的信号优先级定义
  • 信号的 未决 是一种状态,指的是从信号的产生到信号被处理前的这一段时间
  • 信号的 阻塞 是一个开关动作,指的是阻止信号被处理,但不是阻止信号产生

信号的阻塞就是让系统暂时保留信号留待以后发送。由于另外有办法让系统忽略信号,所以一般情况下信号的阻塞只是暂时的,只是为了防止信号打断某些敏感的操作

image-20240301173608721

阻塞信号集和未决信号集在内核中的结构是相同的,它们都是一个整形数组(被封装过的), 一共 128 字节 (int [32] == 1024 bit,4字节一个整形,一个字节8bit,则32*32 = 1024bit),1024个标志位,其中前31个标志位,每一个都对应一个Linux中的标准信号,通过标志位的值来标记当前信号在信号集中的状态

在阻塞信号集中,描述这个信号有没有被阻塞:

  • 默认情况下没有信号是被阻塞的, 因此信号对应的标志位的值为 0
  • 如果某个信号被设置为了阻塞状态, 这个信号对应的标志位 被设置为 1

在未决信号集中, 描述信号是否处于未决状态:

  • 如果这个信号被阻塞了, 不能处理, 这个信号对应的标志位被设置为1
  • 如果这个信号的阻塞被解除了, 未决信号集中的这个信号马上就被处理了, 这个信号对应的标志位值变为0
  • 如果这个信号没有阻塞, 信号产生之后直接被处理, 因此不会在未决信号集中做任何记录

(2)信号集函数

因为用户是不能直接操作内核中的阻塞信号集和未决信号集的,必须要调用系统函数,关于阻塞信号集可以通过系统函数进行读写操作,未决信号集只能对其进行读操作

读/写阻塞信号集的函数:

1
2
3
4
#include <signal.h>
// 使用这个函数修改内核中的阻塞信号集
// sigset_t 被封装之后得到的数据类型, 原型:int[32], 里边一共有1024给标志位, 每一个信号对应一个标志位
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

参数:

  • how

    • SIG_BLOCK:将参数 set 集合中的数据追加到阻塞信号集中

    • SIG_UNBLOCK:将参数 set 集合中的信号在阻塞信号集中解除阻塞

    • SIG_SETMASK:使用参 set 结合中的数据覆盖内核的阻塞信号集数据

  • oldset:通过这个参数将设置之前的阻塞信号集数据传出,如果不需要可以指定为NULL

sigprocmask() 函数有一个 sigset_t类型的参数,对这种类型的数据进行初始化需要调用一些相关的操作函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <signal.h>
// 如果在程序中读写 sigset_t 类型的变量
// 阻塞信号集和未决信号集都存储在 sigset_t 类型的变量中, 这个变量对应一块内存
// 阻塞信号集和未决信号集, 对应的内存中有1024bit = 128字节

// 将set集合中所有的标志位设置为0
int sigemptyset(sigset_t *set);

// 将set集合中所有的标志位设置为1
// 用来将参数set信号集初始化,然后把所有的信号加入到此信号集里
int sigfillset(sigset_t *set);

// 将set集合中某一个信号(signum)对应的标志位设置为1
int sigaddset(sigset_t *set, int signum);

// 将set集合中某一个信号(signum)对应的标志位设置为0
int sigdelset(sigset_t *set, int signum);

// 判断某个信号在集合中对应的标志位到底是0还是1, 如果是0返回0, 如果是1返回1
int sigismember(const sigset_t *set, int signum);

image-20240301181819716

未决信号集不需要程序猿修改, 如果设置了某个信号阻塞, 当这个信号产生之后, 内核会将这个信号的未决状态记录到未决信号集中,当阻塞的信号被解除阻塞, 未决信号集中的信号随之被处理, 内核再次修改未决信号集将该信号的状态修改为递达状态(标志位置0)。因此,写未决信号集的动作都是内核做的,这是一个读未决信号集的操作函数:

1
2
3
4
#include <signal.h>
// 这个函数的参数是传出参数, 传出的内核未决信号集的拷贝
// 读一下这个集合就指定哪个信号是未决状态
int sigpending(sigset_t *set);

使用一张图总结信号集操作函数之间的关系:

image-20240301182103944



3.信号捕捉

Linux中的每个信号产生之后都会有对应的默认处理行为,如果想要忽略这个信号或者修改某些信号的默认行为就需要在程序中捕捉该信号。程序中进行信号捕捉可以看做是一个注册的动作,提前告诉应用程序信号产生之后做什么样的处理,当进程中对应的信号产生了,这个处理动作也就被调用了

(1)signal()

使用 signal() 函数可以捕捉进程中产生的信号,并且修改捕捉到的函数的行为,这个信号的自定义处理动作是一个回调函数,内核通过 signal() 得到这个回调函数的地址,在信号产生之后该函数会被内核调用

1
2
3
4
5
6
7
8
9
10
11
#include <signal.h>

// 将一个 void (*func)(int) 函数指针类型 typedef 为 sighandler_t
typedef void (*sighandler_t)(int);

// 在信号产生之前, 提供一个注册函数, 用来捕捉信号
// - 假设在将来这个信号产生了, 就委托内核进行捕捉, 这个信号的默认动作就不能被执行
// - 执行什么样的处理动作 ==> 在signal函数中指定的处理动作
// - 如果这个信号不产生, 回调函数永远不会被调用
sighandler_t signal(int signum, sighandler_t handler);
// void (*signal(int signum,void (*handler)(int)))(int);

参数:

  • signum 是要捕获的信号
  • handler 是当指定的信号发生时要调用的函数的指针(函数指针,回调函数),或者是以下特定的值:
    • SIG_IGN:忽略该信号
    • SIG_DFL:采取信号的默认动作
    • 该回调函数由内核调用,内核调用回调函数的时候, 会给它传递一个实参,这个实参的值就是捕捉的那个信号值

函数返回值是之前关联到指定信号的处理函数的地址,或者是SIG_IGNSIG_DFL。如果出错,则返回 SIG_ERR

实例程序:

使用 signal() 函数来捕捉定时器产生的信号 SIGALRM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/time.h>
#include <signal.h>

// 定时器信号的处理动作
void doing(int arg)
{
printf("当前捕捉到的信号是: %d\n", arg);
// 打印当前的时间
}

int main()
{
// 注册要捕捉哪一个信号, 执行什么样的处理动作
signal(SIGALRM, doing);
// 1. 调用定时器函数设置定时器函数
struct itimerval newact;
// 3s之后发出第一个定时器信号, 之后每隔1s发出一个定时器信号
newact.it_value.tv_sec = 3;
newact.it_value.tv_usec = 0;
newact.it_interval.tv_sec = 1;
newact.it_interval.tv_usec = 0;
// 这个函数也不是阻塞函数, 函数调用成功, 倒计时开始
// 倒计时过程中程序是继续运行的
setitimer(ITIMER_REAL, &newact, NULL);

// 编写一个业务处理, 阻止当前进程自己结束, 让当前进程被发出的信号杀死
while(1)
{
sleep(1000000);
}

return 0;
}

(2)sigaction()

sigaction() 函数和 signal() 函数的功能是一样的,用于捕捉进程中产生的信号,并将用户自定义的信号行为函数(回调函数)注册给内核,内核在信号产生之后调用这个处理动作。sigaction() 可以看做是 signal() 函数是加强版,函数参数更多更复杂,函数功能也更强一些。函数原型如下:

1
2
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

参数:

  • signum: 要捕捉的信号
  • act: 捕捉到信号之后的处理动作
  • oldact: 上一次调用该函数进行信号捕捉设置的信号处理动作, 该参数一般指定为NULL

返回值:函数调用成功返回0,失败返回-1


(3)sigaction结构体

sigaction函数的参数是一个结构体类型,结构体原型如下:

1
2
3
4
5
6
7
struct sigaction {
void (*sa_handler)(int); // 指向一个函数(回调函数)
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask; // 初始化为空即可, 处理函数执行期间不屏蔽任何信号
int sa_flags; // 0
void (*sa_restorer)(void); //不用
};

结构体成员介绍:

  • sa_handler: 函数指针,指向的函数就是捕捉到的信号的处理动作
  • sa_sigaction: 函数指针,指向的函数就是捕捉到的信号的处理动作,有三个参数,可以获得关于信号更详细的信息
  • sa_mask: 在信号处理函数执行期间, 临时屏蔽某些信号, 将要屏蔽的信号设置到集合中即可
    • 当前处理函数执行完毕, 临时屏蔽自动解除
    • 假设在这个集合中不屏蔽任何信号, 默认也会屏蔽一个(捕捉的信号是谁, 就临时屏蔽谁)
  • sa_flags:使用函数指针指向的函数处理捕捉到的信号(指定信号处理的行为)
    • 0:使用 sa_handler (一般情况下使用这个)
    • SA_SIGINFO:使用 sa_sigaction 成员而不是 sa_handler 作为信号处理函数
    • SA_RESTART,使被信号打断的系统调用自动重新发起
    • SA_NOCLDSTOP,使父进程在它的子进程暂停或继续运行时不会收到 SIGCHLD 信号
    • SA_NOCLDWAIT,使父进程在它的子进程退出时不会收到 SIGCHLD 信号,这时子进程如果退出也不会成为僵尸进程
    • SA_NODEFER,使对信号的屏蔽无效,即在信号处理函数执行期间仍能发出这个信号
    • SA_RESETHAND,信号处理之后重新设置为默认的处理方式
  • sa_restorer: 被废弃的成员

(4)SIGCHLD信号

当子进程退出、暂停、从暂停回复运行的时候,在子进程中会产生一个SIGCHLD信号,并将其发送给父进程,但是父进程收到这个信号之后默认就忽略了。我们可以在父进程中对这个信号加以利用,基于这个信号来回收子进程的资源,因此需要在父进程中捕捉子进程发送过来的这个信号


(5)SIGALRM、SIGTERM信号
1
2
#define SIGALRM  14     // 由alarm系统调用产生timer时钟信号
#define SIGTERM 15 // 终端发送的终止信号


4.常用函数

(1)alarm()

这个函数用于为调用进程设置一个实时闹钟,当这个闹钟超时时,系统会向该进程发送一个SIGALRM信号。它定义在<unistd.h>头文件中

1
2
#include <unistd.h>
unsigned int alarm(unsigned int seconds);

参数

seconds:设置的超时时间,单位为秒。当设置为0时,任何之前设置的闹钟都会被取消

返回值

  • 如果之前已经设置了一个闹钟,且它尚未超时,那么alarm函数会返回之前那个闹钟的剩余时间(秒)。否则,它返回0

alarm设置的时间到达时,操作系统会向进程发送SIGALRM信号。默认情况下,这会终止进程。但你可以使用signalsigaction函数来定义自己的处理函数,从而在收到SIGALRM信号时执行特定的操作

  • SIGALRM默认行为:当进程接收到SIGALRM信号且没有为它设置处理程序时,默认的行为是终止进程
  • SIGALRM自定义处理:你可以使用signalsigaction函数为SIGALRM定义自己的处理程序,这样你就可以在接收到信号时执行特定的操作,而不是让进程终止

(2)setitimer()

setitimer 是用于设置间隔计时器的函数。与之相关的是 getitimer,用于获取当前设置的计时器的值

该函数时间精度很高,而且误差不会累积,因此setitimeralarm函数更好

函数原型

1
2
3
#include <sys/time.h>

int setitimer(int which, const struct itimerval *new_value, struct itimerval *old_value);

参数:

  • which: 指定要设置的计时器的类型,通常可以是:
    • ITIMER_REAL: 实时计时器,当计时器超时时,将发送 SIGALRM 信号。
    • ITIMER_VIRTUAL: 虚拟计时器,仅在进程执行时减少。
    • ITIMER_PROF: 类似于 ITIMER_VIRTUAL,但还包括了当系统执行 behalf 时的时间。
  • new_value: 指定新计时器的间隔和值。
  • old_value: 如果不为NULL,那么在调用之前计时器的当前值将存储在此处

itimerval 结构体的定义大致如下:

1
2
3
4
struct itimerval {
struct timeval it_interval; // 计时器重启时的新值 next value
struct timeval it_value; // 计时器的当前值 current value
};

it_value递减为0时,发信号。并且it_interval会原子化赋值给it_value,重新开始计时

其中,timeval 结构如下:

1
2
3
4
struct timeval {
long tv_sec; // 秒
long tv_usec; // 微秒
};

返回值

  • 0成功,-1表示失败

使用 setitimer 时,还需要处理可能由计时器超时产生的信号,如 SIGALRM

注意事项

定时器类型限制

  • 每种类型的定时器(ITIMER_REALITIMER_VIRTUALITIMER_PROF)在任何时刻都只能有一个活动实例。因此,对于每种类型的定时器,后续的 setitimer 调用会重置该类型的定时器设置
  • 如果你调用 setitimer 设置了一个 ITIMER_REAL 定时器,然后再次调用 setitimer 设置同类型的定时器,第二个调用会覆盖第一个调用的设置


5.其他函数

(1)socketpair()

linux下,使用socketpair函数能够创建一对匿名套接字进行通信,这两个套接字可以用于进程间通信,项目中使用管道通信

1
2
3
4
#include <sys/types.h>
#include <sys/socket.h>

int socketpair(int domain, int type, int protocol, int sv[2]);

参数:

domain:协议族,通常使用 AF_UNIX(表示 Unix 域套接字)。

type:套接字类型,常见的值包括:

  • SOCK_STREAM:面向连接的流套接字
  • SOCK_DGRAM:无连接的数据报套接字

protocol:通常设置为 0,表示使用默认协议

sv[2]:整数数组,用于存储返回的两个套接字文件描述符。

返回值:

成功时返回 0,并在 sv 中存储两个套接字文件描述符

失败时返回 -1,并设置 errno 以指示错误

**socketpair与pipe的区别**:

**socketpair**:

  • 函数创建一对相互连接的套接字。这些套接字之间的通信是全双工的,即数据可以在两个方向上同时流动

**pipe**:

  • 函数创建一个单向数据通道,数据只能从一端(写端)流向另一端(读端)。这意味着使用 pipe 创建的管道仅支持半双工通信,通常需要两个管道来实现双向通信(一个用于每个方向)

实例程序,用于父子进程通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <string.h>

int main() {
int sv[2]; // 存储套接字对
pid_t pid;
char buf[1024];

// 创建套接字对
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1) {
perror("socketpair");
exit(1);
}

// 创建子进程
pid = fork();
if (pid == -1) {
perror("fork");
exit(1);
} else if (pid == 0) { // 子进程
close(sv[0]); // 关闭父进程的套接字

// 读取来自父进程的消息
read(sv[1], buf, sizeof(buf));
printf("Child received: %s\n", buf);

// 发送消息到父进程
const char *msg = "Hello from child";
write(sv[1], msg, strlen(msg) + 1);

close(sv[1]);
} else { // 父进程
close(sv[1]); // 关闭子进程的套接字

// 发送消息到子进程
const char *msg = "Hello from parent";
write(sv[0], msg, strlen(msg) + 1);

// 读取来自子进程的消息
read(sv[0], buf, sizeof(buf));
printf("Parent received: %s\n", buf);

close(sv[0]);
}

return 0;
}

(2)send()

send 函数用于通过套接字发送数据,它通常用于 TCP 套接字(流套接字)。这个函数可以发送数据到连接的另一端

1
2
3
4
#include <sys/types.h>
#include <sys/socket.h>

ssize_t send(int sockfd, const void *buf, size_t len, int flags);

参数:

sockfd:套接字文件描述符,标识一个已连接的套接字

buf:指向要发送的数据缓冲区的指针

len:要发送的数据的长度(字节数)

flags:发送选项,可以是以下值的组合(或 0)

  • MSG_CONFIRM:确认路径有效
  • MSG_DONTROUTE:不使用路由表,直接发送到网络接口
  • MSG_DONTWAIT:非阻塞模式发送数据
  • MSG_EOR:指示数据结束
  • MSG_MORE:数据未完,后续还有数据
  • MSG_NOSIGNAL:如果对等方关闭连接,不发送 SIGPIPE 信号

返回值:

成功时返回发送的字节数

失败时返回 -1,并设置 errno 以指示错误

当套接字发送缓冲区变满时,send通常会阻塞,除非套接字设置为非阻塞模式,当缓冲区变满时,返回EAGAIN或者EWOULDBLOCK错误,此时可以调用select函数来监视何时可以发送数据




三、信号通知流程

Linux下的信号采用的异步处理机制,信号处理函数和当前进程是两条不同的执行路线。具体的,当进程收到信号时,操作系统会中断进程当前的正常流程,转而进入信号处理函数执行操作,完成后再返回中断的地方继续执行

为避免信号竞态现象发生,信号处理期间系统不会再次触发它。所以,为确保该信号不被屏蔽太久,信号处理函数需要尽可能快地执行完毕

一般的信号处理函数需要处理该信号对应的逻辑,当该逻辑比较复杂时,信号处理函数执行时间过长,会导致信号屏蔽太久

这里的解决方案是,信号处理函数仅仅发送信号通知程序主循环,将信号对应的处理逻辑放在程序主循环中,由主循环执行信号对应的逻辑代码



1.统一事件源

统一事件源,是指将信号事件与其他事件一样被处理

具体的,信号处理函数使用管道将信号传递给主循环,信号处理函数往管道的写端写入信号值,主循环则从管道的读端读出信号值,使用I/O复用系统调用来监听管道读端的可读事件,这样信号事件与其他文件描述符都可以通过epoll来监测,从而实现统一处理



2.信号处理机制

每个进程之中,都有存着一个表,里面存着每种信号所代表的含义,内核通过设置表项中每一个位来标识对应的信号类型

image-20240626205942112

信号的接收

  • 收信号的任务是由内核代理的,当内核接收到信号后,会将其放到对应进程的信号队列中,同时向进程发送一个中断,使其陷入内核态。注意,此时信号还只是在队列中,对进程来说暂时是不知道有信号到来的

信号的检测

  • 进程从内核态返回到用户态前进行信号检测
  • 进程在内核态中,从睡眠状态被唤醒的时候进行信号检测
  • 进程陷入内核态后,有两种场景会对信号进行检测:
  • 当发现有新信号时,便会进入下一步,信号的处理

信号的处理

  • ( 内核 )信号处理函数是运行在用户态的,调用处理函数前,内核会将当前内核栈的内容备份拷贝到用户栈上,并且修改指令寄存器(eip)将其指向信号处理函数
  • ( 用户 )接下来进程返回到用户态中,执行相应的信号处理函数
  • ( 内核 )信号处理函数执行完成后,还需要返回内核态,检查是否还有其它信号未处理
  • ( 用户 )如果所有信号都处理完成,就会将内核栈恢复(从用户栈的备份拷贝回来),同时恢复指令寄存器(eip)将其指向中断前的运行位置,最后回到用户态继续执行进程

至此,一个完整的信号处理流程便结束了,如果同时有多个信号到达,上面的处理流程会在第2步和第3步骤间重复进行




四、代码分析

1.信号处理函数

(1)sig_handler()

自定义信号处理函数,创建sigaction结构体变量,设置信号函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
* func:信号处理函数
*/
void Utils::sig_handler(int sig)
{
// 为保证函数的可重入性,保留原来的errno、
// 可重入性表示中断后再次进入该函数,环境变量与之前相同,不会丢失数据
int save_errno = errno;
int msg = sig;

// 将信号值从管道写端写入,传输字符类型,而非整型
// 管道u_pipefd[1]为写端、u_pipefd[0]为读端
send(u_pipefd[1], (char *)&msg, 1, 0);
errno = save_errno;
}

信号处理函数中仅仅通过管道发送信号值,不处理信号对应的逻辑,缩短异步执行时间,减少对主程序的影响

注意:在信号处理函数中保留和恢复 errno 是一个常见的实践,目的是确保信号处理函数不会意外地修改当前执行环境中的错误状态,从而避免对程序的正常运行产生不良影响

errno的作用:

  • errno 是一个全局变量,用于指示最近一次系统调用或库函数调用的错误代码。许多库函数和系统调用在遇到错误时会设置 errno,而程序通常会检查 errno 以确定错误的具体原因

信号处理函数特点:

  • 异步调用:信号处理函数可以在程序执行的任何时刻被调用,可能会打断当前正在执行的代码,包括正在执行的系统调用或库函数
  • 快速返回信号处理函数应该尽量简短,只做最必要的工作,因为它们是异步执行的,会打断程序的正常运行

保留与恢复errno的原因:

防止 errno 被覆盖

  • 当信号处理函数被调用时,当前程序可能正在进行系统调用或库函数调用,并且这些调用可能会设置 errno
  • 如果信号处理函数内部调用了会修改 errno 的系统调用或库函数,那么会覆盖原来的 errno 值,导致信号处理函数返回后,程序无法正确地识别之前的错误状态
  • 理解:若我们在进行系统调用时,设置了errno,并且我们可以根据errno进行一些日志输出,但是在我们还未进行日志输出之前,就捕获到了信号处理函数,对应的信号,产生中断进而调用执行了任务处理函数,在任务处理函数,若存在相关函数将当前程序的errno进行了更改。那么当任务处理函数执行结束,当前程序的errno与之前进行系统调用时,设置的errno就存在差异,我们根据当前errno进行的日志输出就会有问题

确保程序的正确性

  • 程序中的很多部分可能依赖于 errno 的值来判断操作是否成功或失败
  • 信号处理函数在执行过程中修改 errno 可能会干扰这些判断,导致程序行为异常

(2)addsig()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/*
* func:设置被捕捉信号的处理函数
* parameter:
* sig:要捕捉和处理的信号编号
* handler:信号处理函数的指针 其类型为 void(int)
* restart:一个布尔值,指示是否使用 SA_RESTART 标志
* */
void Utils::addsig(int sig, void(*handler)(int), bool restart)
{
// 创建sigaction结构体变量
struct sigaction sa;
// 结构体变量内容清0
memset(&sa, '\0', sizeof(sa));
// 指定信号处理函数
sa.sa_handler = handler;
if (restart)
// SA_RESTART 使被信号打断的系统调用自动重新发起
sa.sa_flags |= SA_RESTART;
// sa.sa_mask 在信号处理函数执行期间, 临时屏蔽某些信号, 将要屏蔽的信号设置到集合中即可
// 假设在这个集合中不屏蔽任何信号, 默认也会屏蔽一个(捕捉的信号是谁, 就临时屏蔽谁)-- 当前就是这种情况
// 用来将参数set信号集初始化,然后把所有的信号加入到此信号集里
sigfillset(&sa.sa_mask);

// 执行sigaction函数
// assert检查sigaction函数是否调用成功,即sigaction返回是否非-1
// 若sigaction函数为-1,则会输出一条错误信息到标准错误流、调用 abort 函数终止程序执行
assert(sigaction(sig, &sa, NULL) != -1);
}

项目中设置信号函数,仅关注SIGTERMSIGALRM两个信号



2.信号通知逻辑

创建管道,其中管道写端写入信号值,管道读端通过I/O复用系统(epoll IO复用)监测读事件

设置信号处理函数SIGALRM(时间到了触发)和SIGTERMkill会触发,Ctrl+C

  • 通过struct sigaction结构体和sigaction函数注册信号捕捉函数
  • 在结构体的handler参数设置信号处理函数,具体的,从管道写端写入信号的名字(需要处理的信号)

利用I/O复用系统监听管道读端文件描述符的可读事件

信息值传递给主循环,主循环再根据接收到的信号值执行目标信号对应的逻辑代码

(1)代码分析
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
// socketpair()函数用于创建一对无名的、相互连接的管道套接字
ret = socketpair(PF_UNIX, SOCK_STREAM, 0, pipefd);
// assert断言函数判断管道套接字是否创建成功
assert(ret != -1);

// 设置管道写端为非阻塞,为什么写端要非阻塞? m_pipefd[1]为写端 m_pipefd[0]为读端
setnonblocking(pipefd[1]);

// 将读端的套接字文件描述符添加到epollfd实例中,监听读事件
// 而且是ET,边沿工作模式下,非阻塞,没有阻塞EPOLLONESHOT事件
addfd(epollfd, pipefd[0], false);

// 传递给主循环的信号值,这里只关注SIGALRM和SIGTERM
// 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;

// 管道读端对应文件描述符发生读事件
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])
{
// 这里是整型
case SIGALRM:
{
timeout = true;
break;
}
case SIGTERM:
{
stop_server = true;
}
}
}
}
}
}
}


(2)进一步分析

为什么管道写端要非阻塞?

send是将信息发送给套接字缓冲区,如果缓冲区满了,则会阻塞,这时候会进一步增加信号处理函数的执行时间,为此,将其修改为非阻塞

没有对非阻塞返回值处理,如果阻塞是不是意味着这一次定时事件失效了?

是的,但定时事件是非必须立即处理的事件,可以允许这样的情况发生

管道传递的是什么类型?switch-case的变量冲突?

信号本身是整型数值,管道中传递的是ASCII码表中整型数值对应的字符

switch的变量一般为字符或整型,当switch的变量为字符时,case中可以是字符,也可以是字符对应的ASCII