大家好,我是飞哥!
就在前几天,我们在灰度上线时遇到了一个服务程序闪退的问题。最后排查的结果是因为一个小小的网络 SIGPIPE 信号导致的这个严重问题。
今天,我就用一篇文章来介绍下 SIGPIPE 信号是如何发生的、为啥该信号会导致进程的闪退、遇到这种问题该如何解决。
让我们开启今天的内核原理学习之旅!
故障背景
我们对某个核心 Go 服务进行了 Rust 重构。由于源码太多,全部重构又不太现实。所以我们采用的方案是将部分代码用 Rust 重构掉。在服务进程中,Go 和 Rust 通过 cgo 进行通信。
但该新服务在线上遇到了崩溃的问题。而且崩溃还不是因为它自己,而是它依赖的另一个业务进程热升级的时候出现的。只要对该依赖热升级,就会导致该新服务崩溃退出,进而导致线上 SLA 出现较为严重的下降。
好在是灰度阶段,影响不大。当时临时禁止热升级后规避了这个问题。但服务进程有概率崩溃终究可不是小事,万一哪天谁不知道,一把线上梭哈升级那可就完犊子了。于是我立即停下了所有手头的工作,帮大伙儿开始排查这个问题。
遇到这种问题,大家第一反应是看日志。但不幸的是在业务日志中没有找到任何线索。然后我的思路是找 coredump 文件单步调试一下,看看崩溃发生在代码的哪一行,结果发现这次崩溃连 core 文件都没有留下,悄无声息的就消失了。
经过七七四十九小时的激情排查后,最终的发现竟然是因为一个小小的网络 SIGPIPE 信号导致的。接下来修改代码,通过设置进程对 SIGPIPE 信号处理方式为 SIGIGN(忽略) 后彻底根治了该问题。
问题是解决了。但我还不满足,想正好借此机会深入地给大家介绍一下内核中信号的工作原理。抽了周末整整两天,写出了本篇文章。
接下来的文章我分三大部分给大家讲解:
SIGPIPE 信号是如何发生的,带大家看看为什么连接异常会导致 SIGPIPE 的发生 内核 SIGPIPE 信号处理流程,带大家看看为什么内核默认遇到 SIGPIPE 时会将应用给杀死 应用层该如何应对 SIGPIPE,带大家看语言运行时以及我们自己的程序如何规避该问题
一、SIGPIPE 信号如何发生
在《深入理解 Linux 网络》中我们介绍过,TCP 三次握手成功后会在内核中生成一个 socket 内核对象,通过该内核对象来表示一条 TCP 连接。但内核对象是不允许我们随便访问的。我们平时在用户态程序中看到的 socket 其实只是一个句柄而已,并不是真正的 socket 对象。
假如由于网络、对端重启等问题这条 TCP 连接断开了。此时我们的用户态程序根本是不知情的。很有可能还会调用 send、write 等系统调用往 socket 里面发送数据。
当数据包发送过程走到内核中的时候,内核是知道这个 socket 已经断开了的。就会给当前进程发送一个 SIGPIPE 信号。
我们来看下具体的源码。内核的发送会走到 do_tcp_sendpages 函数,在这里内核如果发现该 socket 已经 在这种情况下,会调用 sk_stream_error 函数。
//file:net/core/stream.c
ssize_t do_tcp_sendpages(struct sock *sk, struct page *page, int offset,
size_t size, int flags)
{
......
err = -EPIPE;
if (sk->sk_err || (sk->sk_shutdown & SEND_SHUTDOWN))
goto out_err;
out_err:
return sk_stream_error(sk, flags, err);
}
sk_stream_error 函数主要工作就是给正在 current(发送数据的进程)发送一个 SIGPIPE 信号。
int sk_stream_error(struct sock *sk, int flags, int err)
{
......
if (err == -EPIPE && !(flags & MSG_NOSIGNAL))
send_sig(SIGPIPE, current, 0);
return err;
}
二、内核 SIGPIPE 信号处理流程
上一节我们看到如果遇到网络连接异常断开,内核会给当前进程发送一个 SIGPIPE 信号。那么为啥这个信号就能把服务程序给搞崩而且没留下 coredump 文件呢?
简单来说,这是 Linux 内核对 SIGPIPE 信号处理的默认行为。飞哥喝口水,接着给你说。
目标进程每当从内核态返回用户态的过程中,会检测是否有挂起的信号。如果有信号存在,就会进入到信号的处理过程中,会执行到 do_notify_resume,然后再进到核心函数 do_signal。我们直接把 do_signal 的源码翻出来。
//file:arch/x86/kernel/signal.c
static void do_signal(struct pt_regs *regs)
{
struct ksignal ksig;
...
if (get_signal(&ksig)) {
/* Whee! Actually deliver the signal. */
handle_signal(&ksig, regs);
return;
}
...
}
在 do_signal 主要包含 get_signal 和 handle_signal 两个操作。
内核在 get_signal 中是获取一个信号。值得注意的是,内核获取到信号后,还会判断信号的关联行为。如果发现这个信号内核可以处理,内核直接就操作了。
如果内核发现获得到的信号内核需要交接给用户态程序处理,才会在 get_signal 函数中返回。接着再把信号交给 handle_signal 函数,由该函数来为用户空间准备好处理信号的环境,进行后面的处理。
服务程序在收到 SIGPIPE 会导致进程崩溃的关键就藏在这个 get_signal 函数里。
//file:kernel/signal.c
bool get_signal(struct ksignal *ksig)
{
...
for (;;) {
// 1.取出信号
signr = dequeue_synchronous_signal(&ksig->info);
if (!signr)
signr = dequeue_signal(current, ¤t->blocked,
&ksig->info, &type);
// 2.判断用户进程是否为信号配置了 handler
// 2.1 如果是 SIG_IGN(ignore的缩写),就跳过
if (ka->sa.sa_handler == SIG_IGN)
continue;
// 2.3 判断如果不是 SIG_DFL(default的缩写),
// 则证明用户定义了处理函数,break 退出循环后返回信号对象
if (ka->sa.sa_handler != SIG_DFL) {
ksig->ka = *ka;
...
break;
}
// 3.接下来就是内核的默认行为了
......
}
out:
ksig->sig = signr;
return ksig->sig > 0;
}
在 get_signal 函数里主要做了三件事。
一是通过 dequeue_xxx 函数来获取一个信号 二是判断下用户进程是否为信号配置了 handler。如果用户配置的是 SIG_IGN 直接跳过就行了,如果配置了处理函数,get_signal 就会将信号返回交给后面的流程交给用户态程序执行。 三是如果用户没配置 handler,则会进入到内核默认行为中。
由于我们的服务程序没对 SIG_PIPE 信号配过任何处理逻辑,所以 get_signal 在遇到 SIG_PIPE 时会进入到第三步 -- 内核默认行为处理。
我们来继续看看,内核的默认行为究竟是啥样的。
//file:kernel/signal.c
bool get_signal(struct ksignal *ksig)
{
...
for (;;) {
// 1.取出信号
......
// 2.判断信号是否配置了 handler
......
// 3.接下来就是内核的默认行为了
// 3.1 如果是可以忽略的信号,直接跳过
if (sig_kernel_ignore(signr)) /* Default is nothing. */
continue;
// 3.2 判断是否是暂停执行信号,是则暂停其运行
if (sig_kernel_stop(signr)) {
do_signal_stop(ksig->info.si_signo)
}
fatal:
// 3.3 判断是否需要 coredump
// coredump 会杀死进程下的所有线程,并生成 coredump 文件
if (sig_kernel_coredump(signr)) {
do_coredump(&ksig->info);
}
// 3.4 对于非以上情形的信号
// 直接让进程下所有线程退出,并且不生成coredump
do_group_exit(ksig->info.si_signo);
}
......
}
内核默认行为大概是分成四种。
第一种是默认要忽略的信号。从内核源码里可以看到 SIGCONT、SIGCHLD、SIGWINCH 和 SIGURG,这几个信号内核都是默认忽略的。
//file: include/linux/signal.h
#define sig_kernel_ignore(sig) siginmask(sig, SIG_KERNEL_IGNORE_MASK)
#define SIG_KERNEL_IGNORE_MASK (\
rt_sigmask(SIGCONT) | rt_sigmask(SIGCHLD) | \
rt_sigmask(SIGWINCH) | rt_sigmask(SIGURG) )
第二种是暂停信号。内核对 SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU 这几个信号的默认行为是暂停进程运行。
是的,你没猜错。各个 IDE 中集成的代码断点调试器就是使用 SIGSTOP 信号来工作的。调试器给被调试进程发送 SIGSTOP 信号,让其进入停止状态。等到需要继续运行的时候,再发送 SIGCONT 信号让被调试进程继续运行。
理解了 SIGSTOP 你也就理解调试器的底层工作原理了。调试器通过 SIGSTOP 和 SIGCONT 等信号将被调试进程玩弄于股掌之间!
//file: include/linux/signal.h
#define sig_kernel_stop(sig) siginmask(sig, SIG_KERNEL_STOP_MASK)
#define SIG_KERNEL_STOP_MASK (\
rt_sigmask(SIGSTOP) | rt_sigmask(SIGTSTP) | \
rt_sigmask(SIGTTIN) | rt_sigmask(SIGTTOU) )
第三种是需要终止程序运行,并生成 coredump 文件的信号。通过源码我们可以看到 SIGQUIT、SIGILL、SIGTRAP、SIGABRT、SIGABRT、SIGFPE、SIGSEGV、SIGBUS、SIGSYS、SIGXCPU、SIGXFSZ 这些信号的默认行为走这个逻辑。
我们以 SIGSEGV 为例,当应用程序试图访问空指针、数组越界访问等无效的内存操作时,内核会给当前进程发送 SIGSEGV 信号。
内核对于这些信号的默认行为就是会调用 do_coredump 内核函数。这个函数会杀死目标程序所有线程的运行,并生成 coredump 文件。
我们线上遇到的绝大部分程序崩溃都是这一类。
//file: include/linux/signal.h
#define sig_kernel_coredump(sig) siginmask(sig, SIG_KERNEL_COREDUMP_MASK)
#define SIG_KERNEL_COREDUMP_MASK (\
rt_sigmask(SIGQUIT) | rt_sigmask(SIGILL) | \
rt_sigmask(SIGTRAP) | rt_sigmask(SIGABRT) | \
rt_sigmask(SIGFPE) | rt_sigmask(SIGSEGV) | \
rt_sigmask(SIGBUS) | rt_sigmask(SIGSYS) | \
rt_sigmask(SIGXCPU) | rt_sigmask(SIGXFSZ) | \
SIGEMT_MASK )
但是看了这么多信号名了,还是找不到我们开篇提到的 SIGPIPE,好气!!!
最后仔细看完代码以后,发现对于非上面提到的信号外,对于其它的所有信号包括 SIGPIPE 的默认行为都是调用 do_group_exit。这个内核函数的行为也是杀死进程下的所有线程,但不生成 coredump 文件!!!
三、应用层如何应对 SIGPIPE
看完前面两节,我们彻底弄明白了为什么我们的应用程序会崩溃了。
事故大体逻辑是这样的:
1.服务依赖的程序热升级的时候有连接异常断开 2.服务并不知道连接异常,还是正常向连接里发送数据 3.内核在处理数据发送时发现,该连接已经异常中断了,直接给应用程序发送一个 SIGPIPE 信号 4.服务程序会进入到信号处理流程中 5.由于应用程序未对 SIGPIPE 定义处理逻辑,所以走的是内核默认行为 6.内核对于 SIGPIPE 的默认行为是终止程序运行,但不生成 coredump 文件
弄懂了崩溃发生的原因,解决方法自然就明朗了。只需要在应用程序中定义对 SIGPIPE 的处理逻辑就行了。我在项目中增加了以下简单的几行代码。
// 设置 SIGPIPE 的信号处理器为忽略
let ignore_action = SigAction::new(
SigHandler::SigIgn, // SigIgn表示忽略信号
signal::SaFlags::empty(),
SigSet::empty(),
);
// 注册信号处理器
unsafe {
signal::sigaction(Signal::SIGPIPE, &ignore_action)
.expect("Failed to set SIGPIPE handler to ignore");
}
这样就不会走到内核在处理 SIGPIPE 信号时,在 get_signal 函数中发现用户进程设置了 SIGPIPE 信号的行为是 SIG_IGN,则就直接跳过,再也不会把进程杀死了。
//file:kernel/signal.c
bool get_signal(struct ksignal *ksig)
{
...
for (;;) {
// 1.取出信号
...
// 2.判断用户进程是否为信号配置了 handler
// 2.1 如果是 SIG_IGN(ignore的缩写),就跳过
if (ka->sa.sa_handler == SIG_IGN)
continue;
// 3.接下来就是内核的默认行为了
...
}
...
}
不少同学可能会好奇,为啥我的进程中从来没处理过 SIGPIPE 信号,咋就没遇到过这种诡异的崩溃问题呢?
原因是 Golang 等语言运行时会替我们做好这个设置。但我的开发场景是使用 Golang 作为宿主,又通过 cgo 调用了 Rust 的动态链接库。而 Golang 并没有针对这种场景做好处理。
Golang 语言运行时的处理行为解释参见 Go 源码的 os/signal/doc.go 文件中的注释。
If the program has not called Notify to receive SIGPIPE signals, then
the behavior depends on the file descriptor number. A write to a
broken pipe on file descriptors 1 or 2 (standard output or standard
error) will cause the program to exit with a SIGPIPE signal. A write
to a broken pipe on some other file descriptor will take no action on
the SIGPIPE signal, and the write will fail with an EPIPE error.
这段注释清晰地说了 Go 语言运行时对于 SIGPIPE 信号处理
如果 fd 是 stdout、stderr,那么程序收到 SIGPIPE 信号,默认行为是程序会退出; 如果是其他 fd(比如 TCP 连接),程序收到SIGPIPE信号,不采取任何动作,返回一个EPIPE错误即可
对于 cgo 场景,Go 的源码注释中讲了很多,我把其中最关键的一句摘出来
If the SIGPIPE is received on a non-Go thread the signal will
be forwarded to the non-Go handler, if any; if there is none the
default system handler will cause the program to terminate.
如果 SIGPIPE 是在非 go 线程上执行,那么就取决于另一个语言运行时有没有设置 handler 了。如果没有设置,就会走到内核的默认行为中,导致程序终止。
显然我遇到的问题就让注释中这句话给说完了。
总结
好了,最后我们再总结一下。我们的应用程序会崩溃的原因是这样的:
1.服务依赖的程序热升级的时候有连接异常断开 2.服务并不知道连接异常,还是正常向连接里发送数据 3.内核在处理数据发送时发现,该连接已经异常中断了,直接给应用程序发送一个 SIGPIPE 信号 4.服务程序会进入到信号处理流程中 5.由于应用程序未对 SIGPIPE 定义处理逻辑,所以走的是内核默认行为 6.内核对于 SIGPIPE 的默认行为是终止程序运行,但不生成 coredump 文件
Golang 为了避免网络断连把程序搞崩在语言运行时中,设置了对非 0、1 文件句柄的默认处理行为为忽略。但是对于我使用的 Go 在进程内通过 cgo 访问 Rust 代码的情况并没有很好地处理。
最终导致在 SIGPIPE 信号发生时,进入到了内核的默认处理行为中,服务程序退出且不留 coredump。
线上问题最难的地方在于定位根因,一但根因定位出来了,处理起来就简单多了。最后我在 Rust 代码中配置了对 SIGPIPE 的处理行为为 SIGIGN 后问题就彻底搞定了!