Linux信号处理的三种方式,Linux 安全的信号处理方式

信号处理的机制

在 Linux 中,每个进程都拥有两个位向量,这两个位向量共同决定了进程将如何处理信号:

一个是pending位向量,它包含了那些内核发送给进程,但还没有被进程处理掉的信号。

另一个是blocked位向量,它包含了那些被进程屏蔽掉的信号。

当内核发送一个信号给进程时,它将会修改进程的pending位向量,譬如说,当内核发送一个SIGINT信号给进程,那么它会将进程的pending[SIGINT]的值设置成 1:

247765c8f450eb571bc11fa1619b1782.png

同样地,当进程屏蔽掉一个信号时,那么它会修改blocked位向量。那么信号屏蔽是什么意思呢?当进程屏蔽掉一个信号之后,内核仍然可以发送这个信号给进程(保存在进程的pending位向量中),但进程不会接收并处理这个信号。只有当进程解除了对这个信号的屏蔽之后,进程才会接收并处理这个信号。

让我们从内核的角度看,大概是这样的:当内核执行 context switch 切换到某个进程的时候,它会检查进程的pending和blocked位向量。如果发现进程还有信号未处理,同时这个信号没有被进程屏蔽,那么内核就会让进程接收并处理这个信号。用伪代码可以这样表示:

for (int i = 0; i < pending.size(); ++i)

{

if (pending[i] & (~blocked[i]))

{

// 将这个信号从 pending 位向量中清除

pending[i] = 0;

// 让进程接收并处理这个信号

// ...

break;

}

}

譬如说,下面的程序一开始就屏蔽了SIGINT信号,所以即使内核发送SIGINT信号给这个程序,这个信号也不会得到处理。而当程序解除了对SIGINT的屏蔽之后,这个SIGINT信号才会得到处理:

#include #include #include void sigint_handler(int sig)

{

const char *message = "handle SIGINT signal\n";

write(STDOUT_FILENO, message, strlen(message));

}

int main()

{

signal(SIGINT, sigint_handler);

sigset_t mask, prev_mask;

sigemptyset(&mask);

sigaddset(&mask, SIGINT);

// 屏蔽掉 SIGINT 信号

sigprocmask(SIG_BLOCK, &mask, &prev_mask);

// 假设此时接收到 SIGINT 信号

sleep(10);

// 解除对 SIGINT 的屏蔽之后,进程会开始处理 SIGINT 信号

sigprocmask(SIG_SETMASK, &prev_mask, NULL);

return 0;

}

安全地处理信号

通常来说,信号中断有两种情况:

当进程接收到某个信号时,会调用这个信号的 handler,这会中断主程序的执行。

当进程在执行某个信号 handler 的过程中,可能会被另一个信号 handler 中断。

上面这两种情况都会带来并发安全的问题,因此在编写信号 handler 时,需要考虑到并发安全的问题。譬如说,由于信号 handler 会中断主程序的执行,如果信号 handler 与主程序共享全局变量,就可能带来并发安全的问题。

信号 handler 与主程序共享全局变量是很常见的。譬如说,当进程在接收到SIGINT时,为了优雅地退出程序,这时可以使用一个全局变量记录是否接收到SIGINT信号。主程序每次进入循环时都会检查这个变量,如果发现进程接收到SIGINT信号,就释放好资源并退出程序:

#include #include #include #include int quit = 0;

void sigint_handler(int sig) { quit = 1; }

int main()

{

char *buffer = malloc(1024 * sizeof(char));

signal(SIGINT, sigint_handler);

while (true)

{

if (quit)

{

free(buffer);

return 0;

}

// do something else

sleep(10);

}

return 0;

}

上面的代码并不是并发安全的,可能导致两个问题:

现代编译器通常会优化程序对变量的访问。主程序可能会将quit的副本存储在寄存器中,每次访问quit时就从寄存器中访问。那么即使信号 handler 修改了这个quit在内存中的值,主程序也可能不知道。

主程序会读取quit的值,信号 handler 会改变quit的值,而这两个操作都不保证是原子的。

我们可以这样解决这两个问题:

首先将quit声明为volatile变量。volatile可以阻止编译器所做的优化,这样信号 handler 和主程序访问quit时都会从主内存中访问。

其次将quit的类型改成sig_atomic_t,因为 Linux 保证对sig_atomic_t变量的读写操作都是原子的(不会被信号中断)。

也就是说,只需要改变一行代码:

volatile sig_atomic_t quit = 0;

前面我们说到,当进程在执行某个信号 handler 的过程中,可能会被另一个信号 handler 中断。这也会导致并发安全的问题。为了保证并发安全,在信号 handler 中,我们只能调用异步信号安全的函数,这类函数要不就是可重入的,要不就是不会被信号 handler 中断的。

可以使用man 7 signal命令查看哪些系统调用是异步信号安全的。常见的函数,譬如printf()和exit()就不是异步信号安全的,所以在信号 handler 可以使用write()来替代printf(),使用_exit()来替代exit()。具体可以这样做:

void sig_safe_error(const char *msg)

{

write(STDERR_FILENO, msg, strlen(msg));

_exit(1);

}

void sig_safe_print(const char *msg)

{

write(STDOUT_FILENO, msg, strlen(msg));

}

void sigint_handler(int sig)

{

sig_safe_error("receive signal SIGINT");

}

int main()

{

signal(SIGINT, sigint_handler);

// ...

}

I/O 多路复用与信号

在 Linux 中处理信号是极为麻烦的事情,正如 Linux 标准指出的,当select()、poll()和epoll_wait()被信号中断之后,它们是决不会重启的,所以说如果这些函数被信号中断,我们只好手动重启它们:

while (true) {

int n = epoll_wait(/** ... **/);

if (n == -1 && errno == EINTR) {

continue;

} else {

// ...

}

}

所幸的是 Linux 提供了signalfd()函数,signalfd()可以将接收到的信号,转化为文件描述符的可读事件,所以signalfd()可以和 select/poll/epoll 配合使用,大大简化信号处理的难度。

下面的例子将signalfd()与 epoll 配合使用,signalfd()负责将接收到的SIGINT和SIGHUP转换为文件描述符的可读事件:

#include #include #include #include #include #include #include #include int main()

{

// 屏蔽信号 SIGINT 和 SIGHUP

sigset_t mask;

sigemptyset(&mask);

sigaddset(&mask, SIGINT);

sigaddset(&mask, SIGHUP);

sigprocmask(SIG_BLOCK, &mask, NULL);

int signal_fd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);

int epoll_fd = epoll_create1(EPOLL_CLOEXEC);

struct epoll_event event;

memset(&event, 0, sizeof(event));

event.events = EPOLLIN;

event.data.fd = signal_fd;

epoll_ctl(epoll_fd, EPOLL_CTL_ADD, signal_fd, &event);

const int MAX_EVENTS = 64;

struct epoll_event events[MAX_EVENTS];

while (true)

{

int n = epoll_wait(epoll_fd, &events[0], MAX_EVENTS, 0);

for (int i = 0; i < n; ++i)

{

if (events[i].data.fd == signal_fd)

{

struct signalfd_siginfo info;

ssize_t bytes = read(signal_fd, &info, sizeof(info));

assert(bytes == sizeof(info));

if (info.ssi_signo == SIGINT)

{

printf("receive signal SIGINT\n");

}

else if (info.ssi_signo == SIGHUP)

{

printf("receive signal SIGHUP\n");

}

printf("Program quit!\n");

return 0;

}

}

}

return 0;

}

跨平台代码

通常来说,我们可以使用signal()为信号注册一个 handler,然而在编写跨平台代码时,则不应该使用signal(),因为signal()在不同平台会表现出不一致的行为。譬如说,在某些 Uninx 系统中,signal()会出现这样的行为:

每次调用信号 handler 之后,signal()会自动将信号的 handler 重置成SIG_DFL。

类似于read()和write()这类调用,如果它们被信号 handler 中断,是不会自动重启的。

如果考虑编写跨平台代码,所以我们的程序中应该使用sigaction()来代替signal()。当然,sigaction()的使用比较复杂,所以我们提供了一个Signal(),可以用来代替signal()函数:

typedef void (*sighandler_t)(int);

sighandler_t Signal(int signum, sighandler_t handler)

{

struct sigaction action, old_action;

action.sa_handler = handler;

sigemptyset(&action.sa_mask); // Block sigs of type being handled

action.sa_flags = SA_RESTART; // Restart syscalls if possible

if (sigaction(signum, &action, &old_action) < 0)

{

perror("sigaction");

exit(-1);

}

return (old_action.sa_handler);

}

参考资料

Unix Network Programming, Volume 1: The Sockets Networking API (3rd Edition)

Computer Systems: A Programmer’s Perspective (3rd Edition)

Handling signals with signalfd

转自:

http://senlinzhan.github.io/2017/03/02/linux-signal/

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值