[linux] linux 常见信号以及使用信号时注意事项

信号是一种进程间通信的方式,进程间通信的方式还包括共享内存,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。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值