引言
以前对信号的理解,仅仅停留在main
函数入口注册几个异常信号(SIGPIPE
、SIGSEGV
、SIGFPE
)处理函数。当捕获到异常时,将进程的堆栈进行打印,方便排查、定位问题。这一类问题我认为是利用linux系统的异常信号机制,提高开发效率;后续随着工作经验的增长,linux的信号,还可以有其它用途:
- 业务上的触发。比如可以监听
SIGUSER1
、SIGUSER2
信号,表示触发某一业务。 - 提高软件的健壮性。比如可以监听
SIGTERM
信号(reboot
命令内部,回向所有进程发送)。在系统重启前,做一些关键资源的备份或处理。 - 定时器功能。比如通过监听
SIGALRM
信号,可以让系统在指定时间间隔,通知进程去做周期任务。
当然肯定还有其它的使用场景,等待着我去了解,拓展。本文主要介绍linux信号的相关概念,以及工作中的注意事项,常见接口的使用方式。希望能给到您帮助。
信号的概念
每一个信号都有一个名字,他们都是以SIG
开头,比如:SIGABRT
是夭折信号;SIGALRM
是闹钟信号;
注:信号名都是被定义为正整数常量。
信号是一个异步事件。因此当内核检测到它是触发时,实际上有两个做法:
- 设置一个变量(如
signal
),应用程序周期判断该变量状态,判断触发信号类型。 - 内核中断当前进程的执行代码块,去执行指定操作。
很明显方案一存在时效性的问题,因为信号的发生是可能在任一时刻的。
linux 内核处理信号的方式有三种:
- 忽略此信号。但是
SIGKILL
和SIGSTOP
信号无法忽略,因为需要向内核和超级用户提供使进程终止的或停止的可靠方法。也就是说我们常见的SIGSEGV
段错误,实际也可以让内核忽略,从而让进程不退出。但是我们往往不会这么操作,因为一旦发生类型错误,说明代码或业务已经出现异常,无法保证正确可靠的运行了。 - 捕捉信号。即告诉内核捕捉到该信号后,需要调用一个用户函数。这也是我们常见的做法。
- 执行系统默认动作。大多数信号的系统默认动作是终止该进程。可参考下表。
信号 | 说明 | 默认动作 |
---|---|---|
SIGABRT | 调用abort 函数使,产生此信号 | 终止+core |
SIGALRM | 调用alarm 、setitimer 函数,产生此信号 | 终止 |
SIGBUS | 硬件故障 | 终止+core |
SIGCHLD | 子进程终止或停止时,会将该信号发送给父进程,期望回收子进程资源 | 忽略 |
SIGCONT | 若当前进程处于停止状态,则进行运行。否则忽略 | 忽略/继续 |
SIGEMT | 硬件故障 | 终止+core |
SIGFPE | 算数运算异常。如除以0、浮点溢出等 | 终止+core |
SIGHUP | 终端检测到一个连接断开,则将该信号发送给会话中所有进程 | 忽略 |
SIGILL | 执行一条非法硬件指令 | 终止+core |
SIGINT | 当用户按下中断键(ctrl+c),则将该信号发送前台进程组中的所有进程 | 终止 |
SIGIO | 一个异步I/O事件 | 终止/忽略 |
SIGIOT | 硬件故障 | 终止+core |
SIGKILL | 不可被捕捉。向系统管理员提供了可以终止任一进程的可靠方法 | 终止 |
SIGPIPE | 如果在管道的读进程已经终止时写管道、当类型为SOCK_STREAM的套接字已不再连接时,进程写该套接字都会触发该信号 | 终止 |
SIGPWR | 用于具有不间断电源(UPS)的系统 | 终止/忽略 |
SIGQUIT | 当用户输入Ctrl+\ 时,终端驱动程序产生此信号,并发送给前台进程组中的所有进程 | 终止+core |
SIGSEGV | 引用无效的内存地址,也就是我们常说的:内存越界 | 终止+core |
SIGSTOP | 不可被捕捉。这是一个作业信号,停止一个进程 | 终止 |
SIGSYS | 无效的系统调用 | 终止+core |
SIGTERM | 系统默认终止信号,比如执行reboot 命令,会向所有进程发送该信号。用户进程可通过捕捉该信号,在进程退出前做好清理工作 | 终止 |
SIGTSTP | 当用户输入Ctrl+Z 挂起键时,终端驱动程序产生此信号,并发送给前台进程组中的所有进程 | 终止 |
SIGUSER1 | 这是用户定义的信号 | 终止 |
SIGUSER2 | 这是用户定义的信号 | 终止 |
其中core
文件:复制了该进程的内存映像,方便后续调试。关于如何调试core文件,可参考linux gdb 调试专栏。
修改系统对信号的处理方式
从上章节中内核对信号的处理方式有三种:忽略、捕捉、默认。我们可以通过signal
函数进行设置。
#include <signal.h>
/**
* @brief 信号注册处理函数
* @details
*
* @param [in] signo 信号值
* @param [in] func 信号处理函数
* @return 若成功,返回以前的信号处理配置;若出错,返回SIG_ERR
* @note
*/
void (*signal(ing signo, void(*func)(int))) (int);
#define SIG_ERR (void (*)()) (-1) // 一般用于判断signal 接口是否成功
#define SIG_DFL (void (*)()) (0) // 提示内核按照默认动作处理该信号
#define SIG_IGN (void (*)()) (1) // 提示内核忽略该信号
注:我们常常仅关注signal
返回值是否是SIG_ERR
。但我觉得这是不充分的。
比如存在这样的场景:
你作为软件SDK的提供者,并且在内部捕捉了部分信号,方便用于进行调试或业务开发。但是SDK集成方,也注册了相关信号处理。那么就会出现竞争情况,导致意料之外的情况发生。
如下:
#include <signal.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
void handlerAlarm(int signo)
{
printf("signo = %d\n", signo);
return;
}
void handlersdkAlarm(int signo)
{
printf("signo = %d\n", signo);
exit(-1);
return;
}
int init_sdk()
{
if (signal(SIGALRM, handlersdkAlarm) == SIG_ERR)
{
printf("registerSIGALRM failed\n");
return -1;
}
}
int main()
{
if (signal(SIGALRM, handlerAlarm) == SIG_ERR)
{
printf("registerSIGALRM failed\n");
return -1;
}
alarm(5);
/** 第三方SDK */
init_sdk();
while (1)
{
sleep(60);
}
return 0;
}
分析:原本进程针对SIGALRM
信号的处理,仅是日志打印记录。但是SDK提供方则任务这是一个异常,退出进程。这很明显就修改了进程的本意。
我的建议按照以下流程:
- 判断
signal
返回值是否是系统默认,,若是系统默认则说明没有应用对该信号捕获。 - 若不为系统默认处理,则发出警告或恢复。
逻辑大致如下:
void* pftmp = signal(SIGALRM, handlerAlarm);
if (pftmp == SIG_ERR)
{
printf("registerSIGALRM failed\n");
return -1;
}
if(pftmp != SIG_DFL)
{
printf(" SIGALRM have register\n");
abort(0);
}
这是通过技术手段避免对唯一资源的竞争使用判断,最简单的方式,则是提前与集成方沟通约束。类似的还有进程的标准输入输出也存在类似竞争问题。
程序启动后,信号的处理方式。
在之前的进程控制章节中,我们知道进程创建的方式有两种:
fork
函数族。
当一个进程调用fork
时,其子进程集成父进程的信号处理方式;
exec
函数族。
exec
函数将原先设置为要捕捉的信号都更改为默认状态。
其原因是:exec
启动一个程序,其原理是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段。因此之前注册的信号处理函数地址,在当前不一定存在意义,因此需要恢复系统默认。而fork
创建的子进程会将父进程的代码段都复制,因此,可以继承父进程的信号处理方式。
中断的系统调用
linux 系统中有一个特性是:如果一个进程在执行一个低速系统调用而阻塞期间捕捉到一个信号,则该系统调用会被中断,不再继续执行。该系统调用返回错误,并将errno
设置为EINIR
。
低速系统调用指的是可能会使进程永远阻塞的一类系统调用。包括:
- 如果某些类型行文件(如读管道、终端设备和网络设备)的数据不存在,则读操作(
read
,readv
)可能会使调用者永远不会返回。 pause
和wait
函数。- 某些
ioctl
函数。 - 某些进程间通信。
- 如果数据不能被相同的类型文件立即接受,则写操作(
write
、writev
)可能会使调用者永远阻塞。
当我们知道低俗系统调用可能会被信号中断,那我们在编写代码时就需要增加相关防错。如下:
/* 阻塞读取socket 数据*/
int rByte = read(socket,buff,1024);
if(rByte < 0)
{
/* 网络链接异常,断开重新连接*/
close(socket);
}
上述代码似乎没有问题:当read
出错时,则认为socket
异常,重新建立连接。理论上功能都可以实现,但是稍微修改一下,我觉得可能会更好些。优化后版本:
/* 阻塞读取socket 数据*/
int rByte = read(socket,buff,1024);
if(rByte < 0 && (errno != EINTR))
{
/* 网络链接异常,断开重新连接*/
close(socket);
}
思考:现在的进程基本都是多线程任务,那么信号触发时,中断的是哪一个线程呢?
经过下列代码验证(sleep
也可以被信号中断):
#include <signal.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <string.h>
void handlerSignal(int signo)
{
printf("pid=%ld signo = %d\n",pthread_self(),signo);
sleep(5);
return;
}
void* thread_function(void* arg)
{
printf("thread_function pid=%ld\n",pthread_self());
/** ALRM */
alarm(5);
/** abort */
abort();
/** SIGSEGV */
strcpy(NULL,"123");
while(1)
{
sleep(60);
printf("thread_function sleep have broken\n");
}
return NULL;
}
int main()
{
printf("main pid=%ld\n",pthread_self());
if (signal(SIGALRM, handlerSignal) == SIG_ERR)
{
printf("registerSIGALRM failed\n");
return -1;
}
if (signal(SIGABRT, handlerSignal) == SIG_ERR)
{
printf("registerSIGALRM failed\n");
return -1;
}
if (signal(SIGSEGV, handlerSignal) == SIG_ERR)
{
printf("registerSIGALRM failed\n");
return -1;
}
pthread_t pit;
if(pthread_create(&pit,NULL,thread_function,NULL) != 0)
{
printf("create thread failed\n");
return -1;
}
while (1)
{
sleep(60);
printf("main sleep have broken\n");
}
return 0;
}
- 进程外传入的信号(kill -signo pid),默认中断主线程。
- 进程内部创建的信号,比如
SIGALRM
信号,也是中断主线程;但是SIGSEGV
、SIGABRT
等信号中断的是触发的线程。
可重入函数
可重入函数必须要满足以下条件:
- 不使用静态或全局数据结构
- 不可以调用
malloc
或free
- 不可以是标准I/O
信号处理函数中保证调用可重入函数。否则对进程的影响是不可预估的。比如:进程正在执行malloc
,在其堆中分配另外的存储空间,而此时由于捕捉到信号,信号处理函数中也调用了malloc
,因为malloc通常为它维护了一个链表,信号处理函数中就修改了进程的链表。导致异常。这是因为早期的libc
库中的malloc
实现没有采用锁机制。即使加入了锁,信号处理函数也不建议调用,因为容易产生死锁。
线程安全和信号安全
有时候我们会接触到这两个概念,有时候傻傻分不清楚,容易在编码过程中造成一些隐患。
线程安全
线程安全是指一个函数可以被多个线程并发调用而不导致数据不一致或程序崩溃。线程安全函数应该满足以下条件:
- 不修改全局或静态数据,或者任何修改都必须要是原子操作或锁保护。
- 不返回指向静态数据的指针,或者确保指针在使用期间不会被修改。
- 只依赖于调用时的参数,不依赖于任何外部状态。
比如:
int g_count;
int countPlus()
{
int count = g_count++;
return count;
}
由于countPlus
内部调用了全局变量,且没有用锁保护,因此它是线程不安全函数。可通过锁保证多线程安全:
pthread_mutex_t lock;
int g_count;
int countPlus()
{
pthread_mutex_lock(&lock); // 加锁
int count = g_count++;
pthread_mutex_unlock(&lock); // 解锁
return count;
}
信号安全
信号安全是指一个函数可以在信号处理函数中被安全地调用,而不会导致未定义的行为。信号安全函数必须满足以下条件。
- 不调用任何非可重入函数:大多数可重入函数也是信号安全的。
- 不访问或修改全局或静态数据:除非这些数据是专门为信号处理而设计的,并且不受其他线程影响。
注:如上所示的线程安全函数countPlus
,就不是信号安全函数。因为在执行int count=g_count++
指令时,触发信号处理函数,并且信号处理函数中调用了countPlus
接口,就会造成死锁。
SIGCLD信号用途
在【unix高级编程系列】进程控制中,我们介绍了子进程退出后,如果没有对其进行资源回收,则会产生僵尸进程,导致对系统资源造成影响。通常情况下,我们的做法是在父进程中调用waitpid
等待子进程结束,并回收资源。这样的做法会导致父进程业务阻塞,并不是好的方式。
之后了解到子进程结束时,会向父进程发送SIGCLD
信号,因此我们可以从该信号做文章。总体有两种方式:
- 默认忽略,子进程将不再产生僵尸进程。如:
int main()
{
signal(SIGCHLD, SIG_IGN);
}
- 在信号处理函数中回收子线程资源。如:
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
void sigchld_handler(int sig)
{
int status;
pid_t child_pid = wait(&status);
if (child_pid > 0) {
printf("子进程 %d 已退出,状态: %d\n", child_pid, status);
}
}
int main() {
signal(SIGCHLD, sigchld_handler); // 设置SIGCHLD的处理函数
pid_t pid = fork();
if (pid > 0) {
// 父进程
// ... 父进程可以继续其他工作
} else if (pid == 0) {
// 子进程
printf("子进程开始执行...\n");
sleep(1); // 模拟子进程工作
printf("子进程结束。\n");
exit(0); // 子进程退出
} else {
// fork失败
perror("fork");
exit(1);
}
return 0;
}
常用信号函数
kill和raise
kill
和raise
都是发送信号。kill
函数将信号发送给指定的进程或进程组;而raise
是发送线程自身;
#include <signal.h>
int kill(pid_t pid, int signo);
int raise(int signo);
// 若成功返回0;若出错返回-1;
通过raise(signo)
等价于kill(getpid(),signo)
;
其中kill
的pid参数有以下4种情况:
pid>0
;将该信号发送给进程ID为pid的进程;pid==0
;将信号发送给发送进程属于同一进程组的所有进程;pid<0
;将信号发送给其进程组ID等于pid绝对值;pid==-1
;将信号发送给发送进程有权限像它们发送信号的所有进程;
注:signo==0
,常被用来判断一个进程是否仍然存在。
alarm和pause
使用alarm
函数可以设置一个定时器,在将来的某个时间该定时器会超时,并产生SIGALRM
信号。如果忽略或不捕捉此信号,则默认终止该进程。
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
//返回值: 0 或以前设置的闹钟时间的余留秒数
注: 每个进程只能有一个闹钟时钟;
pause
函数是调用线程挂起,直至捕捉到一个信号。
#include <unistd.h>
int pause(void);
//返回值:-1,errno 被设置为EINTR
注:pause
仅是阻塞调用线程,并不会阻塞整个进程。如下示例:
#include <signal.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <string.h>
void* thread_function(void* arg)
{
int count = 0;
while(1)
{
sleep(3);
printf("thread_function count=%d\n",count++);
if(count == 3)
{
pause();
}
}
return NULL;
}
int main()
{
pthread_t pit;
if(pthread_create(&pit,NULL,thread_function,NULL) != 0)
{
printf("create thread failed\n");
return -1;
}
int count = 0;
while (1)
{
sleep(3);
printf("main count=%d\n",count++);
}
return 0;
}
编译输出如下:
xieyihua@xieyihua:~/test$ gcc 6.c -o 6 -lpthread
xieyihua@xieyihua:~/test$ ./6
main count=0
thread_function count=0
main count=1
thread_function count=1
main count=2
thread_function count=2
main count=3
main count=4
main count=5
^C
sigaction函数
sigaction
是检查或修改与指定信号相关联的处理动作。基本已取代了signal
。
#include <signal.h>
struct sigaction
{
void (*sa_handler)(int); // 信号处理函数的地址、或SIG_IGN、或SIG_DFL
sigset_t sa_mask; //是一个信号集,用于指定在处理信号时需要被阻塞的信号
int sa_flag; //saflags 是一些标志,用于改变 sigaction 的行为
void (*sa_sigaction)(int , siginfo_t *,void*); //是另一个函数指针,用于更复杂的信号处理
};
int sigaction(int signo,
const struct sigaction *restrict act,
struct sigaction *restrict oact);
//返回值:若成功,返回0;若出错,返回-1
分析:
- 若
act
指针非空,则要修改其动作。 - 若
oact
指针非空,返回该信号的上一个动作。
常见用法:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handle_sigint(int sig) {
printf("Caught signal %d\n", sig);
}
int main() {
struct sigaction sa;
sa.sa_handler = &handle_sigint;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGINT, &sa, NULL);
while (1) {
printf("Hello, World!\n");
sleep(1);
}
return 0;
}
sleep函数
sleep
是我们工作中经常使用的函数,但是我相信它的一些注意事项很多人都不太了解。可能会造成问题;
#include <unistd.h>
unsigned int sleep(unisigned int seconds);
//返回值:0或未休眠的秒数
sleep
函数返回的场景有两种:
- 已经过了
seconds
所指定的墙上时钟时间。 - 调用进程捕捉到一个信号,并从信号处理程序中返回。
注:墙上时钟时间指实际的物理时间,即现实世界中的时间。
其中第二点是我们常常会忽略,造成错误的。假设有如下代码:
#include <signal.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <string.h>
int g_dothing = 0;
static void sig_alarm(int signo)
{
alarm(5);
g_dothing = 1;
return;
}
void* thread_function(void* arg)
{
if(signal(SIGALRM,sig_alarm) == SIG_ERR)
{
printf("signal failed\n");
return NULL;
}
alarm(5);
while(1)
{
if(g_dothing == 1)
{
/**
* TODO: 执行业务处理
*/
printf("do someting\n");
g_dothing = 0;
}
sleep(1);
}
return NULL;
}
int main()
{
pthread_t pit;
if(pthread_create(&pit,NULL,thread_function,NULL) != 0)
{
printf("create thread failed\n");
return -1;
}
while (1)
{
sleep(60);
/**
* TODO: 上报心跳
*/
printf("report heartbeat\n");
}
return 0;
}
分析:该进程有两个业务线程:
- 主线程周期60秒上报心跳;
- 子线程周期5秒,执行相关动作;
但实际运行过程中,主线程中的sleep
会被子线程中定时器唤醒,因此主线程的上报周期变成了5秒。导致与预期不符。我们应该关注sleep函数的返回值,对主线程做以下优化:
int unsleepTime = 60;
while (1)
{
unsleepTime = sleep(unsleepTime);
if(unsleepTime == 0)
{
/**
* TODO: 上报心跳
*/
printf("report heartbeat\n");
unsleepTime = 60;
}
}
总结
本文详细介绍了Linux信号的概念、处理方式、常见信号的用途以及信号处理函数的使用。希望能给您带来帮助
若我的内容对您有所帮助,还请关注我的公众号。不定期分享干活,剖析案例,也可以一起讨论分享。
我的宗旨:
踩完您工作中的所有坑并分享给您,让你的工作无bug,人生尽是坦途