我的服務(wù)程序被 SIGPIPE 信號(hào)給搞崩了!
就在前幾天,我們?cè)诨叶壬暇€時(shí)遇到了一個(gè)服務(wù)程序閃退的問(wèn)題。最后排查的結(jié)果是因?yàn)橐粋€(gè)小小的網(wǎng)絡(luò) SIGPIPE 信號(hào)導(dǎo)致的這個(gè)嚴(yán)重問(wèn)題。
今天,我就用一篇文章來(lái)介紹下 SIGPIPE 信號(hào)是如何發(fā)生的、為啥該信號(hào)會(huì)導(dǎo)致進(jìn)程的閃退、遇到這種問(wèn)題該如何解決。
讓我們開(kāi)啟今天的內(nèi)核原理學(xué)習(xí)之旅!
故障背景
我們對(duì)某個(gè)核心 Go 服務(wù)進(jìn)行了 Rust 重構(gòu)。由于源碼太多,全部重構(gòu)又不太現(xiàn)實(shí)。所以我們采用的方案是將部分代碼用 Rust 重構(gòu)掉。在服務(wù)進(jìn)程中,Go 和 Rust 通過(guò) cgo 進(jìn)行通信。
但該新服務(wù)在線上遇到了崩潰的問(wèn)題。而且崩潰還不是因?yàn)樗约?,而是它依?lài)的另一個(gè)業(yè)務(wù)進(jìn)程熱升級(jí)的時(shí)候出現(xiàn)的。只要對(duì)該依賴(lài)熱升級(jí),就會(huì)導(dǎo)致該新服務(wù)崩潰退出,進(jìn)而導(dǎo)致線上 SLA 出現(xiàn)較為嚴(yán)重的下降。
好在是灰度階段,影響不大。當(dāng)時(shí)臨時(shí)禁止熱升級(jí)后規(guī)避了這個(gè)問(wèn)題。但服務(wù)進(jìn)程有概率崩潰終究可不是小事,萬(wàn)一哪天誰(shuí)不知道,一把線上梭哈升級(jí)那可就完?duì)僮恿恕S谑俏伊⒓赐O铝怂惺诸^的工作,幫大伙兒開(kāi)始排查這個(gè)問(wèn)題。
遇到這種問(wèn)題,大家第一反應(yīng)是看日志。但不幸的是在業(yè)務(wù)日志中沒(méi)有找到任何線索。然后我的思路是找 coredump 文件單步調(diào)試一下,看看崩潰發(fā)生在代碼的哪一行,結(jié)果發(fā)現(xiàn)這次崩潰連 core 文件都沒(méi)有留下,悄無(wú)聲息的就消失了。
經(jīng)過(guò)七七四十九小時(shí)的激情排查后,最終的發(fā)現(xiàn)竟然是因?yàn)橐粋€(gè)小小的網(wǎng)絡(luò) SIGPIPE 信號(hào)導(dǎo)致的。接下來(lái)修改代碼,通過(guò)設(shè)置進(jìn)程對(duì) SIGPIPE 信號(hào)處理方式為 SIGIGN(忽略) 后徹底根治了該問(wèn)題。
問(wèn)題是解決了。但我還不滿足,想正好借此機(jī)會(huì)深入地給大家介紹一下內(nèi)核中信號(hào)的工作原理。抽了周末整整兩天,寫(xiě)出了本篇文章。
接下來(lái)的文章我分三大部分給大家講解:
- SIGPIPE 信號(hào)是如何發(fā)生的,帶大家看看為什么連接異常會(huì)導(dǎo)致 SIGPIPE 的發(fā)生
- 內(nèi)核 SIGPIPE 信號(hào)處理流程,帶大家看看為什么內(nèi)核默認(rèn)遇到 SIGPIPE 時(shí)會(huì)將應(yīng)用給殺死
- 應(yīng)用層該如何應(yīng)對(duì) SIGPIPE,帶大家看語(yǔ)言運(yùn)行時(shí)以及我們自己的程序如何規(guī)避該問(wèn)題
一、SIGPIPE 信號(hào)如何發(fā)生
但內(nèi)核對(duì)象是不允許我們隨便訪問(wèn)的。我們平時(shí)在用戶態(tài)程序中看到的 socket 其實(shí)只是一個(gè)句柄而已,并不是真正的 socket 對(duì)象。
假如由于網(wǎng)絡(luò)、對(duì)端重啟等問(wèn)題這條 TCP 連接斷開(kāi)了。此時(shí)我們的用戶態(tài)程序根本是不知情的。很有可能還會(huì)調(diào)用 send、write 等系統(tǒng)調(diào)用往 socket 里面發(fā)送數(shù)據(jù)。
圖片
當(dāng)數(shù)據(jù)包發(fā)送過(guò)程走到內(nèi)核中的時(shí)候,內(nèi)核是知道這個(gè) socket 已經(jīng)斷開(kāi)了的。就會(huì)給當(dāng)前進(jìn)程發(fā)送一個(gè) SIGPIPE 信號(hào)。
我們來(lái)看下具體的源碼。內(nèi)核的發(fā)送會(huì)走到 do_tcp_sendpages 函數(shù),在這里內(nèi)核如果發(fā)現(xiàn)該 socket 已經(jīng) 在這種情況下,會(huì)調(diào)用 sk_stream_error 函數(shù)。
//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 函數(shù)主要工作就是給正在 current(發(fā)送數(shù)據(jù)的進(jìn)程)發(fā)送一個(gè) SIGPIPE 信號(hào)。
int sk_stream_error(struct sock *sk, int flags, int err)
{
......
if (err == -EPIPE && !(flags & MSG_NOSIGNAL))
send_sig(SIGPIPE, current, 0);
return err;
}
二、內(nèi)核 SIGPIPE 信號(hào)處理流程
上一節(jié)我們看到如果遇到網(wǎng)絡(luò)連接異常斷開(kāi),內(nèi)核會(huì)給當(dāng)前進(jìn)程發(fā)送一個(gè) SIGPIPE 信號(hào)。那么為啥這個(gè)信號(hào)就能把服務(wù)程序給搞崩而且沒(méi)留下 coredump 文件呢?
簡(jiǎn)單來(lái)說(shuō),這是 Linux 內(nèi)核對(duì) SIGPIPE 信號(hào)處理的默認(rèn)行為。飛哥喝口水,接著給你說(shuō)。
目標(biāo)進(jìn)程每當(dāng)從內(nèi)核態(tài)返回用戶態(tài)的過(guò)程中,會(huì)檢測(cè)是否有掛起的信號(hào)。如果有信號(hào)存在,就會(huì)進(jìn)入到信號(hào)的處理過(guò)程中,會(huì)執(zhí)行到 do_notify_resume,然后再進(jìn)到核心函數(shù) do_signal。我們直接把 do_signal 的源碼翻出來(lái)。
//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 兩個(gè)操作。
內(nèi)核在 get_signal 中是獲取一個(gè)信號(hào)。值得注意的是,內(nèi)核獲取到信號(hào)后,還會(huì)判斷信號(hào)的關(guān)聯(lián)行為。如果發(fā)現(xiàn)這個(gè)信號(hào)內(nèi)核可以處理,內(nèi)核直接就操作了。
如果內(nèi)核發(fā)現(xiàn)獲得到的信號(hào)內(nèi)核需要交接給用戶態(tài)程序處理,才會(huì)在 get_signal 函數(shù)中返回。接著再把信號(hào)交給 handle_signal 函數(shù),由該函數(shù)來(lái)為用戶空間準(zhǔn)備好處理信號(hào)的環(huán)境,進(jìn)行后面的處理。
服務(wù)程序在收到 SIGPIPE 會(huì)導(dǎo)致進(jìn)程崩潰的關(guān)鍵就藏在這個(gè) get_signal 函數(shù)里。
//file:kernel/signal.c
bool get_signal(struct ksignal *ksig)
{
...
for (;;) {
// 1.取出信號(hào)
signr = dequeue_synchronous_signal(&ksig->info);
if (!signr)
signr = dequeue_signal(current, ¤t->blocked,
&ksig->info, &type);
// 2.判斷用戶進(jìn)程是否為信號(hào)配置了 handler
// 2.1 如果是 SIG_IGN(ignore的縮寫(xiě)),就跳過(guò)
if (ka->sa.sa_handler == SIG_IGN)
continue;
// 2.3 判斷如果不是 SIG_DFL(default的縮寫(xiě)),
// 則證明用戶定義了處理函數(shù),break 退出循環(huán)后返回信號(hào)對(duì)象
if (ka->sa.sa_handler != SIG_DFL) {
ksig->ka = *ka;
...
break;
}
// 3.接下來(lái)就是內(nèi)核的默認(rèn)行為了
......
}
out:
ksig->sig = signr;
return ksig->sig > 0;
}
在 get_signal 函數(shù)里主要做了三件事。
- 一是通過(guò) dequeue_xxx 函數(shù)來(lái)獲取一個(gè)信號(hào)
- 二是判斷下用戶進(jìn)程是否為信號(hào)配置了 handler。如果用戶配置的是 SIG_IGN 直接跳過(guò)就行了,如果配置了處理函數(shù),get_signal 就會(huì)將信號(hào)返回交給后面的流程交給用戶態(tài)程序執(zhí)行。
- 三是如果用戶沒(méi)配置 handler,則會(huì)進(jìn)入到內(nèi)核默認(rèn)行為中。
由于我們的服務(wù)程序沒(méi)對(duì) SIG_PIPE 信號(hào)配過(guò)任何處理邏輯,所以 get_signal 在遇到 SIG_PIPE 時(shí)會(huì)進(jìn)入到第三步 -- 內(nèi)核默認(rèn)行為處理。
我們來(lái)繼續(xù)看看,內(nèi)核的默認(rèn)行為究竟是啥樣的。
//file:kernel/signal.c
bool get_signal(struct ksignal *ksig)
{
...
for (;;) {
// 1.取出信號(hào)
......
// 2.判斷信號(hào)是否配置了 handler
......
// 3.接下來(lái)就是內(nèi)核的默認(rèn)行為了
// 3.1 如果是可以忽略的信號(hào),直接跳過(guò)
if (sig_kernel_ignore(signr)) /* Default is nothing. */
continue;
// 3.2 判斷是否是暫停執(zhí)行信號(hào),是則暫停其運(yùn)行
if (sig_kernel_stop(signr)) {
do_signal_stop(ksig->info.si_signo)
}
fatal:
// 3.3 判斷是否需要 coredump
// coredump 會(huì)殺死進(jìn)程下的所有線程,并生成 coredump 文件
if (sig_kernel_coredump(signr)) {
do_coredump(&ksig->info);
}
// 3.4 對(duì)于非以上情形的信號(hào)
// 直接讓進(jìn)程下所有線程退出,并且不生成coredump
do_group_exit(ksig->info.si_signo);
}
......
}
內(nèi)核默認(rèn)行為大概是分成四種。
第一種是默認(rèn)要忽略的信號(hào)。從內(nèi)核源碼里可以看到 SIGCONT、SIGCHLD、SIGWINCH 和 SIGURG,這幾個(gè)信號(hào)內(nèi)核都是默認(rèn)忽略的。
//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) )
第二種是暫停信號(hào)。內(nèi)核對(duì) SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU 這幾個(gè)信號(hào)的默認(rèn)行為是暫停進(jìn)程運(yùn)行。
是的,你沒(méi)猜錯(cuò)。各個(gè) IDE 中集成的代碼斷點(diǎn)調(diào)試器就是使用 SIGSTOP 信號(hào)來(lái)工作的。調(diào)試器給被調(diào)試進(jìn)程發(fā)送 SIGSTOP 信號(hào),讓其進(jìn)入停止?fàn)顟B(tài)。等到需要繼續(xù)運(yùn)行的時(shí)候,再發(fā)送 SIGCONT 信號(hào)讓被調(diào)試進(jìn)程繼續(xù)運(yùn)行。
理解了 SIGSTOP 你也就理解調(diào)試器的底層工作原理了。調(diào)試器通過(guò) SIGSTOP 和 SIGCONT 等信號(hào)將被調(diào)試進(jìn)程玩弄于股掌之間!
//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) )
第三種是需要終止程序運(yùn)行,并生成 coredump 文件的信號(hào)。通過(guò)源碼我們可以看到 SIGQUIT、SIGILL、SIGTRAP、SIGABRT、SIGABRT、SIGFPE、SIGSEGV、SIGBUS、SIGSYS、SIGXCPU、SIGXFSZ 這些信號(hào)的默認(rèn)行為走這個(gè)邏輯。
我們以 SIGSEGV 為例,當(dāng)應(yīng)用程序試圖訪問(wèn)空指針、數(shù)組越界訪問(wèn)等無(wú)效的內(nèi)存操作時(shí),內(nèi)核會(huì)給當(dāng)前進(jìn)程發(fā)送 SIGSEGV 信號(hào)。
內(nèi)核對(duì)于這些信號(hào)的默認(rèn)行為就是會(huì)調(diào)用 do_coredump 內(nèi)核函數(shù)。這個(gè)函數(shù)會(huì)殺死目標(biāo)程序所有線程的運(yùn)行,并生成 coredump 文件。
我們線上遇到的絕大部分程序崩潰都是這一類(lèi)。
//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 )
但是看了這么多信號(hào)名了,還是找不到我們開(kāi)篇提到的 SIGPIPE,好氣?。?!
最后仔細(xì)看完代碼以后,發(fā)現(xiàn)對(duì)于非上面提到的信號(hào)外,對(duì)于其它的所有信號(hào)包括 SIGPIPE 的默認(rèn)行為都是調(diào)用 do_group_exit。這個(gè)內(nèi)核函數(shù)的行為也是殺死進(jìn)程下的所有線程,但不生成 coredump 文件!??!
三、應(yīng)用層如何應(yīng)對(duì) SIGPIPE
看完前面兩節(jié),我們徹底弄明白了為什么我們的應(yīng)用程序會(huì)崩潰了。
事故大體邏輯是這樣的:
- 1.服務(wù)依賴(lài)的程序熱升級(jí)的時(shí)候有連接異常斷開(kāi)
- 2.服務(wù)并不知道連接異常,還是正常向連接里發(fā)送數(shù)據(jù)
- 3.內(nèi)核在處理數(shù)據(jù)發(fā)送時(shí)發(fā)現(xiàn),該連接已經(jīng)異常中斷了,直接給應(yīng)用程序發(fā)送一個(gè) SIGPIPE 信號(hào)
- 4.服務(wù)程序會(huì)進(jìn)入到信號(hào)處理流程中
- 5.由于應(yīng)用程序未對(duì) SIGPIPE 定義處理邏輯,所以走的是內(nèi)核默認(rèn)行為
- 6.內(nèi)核對(duì)于 SIGPIPE 的默認(rèn)行為是終止程序運(yùn)行,但不生成 coredump 文件
弄懂了崩潰發(fā)生的原因,解決方法自然就明朗了。只需要在應(yīng)用程序中定義對(duì) SIGPIPE 的處理邏輯就行了。我在項(xiàng)目中增加了以下簡(jiǎn)單的幾行代碼。
// 設(shè)置 SIGPIPE 的信號(hào)處理器為忽略
let ignore_action = SigAction::new(
SigHandler::SigIgn, // SigIgn表示忽略信號(hào)
signal::SaFlags::empty(),
SigSet::empty(),
);
// 注冊(cè)信號(hào)處理器
unsafe {
signal::sigaction(Signal::SIGPIPE, &ignore_action)
.expect("Failed to set SIGPIPE handler to ignore");
}
這樣就不會(huì)走到內(nèi)核在處理 SIGPIPE 信號(hào)時(shí),在 get_signal 函數(shù)中發(fā)現(xiàn)用戶進(jìn)程設(shè)置了 SIGPIPE 信號(hào)的行為是 SIG_IGN,則就直接跳過(guò),再也不會(huì)把進(jìn)程殺死了。
//file:kernel/signal.c
bool get_signal(struct ksignal *ksig)
{
...
for (;;) {
// 1.取出信號(hào)
...
// 2.判斷用戶進(jìn)程是否為信號(hào)配置了 handler
// 2.1 如果是 SIG_IGN(ignore的縮寫(xiě)),就跳過(guò)
if (ka->sa.sa_handler == SIG_IGN)
continue;
// 3.接下來(lái)就是內(nèi)核的默認(rèn)行為了
...
}
...
}
不少同學(xué)可能會(huì)好奇,為啥我的進(jìn)程中從來(lái)沒(méi)處理過(guò) SIGPIPE 信號(hào),咋就沒(méi)遇到過(guò)這種詭異的崩潰問(wèn)題呢?
原因是 Golang 等語(yǔ)言運(yùn)行時(shí)會(huì)替我們做好這個(gè)設(shè)置。但我的開(kāi)發(fā)場(chǎng)景是使用 Golang 作為宿主,又通過(guò) cgo 調(diào)用了 Rust 的動(dòng)態(tài)鏈接庫(kù)。而 Golang 并沒(méi)有針對(duì)這種場(chǎng)景做好處理。
Golang 語(yǔ)言運(yùn)行時(shí)的處理行為解釋參見(jiàn) 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.
這段注釋清晰地說(shuō)了 Go 語(yǔ)言運(yùn)行時(shí)對(duì)于 SIGPIPE 信號(hào)處理
- 如果 fd 是 stdout、stderr,那么程序收到 SIGPIPE 信號(hào),默認(rèn)行為是程序會(huì)退出;
- 如果是其他 fd(比如 TCP 連接),程序收到SIGPIPE信號(hào),不采取任何動(dòng)作,返回一個(gè)EPIPE錯(cuò)誤即可
對(duì)于 cgo 場(chǎng)景,Go 的源碼注釋中講了很多,我把其中最關(guān)鍵的一句摘出來(lái)
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 線程上執(zhí)行,那么就取決于另一個(gè)語(yǔ)言運(yùn)行時(shí)有沒(méi)有設(shè)置 handler 了。如果沒(méi)有設(shè)置,就會(huì)走到內(nèi)核的默認(rèn)行為中,導(dǎo)致程序終止。
顯然我遇到的問(wèn)題就讓注釋中這句話給說(shuō)完了。
總結(jié)
好了,最后我們?cè)倏偨Y(jié)一下。我們的應(yīng)用程序會(huì)崩潰的原因是這樣的:
- 服務(wù)依賴(lài)的程序熱升級(jí)的時(shí)候有連接異常斷開(kāi)
- 服務(wù)并不知道連接異常,還是正常向連接里發(fā)送數(shù)據(jù)
- 內(nèi)核在處理數(shù)據(jù)發(fā)送時(shí)發(fā)現(xiàn),該連接已經(jīng)異常中斷了,直接給應(yīng)用程序發(fā)送一個(gè) SIGPIPE 信號(hào)
- 服務(wù)程序會(huì)進(jìn)入到信號(hào)處理流程中
- 由于應(yīng)用程序未對(duì) SIGPIPE 定義處理邏輯,所以走的是內(nèi)核默認(rèn)行為
- 內(nèi)核對(duì)于 SIGPIPE 的默認(rèn)行為是終止程序運(yùn)行,但不生成 coredump 文件
Golang 為了避免網(wǎng)絡(luò)斷連把程序搞崩在語(yǔ)言運(yùn)行時(shí)中,設(shè)置了對(duì)非 0、1 文件句柄的默認(rèn)處理行為為忽略。但是對(duì)于我使用的 Go 在進(jìn)程內(nèi)通過(guò) cgo 訪問(wèn) Rust 代碼的情況并沒(méi)有很好地處理。
最終導(dǎo)致在 SIGPIPE 信號(hào)發(fā)生時(shí),進(jìn)入到了內(nèi)核的默認(rèn)處理行為中,服務(wù)程序退出且不留 coredump。
線上問(wèn)題最難的地方在于定位根因,一但根因定位出來(lái)了,處理起來(lái)就簡(jiǎn)單多了。最后我在 Rust 代碼中配置了對(duì) SIGPIPE 的處理行為為 SIGIGN 后問(wèn)題就徹底搞定了??!