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















 
 
 










 
 
 
 