8. 信号

信号是由用户、系统、进程发送给目标进程的信息,以通知目标进程某个状态的改变或系统异常。Linux信号可由以下条件产生:

  1. 对于前台进程,用户可通过输入特殊终端字符来给它发送信号,如输入Ctrl+C通常会给进程发送一个中断信号。
  2. 系统异常。如浮点异常或非法内存段访问。
  3. 系统状态变化。如alarm定时器到期将引起SIGALRM信号。
  4. 运行kill命令或调用kill函数。

服务器程序必须处理(或至少忽略)一些常见信号,以免异常终止。

1.Linux 信号概述

0.全部信号

1.信号的四要素

信号使用前应确定它的四要素

  1. 编号
  2. 名称
  3. 事件
  4. 默认处理动作

2.信号的编号

kill -l 获取全部信号

  1. SIGHUP
    本信号在用户终端连接(正常或非正常)结束时发出, 通常是在终端的控制进程结束时, 通知同一session内的各个作业, 这时它们与控制终端不再关联。
    登录Linux时,系统会分配给登录用户一个终端(Session)。在这个终端运行的所有程序,包括前台进程组和后台进程组,一般都属于这个Session。当用户退出Linux登录时,前台进程组和后台有对终端输出的进程将会收到SIGHUP信号。这个信号的默认操作为终止进程,因此前台进程组和后台有终端输出的进程就会中止。不过可以捕获这个信号,比如wget能捕获SIGHUP信号,并忽略它,这样就算退出了Linux登录,wget也能继续下载。

此外,对于与终端脱离关系的守护进程,这个信号用于通知它重新读取配置文件。

  1. SIGINT

程序终止(interrupt)信号, 在用户键入INTR字符(通常是Ctrl-C)时发出,用于通知前台进程组终止进程。

  1. SIGQUIT

和SIGINT类似, 但由QUIT字符(通常是Ctrl-)来控制. 进程在因收到SIGQUIT退出时会产生core文件, 在这个意义上类似于一个程序错误信号。

  1. SIGILL

执行了非法指令. 通常是因为可执行文件本身出现错误, 或者试图执行数据段. 堆栈溢出时也有可能产生这个信号。

  1. SIGTRAP

由断点指令或其它trap指令产生. 由debugger使用。

  1. SIGABRT

调用abort函数生成的信号。

  1. SIGBUS

非法地址, 包括内存地址对齐(alignment)出错。比如访问一个四个字长的整数, 但其地址不是4的倍数。它与SIGSEGV的区别在于后者是由于对合法存储地址的非法访问触发的(如访问不属于自己存储空间或只读存储空间)。

  1. SIGFPE

在发生致命的算术运算错误时发出. 不仅包括浮点运算错误, 还包括溢出及除数为0等其它所有的算术的错误。

  1. SIGKILL

用来立即结束程序的运行. 本信号不能被阻塞、处理和忽略。如果管理员发现某个进程终止不了,可尝试发送这个信号。

  1. SIGUSR1

留给用户使用

  1. SIGSEGV

试图访问未分配给自己的内存, 或试图往没有写权限的内存地址写数据.

  1. SIGUSR2

留给用户使用

  1. SIGPIPE

管道破裂。这个信号通常在进程间通信产生,比如采用FIFO(管道)通信的两个进程,读管道没打开或者意外终止就往管道写,写进程会收到SIGPIPE信号。此外用Socket通信的两个进程,写进程在写Socket的时候,读进程已经终止。

  1. SIGALRM

时钟定时信号, 计算的是实际的时间或时钟时间. alarm函数使用该信号.

  1. SIGTERM

程序结束(terminate)信号, 与SIGKILL不同的是该信号可以被阻塞和处理。通常用来要求程序自己正常退出,shell命令kill缺省产生这个信号。如果进程终止不了,我们才会尝试SIGKILL。

  1. SIGCHLD

子进程结束时, 父进程会收到这个信号。

如果父进程没有处理这个信号,也没有等待(wait)子进程,子进程虽然终止,但是还会在内核进程表中占有表项,这时的子进程称为僵尸进程。这种情况我们应该避免(父进程或者忽略SIGCHILD信号,或者捕捉它,或者wait它派生的子进程,或者父进程先终止,这时子进程的终止自动由init进程来接管)。

  1. SIGCONT

让一个停止(stopped)的进程继续执行. 本信号不能被阻塞. 可以用一个handler来让程序在由stopped状态变为继续执行时完成特定的工作. 例如, 重新显示提示符

  1. SIGSTOP

停止(stopped)进程的执行. 注意它和terminate以及interrupt的区别:该进程还未结束, 只是暂停执行. 本信号不能被阻塞, 处理或忽略.

  1. SIGTSTP

停止进程的运行, 但该信号可以被处理和忽略. 用户键入SUSP字符时(通常是Ctrl-Z)发出这个信号

  1. SIGTTIN

当后台作业要从用户终端读数据时, 该作业中的所有进程会收到SIGTTIN信号. 缺省时这些进程会停止执行.

  1. SIGTTOU

类似于SIGTTIN, 但在写终端(或修改终端模式)时收到.

  1. SIGURG

有”紧急”数据或out-of-band数据到达socket时产生.

  1. SIGXCPU

超过CPU时间资源限制. 这个限制可以由getrlimit/setrlimit来读取/改变。

  1. SIGXFSZ

当进程企图扩大文件以至于超过文件大小资源限制。

  1. SIGVTALRM

虚拟时钟信号. 类似于SIGALRM, 但是计算的是该进程占用的CPU时间.

  1. SIGPROF

类似于SIGALRM/SIGVTALRM, 但包括该进程用的CPU时间以及系统调用的时间.

  1. SIGWINCH

窗口大小改变时发出.

  1. SIGIO

文件描述符准备就绪, 可以开始进行输入/输出操作.

  1. SIGPWR

Power failure

  1. SIGSYS

非法的系统调用。

在以上列出的信号中,

程序不可捕获、阻塞或忽略的信号有:SIGKILL,SIGSTOP

不能恢复至默认动作的信号有:SIGILL,SIGTRAP

默认会导致进程流产的信号有:

SIGABRT,SIGBUS,SIGFPE,SIGILL,SIGIOT,SIGQUIT,SIGSEGV,SIGTRAP,SIGXCPU,SIGXFSZ

默认会导致进程退出的信号有:

SIGALRM,SIGHUP,SIGINT,SIGKILL,SIGPIPE,SIGPOLL,SIGPROF,SIGSYS,SIGTERM,SIGUSR1,SIGUSR2,SIGVTALRM

默认会导致进程停止的信号有:SIGSTOP,SIGTSTP,SIGTTIN,SIGTTOU

默认进程忽略的信号有:SIGCHLD,SIGPWR,SIGURG,SIGWINCH

1.信号的概念

信号在我们的生活中随处可见,如:古代战争中摔杯为号;现代战争中的信号弹;体育比赛中使用的信号枪.
他们都有共性:

1.简单 2.不能携带大量信息 3. 满足某个特设条件才发送,不能想发就发。

信号是信息的载体,Linux/UNIX环境下,古老、经典的通信方式,现下依然是主要的通信手段。

Unix早期版本就提供了信号机制,但不可靠,信号可能丢失。Berkeley 和 AT&T都对信号模型做了更改,增加
了可靠信号机制。但彼此不兼容。POSIX.1对可靠信号例程进行了标准化。

image-20241213194356275

阻塞信号集和未决信号集都在PCB进程控制块里面

2.信号的机制

A给B发送信号,B收到信号之前执行自己的代码,收到信号后,不管执行到程序的什么位置,都要暂停运行,
去处理信号,处理完毕再继续执行。与硬件中断类似一一异步模式。但信号是软件层面上实现的中断,早期常被称
为“软中断”。

信号的特质:由于信号是通过软件方法实现,其实现手段导致信号有很强的延时性。但对于用户来说,这个延
迟时间非常短,不易察觉。

每个进程收到的所有信号,都是由内核负责发送的,内核处理。

3.发送信号 kill 函数

Linux下,一个进程给其他进程发送信号的API是kill函数:

1
2
3
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);

把信号sig参数发送给目标进程。目标进程用pid参数指定,其可能的取值及含义见下表:

img

Linux定义的信号值都大于0,如果sig参数传为0,则kill函数不发送任何信号,此时可用来检测目标进程或进程组是否存在,因为检查工作总是在信号发送前执行,但这种检测方式不可靠,一方面是由于进程 PID的回绕,导致被检测的PID不是我们期望的进程的PID,另一方面,这种检测方法不是原子操作。

kill函数成功时返回0,失败则返回-1并设置errno,以下是几种可能的errno

img

4.信号处理方式

信号处理函数的原型为:

1
2
#incldue <signal.h>
typedef void(* _sighandler_t) (int);

信号处理只带有一个整型参数,该参数用来指示信号类型。信号处理函数应该是可重入的,否则容易引发竞态条件,因此在信号处理函数中严禁调用不安全的函数。

除了用户自定义信号处理函数外,bits/signum.h头文件中还定义了信号的另外两种处理方式:

1
2
3
#include <bits/signum.h>
#define SIG_DFL ((_sighandler_t) 0)
#define SIG_IGN ((_sighandler_t) 1)

SIG_IGN表示忽略目标信号,SIG_DFL表示使用信号的默认处理方式。

信号的默认处理方式有以下几种:

  • 结束进程(Term)
  • 忽略信号(Ign)
  • 结束进程并生成核心转储文件(Core)
  • 暂停进程(Stop)
  • 继续进程(Cont)。

可重入函数

可重入函数(Reentrant Function)是指一个可以被中断并在中断后能安全地被再次调用的函数,而不会出现任何不正确的行为或数据损坏。这种函数特别适用于多线程环境或者中断处理程序中,因为在这些场景下,函数可能会被并发地调用。

要实现可重入,函数必须满足以下条件:

  1. 不使用静态或全局变量:函数内部不应依赖静态变量或全局变量,因为多个线程可能会同时修改这些变量,导致数据不一致或竞争条件。
  2. 不调用非可重入函数:函数内部不应调用其他非可重入函数,否则会继承那些非可重入函数的特性。
  3. 局部数据处理:函数只能使用局部变量来存储临时数据,因为局部变量在每次调用时都是独立的,不会与其他调用互相干扰。
  4. 不依赖共享资源:函数应避免依赖共享资源(如文件、设备),或者在访问这些资源时应采取适当的同步措施(如使用信号量)。
  5. 避免使用动态内存分配或释放:函数不应频繁地进行动态内存分配或释放,因为这些操作可能引发不可预见的行为,尤其是在嵌入式或实时系统中。

举个例子:

1
2
3
int sum(int a, int b) {
return a + b;
}

这个简单的函数是可重入的,因为它只使用局部变量,不依赖全局状态,也不调用非可重入的函数。

5.Linux 信号

Linux的可用信号都定义在bits/signum.h头文件中,其中包括标准信号和POSIX实时信号,我们仅讨论标准信号,如下表所示。

我们主要关注几个信号SIGHUP,SIGPIPE,SIGURG,SIGALRM,SIGCHLD

img

img

6.中断系统调用

如果程序在执行处于阻塞状态的系统调用时收到信号,且我们为该信号设置了信号处理函数,则默认情况下该系统调用会被中断,并将errno设置为EINTR。我们可使用sigaction函数为信号设置SA_RESTART标志以自动重启被该信号中断的系统调用。

2.信号处理函数(信号捕捉函数)

信号你随便来,来了我就给你抓住,我让你干啥你就只能干啥

1.signal 系统调用

signal 系统调用用于为一个信号设置处理函数。

注册了一个捕捉函数,给内核说,这个信号来了你给我抓住(用户自己是抓不了信号的)

如果没有注册就是没有给内核说,内核自然不帮你抓

1
2
#include <signal.h>
_sighandler_t signal(int sig, _sighandler_t _handler);
参数
  • sig:指定要捕获的信号类型。
  • _handler:是 _sighandler_t 类型的函数指针,用于指定 sig 的处理函数。
返回值
  • signal函数成功时返回一个函数指针,该函数指针的类型为_sighandler_t。它是sig参数信号在前一次调用signal函数时传入的函数指针,或是sig信号的默认处理函数指针SIG_DEF(如果是第一次调用 signal)。
  • signal系统调用出错时返回SIG_ERR,并设置errno

练习

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<error.h>
#include<pthread.h>
#include<sys/types.h>
#include<signal.h>


void set_catch(int signo)
{
printf("catch you %d!!\n",signo);
}

int main(int argc,char* argv[])
{
signal(SIGINT,set_catch);
while(1){

}
return 0;
}

image-20241213204116873

2.sigaction 系统调用

设置信号处理函数的更健壮的接口是sigaction系统调用:

1
2
#include <signal.h>
int sigaction(int sig, const struct sigaction* act, struct sigaction* oact);
参数
  • sig:指定要捕获的信号类型。
  • act:指定新的信号处理方式。
  • oact:输出信号先前的处理方式(如果不为 NULL)。
  • sigaction结构体:描述了信号处理的细节。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct sigaction{
#ifdef __USE_POSIX199309
union{
_sighandler_t sa_handler;
void (*sa_sigaction) (int, siginfo_t*, void*);
}
_sigaction_handler;
#define sa_handler __sigaction_handler.sa_handler
#define sa_sigaction __sigaction_handler.sa_sigaction
#else
_sighandler_t sa_handler; /* 指定信号处理函数 */
#endif
_sigset sa_mask; /* 设置(增加)进程的信号掩码 */
int sa_flags; /* 设置程序接收到信号时的行为 ,默认属性(默认值0或者咱们自己赋值0)会屏蔽此次sigaction捕捉的信号,否则的话在处理过程中可能再来一个同样的信号,又重新调用了处理函数*/
void (*sa_restorer) (void); /* 已经过时 */
}

img

返回值
  • sigaction函数成功返回0,失败返回-1并设置errno

3.重点

1.结构体中的mask作用时间是信号捕捉函数运行期间

而信号集里面的阻塞屏蔽集是从程序开始到程序结束

2.sa_flags

设置程序接收到信号时的行为 ,默认属性(默认值0或者咱们自己赋值0)会屏蔽此次sigaction捕捉的信号,否则的话在处理过程中可能再来一个同样的信号,又会重新调用处理函数

也就是捕捉函数执行期间,本信号自动被屏蔽

3.捕捉函数执行期间,被屏蔽信号多次发送,解除屏蔽后之处理一次

比如sa_mask屏蔽2,3 2先到达,调用2的处理函数,2的处理函数执行过程中收到了5个3信号,那么2结束的时候不会调5次3信号,就只调用1次

4.练习

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
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<error.h>
#include<pthread.h>
#include<sys/types.h>
#include<signal.h>


void sig_catch(int signo)
{
printf("catch you %d!!\n",signo);
}

int main(int argc,char* argv[])
{
struct sigaction act,oldact;
act.sa_handler=sig_catch;
sigemptyset(&(act.sa_mask));
act.sa_flags=0;

sigaction(SIGINT,&act,&oldact);
while(1){

}
return 0;
}

image-20241213205915493

5.内核实现信号捕捉过程

image-20241213212051210

总体结构

图中分为两大部分:

  1. User Mode(用户模式):位于图的上半部分,代表用户程序的执行环境。
  2. Kernel Mode(内核模式):位于图的下半部分,代表操作系统内核的执行环境。

流程步骤

  1. 用户模式下的主函数(int main ())
    • 在用户模式下,程序从main函数开始执行。
    • 当执行主控制流程的某条指令时,可能会因为中断、异常或系统调用而进入内核。
  2. 进入内核(do_signal ())
    • 一旦进入内核,内核会处理异常并准备返回用户模式。
    • 在内核处理完异常准备返回用户模式之前,会先处理当前进程中可以递送的信号。
  3. 信号处理(void sighandler (int))
    • 如果信号的处理动作是自定义的信号处理函数,则会执行这个自定义的信号处理函数。
    • 这里需要注意的是,信号处理函数执行完后,并不会直接返回主控制流程,而是再次进入内核。
  4. 再次进入内核(sys_sigreturn ())
    • 信号处理函数返回时,会执行特殊的系统调用sys_sigreturn,再次进入内核。
  5. 返回用户模式(继续执行)
    • 内核处理完sys_sigreturn后,会返回用户模式,从主控制流程中上次被中断的地方继续向下执行。

6.中断系统调用

image-20241213213310473

7.借助信号捕捉回收子进程

SIGCHLD信号

只要状态发生一点点变化就马上通知父进程

SIGCHLD产生的条件

  • 只要子进程的状态发生变化就会产生SIGCHLD信号

1.子进程终止时(最常用)

2.子进程接收到SIGSTOP信号停止时

3.子进程处在停止状态,接受SIGCONT后唤醒时

借助SIGCHLD回收子进程

核心思路:

  1. 循环创建多个子进程
  2. 在注册信号捕捉前加上信号屏蔽集去屏蔽SIGCHLD信号,以免信号捕捉未注册完 子进程就死亡了
  3. 父进程中注册sigaction信号捕捉(回调函数中循环回收子进程)
  4. 取消屏蔽SIGCHLD信号
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
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>

//回调函数
void catch_child(int signo)
{
pid_t wpid;

while((wpid = wait(NULL)) != -1)
{
printf("----------catch%d\n",wpid);
}

return ;
}

int main(void)
{

pid_t pid;

//阻塞
sigset_t set;
sigemptyset(&set);
sigaddset(&set,SIGCHLD);
sigprocmask(SIG_BLOCK,&set,NULL);

//循环创建子进程
int i;
for(i=0; i<5; i++)
if((pid=fork()) == 0)
break;

if(5 == i)
{
//父进程中信号捕捉
struct sigaction act;
act.sa_handler = catch_child;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGCHLD,&act,NULL);

//解除阻塞
sigprocmask(SIG_UNBLOCK,&set,NULL);

printf("我是父进程,pid是%d\n",getpid());

while(1);
}
else
{
printf("我是子进程,pid是%d\n",getpid());
}
return 0;
}

重点解释

注释的阻塞和解除阻塞是什么意思

目的是为了避免在我父进程注册捕捉函数之前子进程就死掉,导致子进程没有被回收出现了僵尸进程的情况

阻塞的意思就是先把SIGCHLD屏蔽,放到未决信号集,将信号挂起,等我父进程注册完了再解除对该信号的屏蔽

只要这期间有过这个信号,就会捕捉,但是信号不会排队,多个SIGCHLD过来我们只会调用一次处理函数。不过我们的处理函数里面是while循环,也就是说只要还有子进程没有被回收,while就会继续回收直到所有的进程都被回收

3.信号集

1.信号集函数

上一节在sigaction 系统调用提到,sigaction结构体中的sa_mask是信号集sigset_t_sigset_t 的同义词)类型,该类型指定一组信号。

1
2
3
4
5
6
#include <bits/sigset.h>
#define _SIGSET_NWORDS(1024 / (8 * sizeof(unsigned long int)))
typedef struct
{
unsigned long int __val[_SIGSET_NWORDS];
} sigset_t

sigset_t实际是一个长整型(long int)数组,数组的每个元素的每个位表示一个信号,这种定义方式和文件描述符集fd_set类似 文件描述符。Linux提供了以下函数来设置、修改、删除、查询信号集:

1
2
3
4
5
6
#include <signal.h>
int sigemptyset(sigset_t *_set); /* 清空信号集 */
int sigfillset(sigset_t* _set); /* 在信号集中设置所有信号 */
int sigaddset(sigset_t* _set, int _signo); /* 将信号_signo添加到信号集中 */
int sigdelset(sigset_t* _set, int _signo); /* 将信号_signo从信号集中删除 */
int sigismember(_const sigset_t* _set, int _signo); /* 测试_signo是否在信号集中 */

只有把这些操作搞明白了才能用自己的set去设置信号掩码,去进行位或或者位与操作

2.信号掩码

设置阻塞信号集的

上一节在sigaction 系统调用提到,sigaction结构体中的sa_mask成员可以用于设置进程的信号掩码,下面这个函数也可以用于设置或查看进程的信号掩码:

1
2
#include <signal.h>
int sigprocmask(int _how, _const sigset_t* _set, sigset_t* _oset);
参数
  • _set:指定新的信号掩码。
  • _oset:输出原来的信号掩码(如果不为 NULL)。
  • _how:如果_set参数不为NULL,则_how参数指定设置进程信号掩码的方式,其可选值为:

img

  • 如果_set参数为NULL,则进程信号掩码不变,此时我们可用_oset参数来获取进程当前的信号掩码。
返回值
  • sigprocmask函数成功时返回0,失败则返回-1并设置errno

3.被挂起的信号

读取未决信号集

设置进程信号掩码后,被屏蔽的信号不能被进程接收,如果给进程发送一个被屏蔽的信号,则操作系统将该信号设置为进程的一个被挂起的信号,如果我们取消对被挂起信号的屏蔽,则它立即能被进程接收到。以下函数能获得进程当前被挂起的信号集:

1
2
#include <signal.h>
int sigpending(sigset_t* set);
参数
  • set参数返回被挂起的信号集。进程即使多次接收到同一个被挂起的信号,sigpengding函数也只能返回一次(set参数的类型决定了它只能反映信号是否被挂起,不能反映被挂起的次数),并且,当我们再次使用sigprocmask函数使能该挂起的信号时,该信号的处理函数也只触发一次。
返回值
  • sigpending函数成功时返回0,失败时返回-1并设置errno

在多线程、多进程环境中,我们以线程、进程为单位来处理信号和信号掩码。并且我们不能设想新创建的线程、进程具有和父进程、主线程完全相同的信号特征。比如,fork函数产生的子进程继承父进程的信号掩码,但具有一个空的挂起信号集(信号掩码相同,但是挂起信号集不同)。

4.信号集操作函数练习

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
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<error.h>
#include<pthread.h>
#include<sys/types.h>
#include<signal.h>


void print_set(sigset_t *set)
{
int i;
for(i=1;i<32;i++)
{
if(sigismember(set,i))
putchar('1');
else
putchar('0');
}
printf("\n");
}

int main(int argc,char* argv[])
{
//设置set
sigset_t set,oldset,pedset;
sigemptyset(&set);
sigaddset(&set,SIGINT);

//设置阻塞信号集
sigprocmask(SIG_BLOCK,&set,&oldset);

while(1){
//查看未决信号集
sigpending(&pedset);

print_set(&pedset);
sleep(3);
}
return 0;
}

运行结果

可以看到我按了ctrl+c以后,编号为2的信号就被屏蔽了

image-20241213202644233

4.alarm和setitimer函数

这是补充内容,11章说10章讲了,可我压根没找着,故做个补充

这两个函数其实是sleep函数的实现,平时用的也不多

alarm

使用自然计时法,就是说

只要我设定了时间,假定是1秒,那这一秒不管进程是什么状态,不管你在这1秒是是在阻塞等待设备,还是等待cpu,还是正在运行,还是被挂起了,还是怎么样,反正1秒以后我都给它发信号

image-20241214214123628

1
2
3
4
5
6
7
8
//alarm.c

alarm(1);
while(1)
{
printf("%d\n",i);
i++;
}

1.常用的是alarm(0),用来取消闹钟,比如我先调alarm(5),后调用alarm(0)就相当于原来的alarm被取消了

  • 调用alarm(0)本身不会发送SIGALRM信号。只有当alarm定时器正常计时结束(也就是alarm函数设置的时间到期)时,系统才会发送SIGALRM信号。因为alarm(0)的作用是取消定时器,所以它不会触发SIGALRM信号的发送机制。

2.返回值返回的是上一次调用alarm剩下的时间,如果是第一次调用alarm,返回的就是0

举例说明:第一次调用alarm传入5秒,我在第三秒的时候第二次调用alarm设置10,那么我第二次调用alarm返回值就是2,说明我还剩下2秒

不捕捉SIGALRM信号的话就会执行默认动作(终止进程)或者忽略

3.执行命令 time ./alarm

可以计时程序运行了多久

image-20241214215020915

real表示程序实际执行时间

user程序运行在用户态的时间

sys程序运行在内核态的时间

real=user+sys

但是图中并不相等,因为少的那部分时间是等待的时间,可能是在等设备,可能是在等cpu资源,本程序alarm是在等标准输出,因为所有的进程都共用一个标准输出

解决:把输出到标准输出的东西重定向到一个文件里面去就行了,那user+sys几乎就是real了

image-20241214215417593

4.原来1秒只能打印9万条数据

重定向到文件(IO优化)以后一秒可以打印9百万条数据

说明程序运行瓶颈在IO,要优化程序先优化IO

setitimer

比alarm设计的更加精细

image-20241214222905140

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
//扩展练习 捕捉信号,动作改为打印hello world
#include <stdio.h>
#include <sys/time.h>
#include <signal.h>
void myfunc(int signo)
{
printf("hello world\n");
}

int main(void)
{
struct itimerval it, oldit;

//注册SIGALRM信号的捕捉处理函数。

signal(SIGALRM, myfunc);

it.it_value.tv_sec = 2;
it.it_value.tv_usec = 0;

it.it_interval.tv_sec = 5;
it.it_interval.tv_usec = 0;

if(setitimer(ITIMER_REAL, &it, &oldit) == -1)
{
perror("setitimer error");
return -1;
}
while(1);
return 0;
}

运行结果

2秒后打印一次helloworld

之后每5秒打印一次helloworld

就相当一个do-while循环了 第一次间隔两秒

后续都是间隔5秒,所以说它可以实现周期计时

5.统一事件源

信号是一种异步事件:信号处理函数和程序的主循环是两条不同的执行路线,我们希望信号处理函数尽可能快地执行完毕,以确保该信号不被屏蔽太久(信号在处理期间,为了避免一些竞态条件,系统不会再触发它)。

一种典型的解决方案是:把信号的主要处理逻辑放在进程的主循环中,当信号处理函数被触发时,它只是简单地通知主循环程序接收到信号,并把信号值传递给主循环,主循环再根据接收到的信号值执行目标信号对应的逻辑代码。

信号处理函数通常使用管道将信号通知主循环:信号处理函数往管道的写端写入信号值,主循环则从管道的读端读出该信号值,主循环中使用I/O复用系统调用来监听管道的读端文件描述符上的可读事件,这样,信号事件就能和其他I/O事件一样被处理,即统一事件源。

笔者自认为更好理解的说明

笔者的理解:就是原来是信号来了我去调用信号处理函数,假设信号处理函数有2000行(执行的功能可能有写日志,回收子进程之类的),我得执行10秒,那我这10秒内,程序就会屏蔽掉我捕捉的信号,可能会导致一些其他的问题

解决方案就是:信号来了,我去调信号处理函数,但是信号处理函数就弄个2行(量词,说明这个函数足够短就行),功能就是通知主循环我有个信号来了,原来信号处理函数里面的像是写日志,回收子进程之类的,都在主循环里面完成。就在主循环里面多加一个

if(有信号){写日志,回收子进程}这样子的代码段

而我这两行代码怎么能做到通知主循环的呢?epoll监听或者管道

作用就是降低了处理函数的运行时间,减少了被捕捉信号的屏蔽时间,减少了捕捉这个信号这个行为对进程的影响

很多优秀的I/O框架库和后台服务器都统一处理信号和I/O事件,如Libevent I/O框架库和 xinetd 超级服务。以下代码给出了统一事件源的一个简单实现:

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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <pthread.h>
#include <libgen.h>

#define MAX_EVENT_NUMBER 1024

static int pipefd[2];

int setnonblocking(int fd) {
int old_option = fcntl(fd, F_GETFL);
int new_option = old_option | O_NONBLOCK;
fcntl(fd, F_SETFL, new_option);
return old_option;
}

void addfd(int epollfd, int fd) {
epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN | EPOLLET;
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
setnonblocking(fd);
}

// 信号处理函数
void sig_handler(int sig) {
// 保留原来的errno,在函数最后恢复,保证函数的可重入性
int save_errno = errno;
int msg = sig;
// 将信号写入管道,以通知主循环,此处代码是错误的,只发送了int的低地址1字节
// 如果系统是大端字节序,则发送的永远是0,因此可以改成发送一个int,或将sig改为网络字节序,然后发送最后一个字节
send(pipefd[1], (char *)&msg, 1, 0);
errno = save_errno;
}

// 设置信号的处理函数
void addsig(int sig) {
struct sigaction sa;
memset(&sa, '\0', sizeof(sa));
sa.sa_handler = sig_handler;
sa.sa_flags |= SA_RESTART;
sigfillset(&sa.sa_mask);
assert(sigaction(sig, &sa, NULL) != -1);
}

int main(int argc, char *argv[]) {
if (argc != 3) {
printf("usage: %s ip_address port_number\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);

int ret = 0;
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);

int listenfd = socket(PF_INET, SOCK_STREAM, 0);
assert(listenfd >= 0);

ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
if (ret == -1) {
printf("errno is %d\n", errno);
return 1;
}
ret = listen(listenfd, 5);
assert(ret != -1);

epoll_event events[MAX_EVENT_NUMBER];
int epollfd = epoll_create(5);
assert(epollfd != -1);
addfd(epollfd, listenfd);

// 使用socketpair创建管道,注册pipefd[0]上的可读事件
ret = socketpair(PF_UNIX, SOCK_STREAM, 0, pipefd);
assert(ret != -1);
setnonblocking(pipefd[1]);
addfd(epollfd, pipefd[0]);

// 设置一些信号的处理函数
addsig(SIGHUP);
addsig(SIGCHLD);
addsig(SIGTERM);
addsig(SIGINT);
bool stop_server = false;

while (!stop_server) {
int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
if ((number < 0) && (errno != EINTR)) {
printf("epoll failure\n");
break;
}

for (int i = 0; i < number; ++i) {
int sockfd = events[i].data.fd;
// 如果就绪的文件描述符是listenfd,则处理新的连接
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);
addfd(epollfd, connfd);
// 如果就绪的文件描述符是pipefd[0],则处理信号
} else if ((sockfd == pipefd[0]) && (events[i].events & EPOLLIN)) {
int sig;
char signals[1024];
ret = recv(pipefd[0], signals, sizeof(signals), 0);
if (ret == -1) {
continue;
} else if (ret == 0) {
continue;
} else {
// 每个信号占1字节,所以按字节逐个接收信号,我们用SIGERTM信号为例说明如何安全终止服务器主循环
for (int i = 0; i < ret; ++i) {
switch (signals[i]) {
case SIGCHLD:
case SIGHUP:
continue;
case SIGTERM:
case SIGINT:
stop_server = true;
}
}
}
}
}
}

printf("close fds\n");
close(listenfd);
close(pipefd[1]);
close(pipefd[0]);
return 0;
}

6.网络编程相关信号

1.SIGHUP

img

当挂起进程的控制终端时(关闭终端),SIGHUP信号将被触发。对于没有控制终端的网络后台程序而言,它们通常利用SIGHUP信号来强制服务器重读配置文件。一个典型的例子是 xinetd 超级服务程序。

xinetd 程序在接收到SIGHUP信号后将调用hard_reconfig函数(见xinetd 源码),它循环读取/etc/xinetd.d目录下的每个子配置文件,并检测其变化。如果某个正在运行的子服务的配置文件被修改以停止服务,则 xinetd 主进程将给该子进程发送SIGTERM信号以结束它。如果某个子服务的配置文件被修改以开启服务,则 xinetd 将创建新socket并将其绑定到该服务对应的端口上。

img

SIGHUP 信号(终端断开信号)

  • 含义:
    • SIGHUP 信号通常在终端连接断开时发送给与该终端相关联的进程。这包括用户正常退出终端登录(比如在终端中输入logout或者关闭终端模拟器),或者网络连接导致的终端会话断开等情况。
  • 应用场景和作用:
    • 许多守护进程(在后台持续运行,不与特定终端关联的进程)会利用 SIGHUP 信号来重新读取配置文件。例如,一个网络服务器守护进程在收到 SIGHUP 信号后,可以重新加载新的配置参数,如监听端口的改变、日志级别设置的调整等,而不需要重新启动整个进程。这样可以在不中断服务的情况下更新服务器的配置。

2.SIGPIPE

img

默认情况下,往一个读端关闭的管道或已关闭的socket连接中写数据将引发SIGPIPE信号,我们需要在代码中捕获并处理该信号,或者至少忽略它,因为程序接收到 SIGPIPE 信号的默认行为是结束进程,而我们绝对不希望因为错误的写操作而导致程序退出。引起SIGPIPE信号的写操作将设置errnoEPIPE

其中, send函数的MSG_NOSIGNAL标志可以用来禁止写操作触发SIGPIPE信号。此时,我们应使用send函数反馈的errno值来判断管道的读端或socket连接的读端是否已经关闭。

此外,我们也可利用I/O复用系统调用来检测管道读端和socket 连接的读端是否已经关闭。

poll函数为例,当管道的读端关闭时,写端文件描述符上的POLLHUP事件将被触发;

img

当socket连接被对方关闭时,socket上的POLLRDHUP事件将被触发。见poll 系统调用

img

SIGPIPE 信号(管道破裂信号)

  • 含义:
    • SIGPIPE 信号主要与管道(Pipe)和套接字(Socket)通信相关。当一个进程向一个已关闭的管道或套接字写入数据时,就会收到 SIGPIPE 信号。这是因为管道或套接字的另一端已经关闭,无法再接收数据。
  • 应用场景和作用:
    • 在网络编程中,当客户端突然关闭连接,而服务器还在尝试向该连接对应的套接字发送数据时,服务器进程就会收到 SIGPIPE 信号。这个信号提醒进程通信通道已经不可用,需要进行适当的处理,比如关闭相关的套接字,释放资源,并可能记录错误信息。

3.SIGURG

在Linux环境下,内核通知应用程序带外数据到达主要有两种方法:

  • I/O复用技术,select等系统调用在接收到带外数据时将返回,并向应用程序报告socket上的异常事件
  • 使用SIGURG信号。

SIGURG 信号(紧急数据信号)

  • 含义:
    • SIGURG 信号用于通知进程在套接字上有紧急数据到达。在网络通信中,TCP 协议支持紧急数据的概念,当对端发送了紧急数据时,接收端进程会收到 SIGURG 信号。
  • 应用场景和作用:
    • 这种信号通常用于需要及时处理特殊数据的场景。例如,在一个支持远程命令执行的服务器中,当收到一个高优先级的紧急命令(如立即停止当前操作)时,服务器进程可以通过 SIGURG 信号快速响应,优先处理这个紧急命令,而不是等待正常的数据读取流程。

使用 SIGURG 信号检测带外数据是否到达

Linux高性能服务器编程中的TCP带外数据梳理总结-CSDN博客

使用 SIGURG 信号处理带外数据的代码如下所示:

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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <signal.h>
#include <fcntl.h>
#include <libgen.h>

#define BUF_SIZE 1024

static int connfd;

// SIGURG信号的处理函数
void sig_urg(int sig) {
int save_errno = errno;
char buffer[BUF_SIZE];
memset(buffer, '\0', BUF_SIZE);

int ret;
while ((ret = recv(connfd, buffer, BUF_SIZE - 1, MSG_OOB)) < 0) {
if (errno == EWOULDBLOCK) {
continue; // 如果接收缓冲区满了,继续读取,直到接收到带外数据
} else {
break; // 处理其他错误情况
}
}
if (ret > 0) {
printf("got %d bytes of oob data '%s'\n", ret, buffer);
}

errno = save_errno;
}

void addsig(int sig, void (*sig_handler)(int)) {
struct sigaction sa;
memset(&sa, '\0', sizeof(sa)); // 使用 memset 函数将结构体 sa 的所有字节初始化为零。
sa.sa_handler = sig_handler; // 将信号处理函数指针 sig_handler 赋值给 sa_handler 字段。这个字段指定了当信号 sig 发生时,操作系统应该调用哪个函数来处理这个信号。
sa.sa_flags |= SA_RESTART; // 设置 sa_flags 字段,并启用 SA_RESTART 标志。SA_RESTART 标志表示,如果在处理信号时一个被阻塞的系统调用被中断,内核会自动重新启动这个系统调用,而不会返回 EINTR 错误。这在网络编程中很有用,因为它可以避免信号处理过程中某些系统调用(如 recv 或 accept)被意外中断。
sigfillset(&sa.sa_mask); // 将 sa_mask 字段设置为阻塞所有信号。在信号处理函数运行期间,其他信号将被阻塞,防止信号处理函数被其他信号中断。
assert(sigaction(sig, &sa, NULL) != -1); // 调用 sigaction 函数来注册信号处理程序,将信号 sig 与 sig_handler 函数绑定。如果 sigaction 调用失败,它将返回 -1,在这种情况下,assert 宏将终止程序并报告错误。sigaction 的第三个参数为 NULL,表示不需要保存之前的信号处理程序信息。
}

int main(int argc, char *argv[]) {
if (argc != 3) {
printf("usage: %s ip_address port_number\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);

struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);

int sock = socket(PF_INET, SOCK_STREAM, 0);
assert(sock >= 0);

int ret = bind(sock, (struct sockaddr *)&address, sizeof(address));
assert(ret != -1);

ret = listen(sock, 5);
assert(ret != -1);

struct sockaddr_in client;
socklen_t client_addrlength = sizeof(client);
connfd = accept(sock, (struct sockaddr *)&client, &client_addrlength);
if (connfd < 0) {
printf("errno is: %d\n", errno);
} else {
// 将 SIGURG 信号与 sig_urg 处理函数关联起来。当 SIGURG 信号发生时,操作系统将调用 sig_urg 函数来处理这个信号。
addsig(SIGURG, sig_urg);
// 设置指定文件描述符 connfd 的所有者为当前进程,以便该文件描述符在收到 SIGURG 信号时,将信号发送给这个进程。
fcntl(connfd, F_SETOWN, getpid());

char buffer[BUF_SIZE];
// 循环接收普通数据
while (1) {
memset(buffer, '\0', BUF_SIZE);
ret = recv(connfd, buffer, BUF_SIZE - 1, 0);
if (ret < 0) {
// 如果 recv 或其他系统调用因信号中断而返回 -1,那么 errno 就会被设置为 EINTR,表示系统调用被信号中断。
if(errno == EINTR) {
continue; // 如果recv因信号中断,则继续读取
}
break; // 处理其他错误
} else if (ret == 0) {
printf("Client disconnected.\n");
break;
}
printf("get %d bytes of normal data '%s'\n", ret, buffer);
}
close(connfd);
}
close(sock);
return 0;
}

编译:

1
g++ -o sigurg_server sigurg_server.cpp

运行:

1
2
./sigurg_server 127.0.0.1 12345 		// 服务器端
./client 127.0.0.1 12345 // 客户端