我的服務程序被 SIGPIPE 信號給搞崩了!
就在前幾天,我們在灰度上線時遇到了一個服務程序閃退的問題。最后排查的結果是因為一個小小的網絡 SIGPIPE 信號導致的這個嚴重問題。
今天,我就用一篇文章來介紹下 SIGPIPE 信號是如何發生的、為啥該信號會導致進程的閃退、遇到這種問題該如何解決。
讓我們開啟今天的內核原理學習之旅!
故障背景
我們對某個核心 Go 服務進行了 Rust 重構。由于源碼太多,全部重構又不太現實。所以我們采用的方案是將部分代碼用 Rust 重構掉。在服務進程中,Go 和 Rust 通過 cgo 進行通信。
但該新服務在線上遇到了崩潰的問題。而且崩潰還不是因為它自己,而是它依賴的另一個業務進程熱升級的時候出現的。只要對該依賴熱升級,就會導致該新服務崩潰退出,進而導致線上 SLA 出現較為嚴重的下降。
好在是灰度階段,影響不大。當時臨時禁止熱升級后規避了這個問題。但服務進程有概率崩潰終究可不是小事,萬一哪天誰不知道,一把線上梭哈升級那可就完犢子了。于是我立即停下了所有手頭的工作,幫大伙兒開始排查這個問題。
遇到這種問題,大家第一反應是看日志。但不幸的是在業務日志中沒有找到任何線索。然后我的思路是找 coredump 文件單步調試一下,看看崩潰發生在代碼的哪一行,結果發現這次崩潰連 core 文件都沒有留下,悄無聲息的就消失了。
經過七七四十九小時的激情排查后,最終的發現竟然是因為一個小小的網絡 SIGPIPE 信號導致的。接下來修改代碼,通過設置進程對 SIGPIPE 信號處理方式為 SIGIGN(忽略) 后徹底根治了該問題。
問題是解決了。但我還不滿足,想正好借此機會深入地給大家介紹一下內核中信號的工作原理。抽了周末整整兩天,寫出了本篇文章。
接下來的文章我分三大部分給大家講解:
- SIGPIPE 信號是如何發生的,帶大家看看為什么連接異常會導致 SIGPIPE 的發生
- 內核 SIGPIPE 信號處理流程,帶大家看看為什么內核默認遇到 SIGPIPE 時會將應用給殺死
- 應用層該如何應對 SIGPIPE,帶大家看語言運行時以及我們自己的程序如何規避該問題
一、SIGPIPE 信號如何發生
但內核對象是不允許我們隨便訪問的。我們平時在用戶態程序中看到的 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 了。如果沒有設置,就會走到內核的默認行為中,導致程序終止。
顯然我遇到的問題就讓注釋中這句話給說完了。
總結
好了,最后我們再總結一下。我們的應用程序會崩潰的原因是這樣的:
- 服務依賴的程序熱升級的時候有連接異常斷開
- 服務并不知道連接異常,還是正常向連接里發送數據
- 內核在處理數據發送時發現,該連接已經異常中斷了,直接給應用程序發送一個 SIGPIPE 信號
- 服務程序會進入到信號處理流程中
- 由于應用程序未對 SIGPIPE 定義處理邏輯,所以走的是內核默認行為
- 內核對于 SIGPIPE 的默認行為是終止程序運行,但不生成 coredump 文件
Golang 為了避免網絡斷連把程序搞崩在語言運行時中,設置了對非 0、1 文件句柄的默認處理行為為忽略。但是對于我使用的 Go 在進程內通過 cgo 訪問 Rust 代碼的情況并沒有很好地處理。
最終導致在 SIGPIPE 信號發生時,進入到了內核的默認處理行為中,服務程序退出且不留 coredump。
線上問題最難的地方在于定位根因,一但根因定位出來了,處理起來就簡單多了。最后我在 Rust 代碼中配置了對 SIGPIPE 的處理行為為 SIGIGN 后問題就徹底搞定了!!