信号是一种进程间通信的方式,进程间通信的方式还包括共享内存,socket,管道,消息队列。信号与其它方式是有区别的,其它通信方式很灵活,可以详细定义自己的消息内容;而信号没有这么灵活,信号的种类在系统中是固定死的,我们可以使用的信号种类,只能从固定的类型中选择。
linux 中的信号有 64 种,可以通过 kill -l 命令查看。
1 信号的使用
信号作为一种进程间通信方式,最基本的有两个处理,一个是发送信号,一个是接收并处理信号。
1.1 kill 和 raise
kill 可以指定要发送的信号,以及要将这个信号发送到哪个或者哪些进程。raise 可以把信号发给进程自己。
kill 函数
int kill(pid_t pid, int sig);
raise 相当于 kill(getpid(), sig)
int raise(int sig);
使用 kill 的时候,我们一般都是向某个进程发送一个信号,第一个形参 pid 就是目标进程的进程号。但是 kill 的 pid 也可以传不大于 0 的数。
pid > 0 | 一个进程 |
pid == 0 | 信号发向一个进程组,发向哪个进程组呢,就是当前这个进程所在的进程组 |
pid < -1 | 信号也是发向一个进程组,进程组的组 id 是 pid 的绝对值 |
pid == -1 | 发向每一个进程,前提这个进程有权限,不会发向 1 号进程 |
如下代码是 kill 使用的示例,使用 signal 捕获了 SIGTERM 信号,信号处理函数是 signal_handler。父进程中的代码 kill(pid, SIGTERM) 是向子进程发送信号,kill(0, SIGTERM) 是向进程组发信号。
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
void signal_handler(int sig_num) {
printf("signal = %d, pid = %d\n", sig_num, getpid());
}
int main() {
signal(SIGTERM, signal_handler);
pid_t pid = fork();
if (pid == 0) {
printf("子进程 id = %d, 进程组 id = %d\n", getpid(), getpgrp());
// 子进程
while (1) {
sleep(1);
}
} else {
printf("父进程 id = %d, 进程组 id = %d\n", getpid(), getpgrp());
sleep(2);
// kill(pid, SIGTERM);
kill(0, SIGTERM);
sleep(2);
}
return 0;
}
1.2 signal 和 sigaction
signal 和 sigaction 都可以用于改变信号的处理方式,指定一个信号,指定这个信号的处理函数,就可以工作了。
当进程中有线程阻塞在内核态的时候,比如 io,这个时候收到信号,默认情况下线程会被唤醒,从阻塞的系统调用中返回。如下代码,使用 sigaction 捕获 SIGTERM 信号,在主函数中有一个 while(1) 循环,循环中使用 scanf() 从标准输入读取数据,我们预期的情况是如果在控制台不输入数据,那么 scanf() 就会一直处于阻塞状态。但是默认的情况和我们预期是不一样的,当向进程发送信号 SIGTERM 时,scanf() 会被唤醒,从而返回。
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
void signal_handler(int sig_num) {
printf("signal = %d, pid = %d\n", sig_num, getpid());
}
int main() {
struct sigaction sa_sigterm;
sa_sigterm.sa_flags = 0; // SA_RESTART;
sa_sigterm.sa_handler = signal_handler;
sigaction(SIGTERM, &sa_sigterm, NULL);
printf("pid = %d\n", getpid());
while (1) {
printf("before scanf\n");
int a = 10;
scanf("%d", &a);
printf("after scanf, a = \n", a);
}
return 0;
}
运行结果如下,当向进程发送 SIGTERM 时,信号处理函数会被调用,同时 scanf() 也会返回。
使用 sigaction 的时候,可以给 sa_flags 设置一个标志 SA_RESTART,设置这个标志之后,收到信号之后 scanf() 就不会返回。
signal 是对 sigaction 的封装,主要 signal 内部使用 sigaction 的时候就设置了 SA_RESTART 标志,所以使用 signal 的话,收到信号的时候 io 阻塞不会返回。在使用 signal 的代码里边,使用 gdb 给 sigaction 设置断点,可以看到 signal 调用了 sigaction,如下图所示。
1.3 signalfd, 一切皆文件
signalfd 是将信号通过一个 fd 来管理,体现了 linux 中一切皆文件的简洁性。
当把一个系统通过文件来管理的时候,往往都要实现自己的 struct file_operations。signalfd 也不例外,如下是实现,其中实现了最主要的两个函数,read 和 poll,通过 read 可以读取信号信息;实现了 poll 函数的话,就支持通过 select,poll,epoll 这些多路复用技术来监听。
static const struct file_operations signalfd_fops = {
#ifdef CONFIG_PROC_FS
.show_fdinfo = signalfd_show_fdinfo,
#endif
.release = signalfd_release,
.poll = signalfd_poll,
.read = signalfd_read,
.llseek = noop_llseek,
.may_pollfree = true,
};
通过 signalfd 来管理信号,要注意以下两点:
(1)使用 read 或者 select, poll, epoll 来监听信号之后,信号的处理就是同步的了;不像通过 signal() 或者 sigaction() 这样直接注册一个信号处理函数,这样是异步处理,有不可重入问题需要注意。改成同步处理之后,我们就可以使用一个单独的线程来处理信号了。
(2)使用 signalfd 来管理信号的时候需要使用 sigprocmask() 来将监听的信号设置为阻塞状态,因为我们并没有改变信号的处理方式,如果不设置为阻塞,那么信号会被默认方式处理,使用 read,select,poll,epoll 就无法获取到信号了。
sigpromask 是设置本线程的 mask,哪个线程调用,就对哪个线程生效。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <pthread.h>
#include <sys/signalfd.h>
void *thread_function(void *arg) {
sigset_t mask;
int signal_fd;
struct signalfd_siginfo fd_si[8];
ssize_t s;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGINT);
signal_fd = signalfd(-1, &mask, 0);
if (signal_fd == -1) {
perror("signa_fd");
return NULL;
}
printf("signal_fd = %d\n", signal_fd);
// 信号阻塞,防止信号被默认的处理方式处理
if (sigprocmask(SIG_BLOCK, &mask, NULL) == -1) {
perror("sigprocmask");
}
while (1) {
fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(signal_fd, &read_fds);
sleep(20);
printf("before select\n");
if (select(signal_fd + 1, &read_fds, NULL, NULL, NULL) == -1) {
perror("select");
return NULL;
}
printf("after select\n");
s = read(signal_fd, fd_si, 2 * sizeof(struct signalfd_siginfo));
if (s == sizeof(struct signalfd_siginfo)) {
printf("received signal %d\n", fd_si[0].ssi_signo);
} else {
printf("received signal %d and %d\n", fd_si[0].ssi_signo, fd_si[1].ssi_signo);
}
}
close(signal_fd);
return NULL;
}
int main() {
pthread_t t1;
pthread_create(&t1, NULL, thread_function, NULL);
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGINT);
// 信号阻塞,防止信号被默认的处理方式处理
if (sigprocmask(SIG_BLOCK, &mask, NULL) == -1) {
perror("sigprocmask");
}
printf("before return\n");
sleep(200);
return 0;
}
1.4 sigtimedwait
与使用 signalfd 类似,sigtimedwait 也可以同步处理信号,并且使用 sigtimedwait 比使用 signalfd 要简洁一些,需要调用的 api 少。
sigtimedwait 和 signalfd 有一个共同的优点,就是可以通过 signal info 获取到信号是谁发送的。这个在实际工作中非常有用,有时候进程被一个信号杀死,我们经常需要确定这个信号是谁发送的。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <time.h>
#include <errno.h>
int main() {
sigset_t mask;
siginfo_t info;
struct timespec timeout;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGINT);
// 信号阻塞,防止信号被默认的处理方式处理
if (sigprocmask(SIG_BLOCK, &mask, NULL) == -1) {
perror("sigprocmask");
}
timeout.tv_sec = 5;
timeout.tv_nsec = 0;
// 在这 20s 期间,向进程分别发送一个 SIGTERM 和 SIGINT 观察怎么处理
sleep(20);
while (1) {
printf("before signal timed wait\n");
int result = sigtimedwait(&mask, &info, /*&timeout*/ NULL);
printf("after signal timed wait\n");
if (result == -1) {
if (errno == EAGAIN) {
printf("timed out waiting for signal\n");
} else {
perror("sigtimedwait");
return -1;
}
} else {
printf("received signal %d, sent by pid %d\n", result, info.si_pid);
}
}
return 0;
}
1.5 信号是进程的资源
“进程是资源封装的单位”,这句话我们经常看到,信号也是属于进程的资源,所以 struct task_struct 中也有成员来维护这个进程的信号。除了信号之外,进程的资源还包括内存,打开的文件。
struct task_struct 中与信号相关的成员有如下几个:
struct task_struct {
/* Signal handlers: */
struct signal_struct *signal;
struct sighand_struct __rcu *sighand; // 信号处理函数
sigset_t blocked; // 线程阻塞哪些信号
sigset_t real_blocked; // 线程当前阻塞的信号
struct sigpending pending; // 当前线程等待处理的信号
}
2 常见信号
在工作中我们经常使用一些信号,比如使用 kill -9 杀死一个进程,ctrl + C 停止一个进程其实是向进程发信号 SIGINT。另外还经常见到一个信号,比如 SIGCHLD,SIGSEGV 等。本节就记录这些常用以及常见的信号。
2.1 SIGKILL
(1)SIGKILL 是信号 9,平时常用 kill -9 杀死一个进程
(2)如果一个进程一直申请内存,导致系统资源紧张的话,系统会用 SIGKILL 将进程杀死
2.2 SIGINT
我们使用最多的 ctrl C 把进程停掉,就是向进程发 SIGINT,2 信号。
2.3 SIGCHILD
当子进程退出的时候会向父进程发送信号 SIGCHLD。
SIGCHLD 默认的操作是 ignore 的。
在开发中也不会专门使用 signal 来捕获 SIGCHLD,而是使用 wait() 来回收子进程。
如下代码可以观察到子进程退出后,父进程收到 SIGCHLD 的现象。
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
void signal_handler(int sig_num) {
printf("signal = %d, pid = %d\n", sig_num, getpid());
}
int main() {
signal(SIGCHLD, &signal_handler);
printf("pid = %d\n", getpid());
pid_t pid = fork();
if (pid == 0) {
printf("子进程 id %d\n", getpid());
sleep(2);
} else {
printf("父进程 id %d\n", getpid());
sleep(5);
}
return 0;
}
2.4 SIGSEGV
SIGSEGV 是段错误,一般出现在非法访问内存的时候。
如下代码 int a 是一个全局的 const 常量,常量只能在声明的时候初始化,不可以在其他地方修改,没有写权限。可以构造出段错误。
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
const int a = 10;
int main() {
int *pa = (int *)(void *)(&a);
*pa = 100;
return 0;
}
2.5 SIGABRT
发送 SIGABRT 信号的原因没有那么具体,进程收到 SIGABRT 的时候,说明发生了严重的错误。
如下代码是对一块内存重复释放了,会产生 SIGABRT 信号。
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
int main() {
char *p = (char *)malloc(8);
p[0] = 10;
free(p);
free(p);
return 0;
}
2.6 SIGPIPE
当我们使用 tcp 或者 unix socket 中面向连接的 socket 时,如果对端已经关闭了,这个时候我们还向对端发送数据,就会收到 SIGPIPE 信号。
2.7 SIGTERM
systemd 使用了 SIGTERM 信号将 systemd 管理的服务关掉。服务可以捕获这个信号,当收到该信号的时候,说明进程需要关闭,进程内部可以做一些退出前的状态保存工作。
2.8 SIGBUS
使用 mmap 的时候,如果使用不当,会被 SIGBUS 信号杀死。
如下代码,使用 mmap,如果 mmapfile 文件的大小是 0,这个时候通过 p[0] 写数据的话,就会收到 SIGBUS 信号。
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
int fd = open("mmapfile", O_RDONLY);
if (fd == -1) {
perror("open");
return 1;
}
void* map_addr = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
if (map_addr == MAP_FAILED) {
perror("mmap");
close(fd);
return 1;
}
char *pc = (char *)map_addr;
pc[0] = 10;
close(fd);
if (munmap(map_addr, 4096) == -1) {
perror("munmap");
return 1;
}
return 0;
}
3 信号使用注意事项
3.1 SIGKILL 和 SIGSTOP 不能被用户捕获
signal 的 man 手册中说了,这两个信号不能被用户捕获,也不能忽略。
如下代码分别设置 SIGTERM,SIGKILL, SIGSTOP 的回调函数或者将信号设置为忽略。SIGTERM 可以设置成功,SIGKILL 和 SIGSTOP 设置失败。
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
void signal_handler(int sig_num) {
printf("signal = %d, pid = %d\n", sig_num, getpid());
}
int main() {
if (signal(SIGTERM, signal_handler) == SIG_ERR) {
perror("capture SIGTERM: ");
}
if (signal(SIGTERM, SIG_IGN) == SIG_ERR) {
perror("ignore SIGTERM: ");
}
if (signal(SIGKILL, signal_handler) == SIG_ERR) {
perror("capture SIGKILL: ");
}
if (signal(SIGSTOP, signal_handler) == SIG_ERR) {
perror("capture SIGTERM: ");
}
if (signal(SIGKILL, SIG_IGN) == SIG_ERR) {
perror("ignore SIGKILL: ");
}
if (signal(SIGSTOP, SIG_IGN) == SIG_ERR) {
perror("ignore SIGSTOP: ");
}
return 0;
}
运行结果如下:
3.2 一个信号的处理函数在一个进程内只有一个
如果是多线程的程序,在不同的线程里使用 signal() 或者 sigaction() 注册多次信号的回调函数,那么后边注册的会覆盖前边注册的函数,最后一次调用 signal() 或者 sigaction() 传的回调函数是有效的。
当进程收到信号的时候,无论信号被哪个线程处理,都是调用最后这个函数。
如下代码所示,main 函数中首先对 SIGTERM 注册了 signal_handler1 和 signal_handler2 两个函数;然后创建了线程,在线程中又对 SIGTERM 注册了 signal_handler3;最后睡眠 1s 之后,在 main 函数中又注册了 signal_handler4。那么进程收到 SIGTERM 信号之后的处理函数是 signal_handler4。
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <pthread.h>
void signal_handler1(int sig_num) {
printf("1 signal = %d, pid = %d\n", sig_num, getpid());
}
void signal_handler2(int sig_num) {
printf("2 signal = %d, pid = %d\n", sig_num, getpid());
}
void signal_handler3(int sig_num) {
printf("3 signal = %d, pid = %d\n", sig_num, getpid());
}
void signal_handler4(int sig_num) {
printf("4 signal = %d, pid = %d\n", sig_num, getpid());
}
void *thread_function(void *arg) {
signal(SIGTERM, signal_handler3);
sleep(100);
}
int main() {
signal(SIGTERM, signal_handler1);
signal(SIGTERM, signal_handler2);
pthread_t t1;
pthread_create(&t1, NULL, thread_function, NULL);
sleep(1);
signal(SIGTERM, signal_handler4);
sleep(100);
return 0;
}
3.3 fork 之后,子进程与父进程共用一个 signal handler
如下代码,父进程中创建子进程之前使用 signal() 指定信号 SIGTERM 的处理函数是 signal_handler。然后创建一个子进程,在父进程中分别向子进程和父进程中发送信号 SIGTERM,可以看到子进程中的 SIGTERM 处理函数也是 signal_handler。
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
void signal_handler(int sig_num) {
printf("signal = %d, pid = %d\n", sig_num, getpid());
}
int main() {
signal(SIGTERM, signal_handler);
pid_t pid = fork();
if (pid == 0) {
printf("子进程 id = %d, 进程组 id = %d\n", getpid(), getpgrp());
while (1) {
sleep(1);
}
} else {
printf("父进程 id = %d, 进程组 id = %d\n", getpid(), getpgrp());
sleep(2);
kill(pid, SIGTERM);
raise(SIGTERM);
sleep(2);
}
return 0;
}
运行结果如下:
3.3 信号会被并行处理
对于一个多线程的进程来说,如果同一个信号,前一个信号还没有处理完毕,这个时候又来了一个信号,那么两个信号会并发处理吗 ?
会。
如下代码,有两个线程,一个主线程,一个使用 pthread_create() 创建的线程。信号 SIGTERM 的处理函数是 signal_handler。为了构造信号没有处理完毕的场景,在信号处理函数 signal_handler 中是一个死循环,一旦收到了信号,这个函数不返回。
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <pthread.h>
#include <sys/syscall.h>
void signal_handler(int sig_num) {
printf("signal = %d, pid = %d, tid = %d\n", sig_num, getpid(), syscall(SYS_gettid));
while (1);
}
void *thread_function(void *arg) {
while (1) {
sleep(1);
}
}
int main() {
signal(SIGTERM, signal_handler);
pthread_t t1;
pthread_create(&t1, NULL, thread_function, NULL);
while (1) {
sleep(1);
}
return 0;
}
进程运行之后,连续两次向进程发送信号,可以看到如下的打印,两次处理信号的线程,一次是 10707,一次是 10708。由此可以看出来,同一个信号并不是串行处理的,可以并行处理。
从下边的打印信息也可以看出来,信号处理函数是在线程上下文执行的,并且这个线程是我们自己创建的线程。
3.4 不可重入问题
3.4.1 什么是不可重入问题
信号相对于用户态的线程,类似于中断相对于内核态的线程。
在内核态做开发时,如果数据会被线程和中断同时访问,在线程中我们需要使用关中断自旋锁,在中断处理函数中需要使用自旋锁。这样在线程访问数据的时候,中断是关闭的,所以这个时候中断也上不来,不会打断线程的执行。如果线程中只是加自旋锁,而不关中断,这样会导致问题:因为没有关中断,在线程访问数据的时候,中断是可以打断线程的,这个时候中断要获取锁获取不到(因为自旋锁被线程拿着),中断一直无法返回,中断无法返回就会导致线程也得不到执行,这样就造成了中断和线程之间的死锁。
内核线程:
spin_lock_irq(); // 关中断自旋锁
// do something
spin_unloc_irq();
中断处理函数:
spin_lock();
// do something
spin_unlock();
什么是信号的重入问题呢 ?
如下代码,有一个全局变量 g_a,这个变量会在两处修改,一个是线程 t1 中,一个是 SIGINT 的信号处理函数 signal_handler 中。如果只是在多个线程中处理,不在信号中处理,这就是我们常见的多线程并发问题,多线程并发问题可以在访问变量的地方加锁来解决。但是如果这个变量在线程中处理,也在信号中处理,就不能使用自旋锁来解决了。从上边的分析也能看出来,信号回调函数的调用是在线程上下文,并且这个调用的时机是不确定的,那就很有可能出现线程正在拿着自旋锁,这个时候线程被打断,而这个时候信号处理函数中也想拿到自旋锁,但是自旋锁被线程拿着,所以信号处理函数也拿不到自旋锁,就产生了死锁。这就是信号的重入问题,使用自旋锁无法解决。我们经常使用的 malloc(),free(),printf() 这些标准库函数都是不可重入的,也就是不能在信号处理函数中调用这些函数。
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <pthread.h>
int g_a = 0;
void signal_handler(int signum) {
printf("received signal: %d\n", signum);
// pthrtead_spin_lock()
g_a++;
// pthread_spin_unlock()
}
void *thread_function(void *arg) {
while (1) {
// pthread_spin_lock()
g_a++;
// pthread_spin_unlock()
sleep(1);
}
}
int main() {
signal(SIGINT, signal_handler);
pthread_t t1;
pthread_create(&t1, NULL, thread_function, NULL);
while (1) {
sleep(1);
}
return 0;
}
3.4.2 怎么避免不可重入问题
(1)直接调用系统调用,而不是标准库函数
以 printf() 为例,printf() 是向标准输出打印信息,标准输出的 fd 是 1,我们可以不使用 printf(),而是使用 write 系统调用直接向文件 1 写内容。
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
int main() {
printf("hello\n");
char *str = "hello world\n";
write(1, str, strlen(str));
return 0;
}
运行结果如下:
(2)使用 signalfd 或者 sigtimedwait,将不可重入问题改成并发问题
改成并发问题就能通过加锁来解决了
(3)参考内核线程中的关中断自旋锁方案
在线程加锁之前使用 sigprocmast() 函数将信号阻塞,SIG_BLOCK;处理完毕之后,解除信号阻塞,SIG_UNBLOCK。