記一個openwrt reboot非同步訊號處理死鎖問題
寫在前面
覺得本頁面排版單調的話,可以嘗試到這裡看。
問題背景
在 openwrt
上碰到了一個偶現的 reboot
失效問題。執行 reboot
之後系統並沒有重啟,此時控制台還能工作。
初步排查
首先復現問題,發現復現後控制台仍可正常運行,但此時重複執行 reboot
也無效,執行 reboot -f
則可正常觸發重啟。
此處 reboot
是一個指向 busybox
的軟鏈接,從 help
資訊
-f Force (don't go through init)
中可以看出 reboot
和 reboot -f
的區別在於 reboot
會先通知 init
進程進行一系列操作,而 reboot -f
則直接調內核。
看下 busybox
源碼, 如果帶了 -f
則直接調用 C 庫的 reboot 函數
,如果沒有帶 -f
參數,則只會通過 kill
發訊號給 1號進程
。
if (!(flags & 4)) { /* no -f */
//TODO: I tend to think that signalling linuxrc is wrong
// pity original author didn't comment on it...
if (ENABLE_LINUXRC) {
/* talk to linuxrc */
/* bbox init/linuxrc assumed */
pid_t *pidlist = find_pid_by_name("linuxrc");
if (pidlist[0] > 0)
rc = kill(pidlist[0], signals[which]);
if (ENABLE_FEATURE_CLEAN_UP)
free(pidlist);
}
if (rc) {
/* talk to init */
if (!ENABLE_FEATURE_CALL_TELINIT) {
/* bbox init assumed */
rc = kill(1, signals[which]);
if (init_was_not_there())
rc = kill(1, signals[which]);
} else {
/* SysV style init assumed */
/* runlevels:
* 0 == shutdown
* 6 == reboot */
execlp(CONFIG_TELINIT_PATH,
CONFIG_TELINIT_PATH,
which == 2 ? "6" : "0",
(char *)NULL
);
bb_perror_msg_and_die("can't execute '%s'",
CONFIG_TELINIT_PATH);
}
}
} else {
rc = reboot(magic[which]);
}
目前 reboot -f
正常,那問題就出在用戶空間調用 reboot()
之前的操作中了。
現場分析
既然知道了 reboot
是通過發送訊號給 init
進程,那麼下一步自然就是搞清楚 init
進程為什麼卡住了。
出問題時控制台還能用,這是個好消息。先通過 ps
列出進程資訊看下,發現 procd
處於 S
狀態。
S interruptible sleep (waiting for an event to complete)`
但只知道這個沒太大作用,我們需要更多資訊,幸好 linux
還有 proc
文件系統
/proc 文件系統是一個虛擬文件系統, 最初開發 /proc 文件系統是為了提供有關係統中進程的資訊。但是由於這個文件系統非常有用,因此內核中的很多元素也開始使用它來報告資訊,或啟用動態運行時配置。
知道了某個進程的 pid
號。就可以在 /proc/<pid>
目錄下,獲取到大量的進程相關資訊。例如 cat /proc/1/status
查看狀態資訊 , cat /proc/1/stack
查看棧資訊。
$ cat /proc/1/stack
[<ffffff800808526c>] __switch_to+0x90/0xc4
[<ffffff80080f78c4>] futex_wait_queue_me+0xb8/0x108
[<ffffff80080f8018>] futex_wait+0xcc/0x1b4
[<ffffff80080f9728>] do_futex+0xdc/0x940
[<ffffff80080fa0c8>] SyS_futex+0x13c/0x148
[<ffffff800808325c>] __sys_trace+0x4c/0x4c
[<ffffffffffffffff>] 0xffffffffffffffff
從棧資訊看,似乎在等待某個鎖。
跟蹤工具
情況又清晰了一點,但還不夠,下一步用跟蹤工具看下。
先上 strace
, strace
是跟蹤進程行為的利器, 可以直接用 strace
來啟動一個程式,從頭開始跟蹤,例如 strace reboot
,也可以在程式運行過程中,通過指定 pid
動態 attach
上去,中途開始跟蹤,例如目前這種情況,在 reboot
之前先運行 strace -p 1
,即可觀察卡住前 1號進程
都執行了什麼操作。
從 strace
的輸出,加上我自己增加的一些 log
驗證,此時已經鎖定到問題出在一個列印語句中,展開後是對 vsyslog
的調用。init
就卡在這個調用中,一去不復返。
如果有 gdb
那就更簡單了,直接在卡住後連上去,看下 backtrace
,不僅能直接看到 init
調用了 vsyslog
,還能進一步看到是 glibc
內部在 vsyslog
中又調用了 realloc
,最終卡住。log
如下(本機的一些路徑資訊用 *** 代替了)
(gdb) bt
#0 0x0000007f8f5948e0 in __lll_lock_wait_private () from /lib/libc.so.6
#1 0x0000007f8f543420 in realloc () from /lib/libc.so.6
#2 0x0000007f8f539108 in _IO_mem_finish () from /lib/libc.so.6
#3 0x0000007f8f5316c8 in fclose@@GLIBC_2.17 () from /lib/libc.so.6
#4 0x0000007f8f586d94 in __vsyslog_chk () from /lib/libc.so.6
#5 0x0000007f8f6a727c in vsyslog (__ap=..., __fmt=0x40c98c "- shutdown -\n",
__pri=6)
at /***-glibc/toolchain/include/bits/syslog.h:47
#6 ulog_syslog (ap=..., fmt=0x40c98c "- shutdown -\n", priority=6)
at /***/compile_dir/target/libubox-2016-02-26/ulog.c:117
#7 ulog (priority=priority@entry=6, fmt=fmt@entry=0x40c98c "- shutdown -\n")
at /***/compile_dir/target/libubox-2016-02-26/ulog.c:172
#8 0x0000000000404c84 in state_enter ()
at /***/compile_dir/target/procd-2016-02-08/state.c:155
#9 0x0000000000404314 in signal_shutdown (signal=<optimized out>,
siginfo=<optimized out>, data=<optimized out>)
at /***/compile_dir/target/procd-2016-02-08/signal.c:61
#10 <signal handler called>
---Type <return> to continue, or q <return> to quit---
#11 0x0000007f8f565070 in fork () from /lib/libc.so.6
#12 0x000000000040b19c in queue_next ()
at /***/compile_dir/target/procd-2016-02-08/plug/hotplug.c:335
#13 0x0000007f8f6a3ce0 in uloop_handle_processes ()
at /***/compile_dir/target/libubox-2016-02-26/uloop.c:545
#14 uloop_run ()
at /***/compile_dir/target/libubox-2016-02-26/uloop.c:685
#15 0x0000000000404074 in main (argc=1, argv=0x7fdf7255c8)
at /***/compile_dir/target/procd-2016-02-08/procd.c:75
分析原因
找到了卡住的點,搜索一番,問題的原因也就很明顯了。這是一個非同步訊號安全問題。
前面說到 reboot
時是發送了一個訊號給 1號進程
, 而 1號進程procd
的這段出問題程式碼,正是在訊號處理函數中被調用的。
搜下 訊號處理 死鎖
之類的關鍵詞,就可以搜到很多人前仆後繼地踩了這個坑。訊號的到來會打斷正常的執行流程,轉而執行非同步訊號處理函數,由於不確定被打斷的位置,所以非同步訊號處理函數的編寫是很有講究的,只能調用非同步訊號安全的函數。可以在 man 7 signal
中找到這個非同步訊號安全函數的列表。太占篇幅這裡就不列了。
除了這些函數,其他的調用都不保證是安全的。本例中是調用了syslog
, 裡面執行了記憶體分配操作。此時如果訊號發生時正常流程中也在執行記憶體分配操作,那就可能發生死鎖,因為 glibc
中的記憶體分配操作是有鎖的,正常流程中上鎖之後被訊號打斷,訊號處理函數中又去拿這個鎖,就死鎖了。
此處要區分好 執行緒安全
和 非同步訊號安全
。例如
lock
do something
unlock
有鎖保護之後,多執行緒調用這段程式碼,任意時刻只有一個執行緒可拿到鎖,就保證只會有一個執行緒在執行中間的 do something
,但當某個執行緒拿到鎖後正在執行 do something
時,是可以被訊號打斷的。如果訊號處理函數中,也嘗試執行這段函數,那麼訊號處理函數就會卡在 lock
上一直拿不到鎖。
回到問題本身,這個問題的直接原因是訊號處理函數中調用了 LOG
,而展開後調用了不安全的 vsyslog
。
但解決問題不能只是簡單地注釋掉這行,這樣治標不治本,因為這個訊號處理函數中還調用了不少其他函數,都是有風險的。
要解決這個問題,還得完全按標準來,保證訊號處理函數中只調用非同步訊號安全的函數,才能永絕後患。
方案一
為了滿足非同步訊號安全,在訊號處理函數中編程就難免限制多多,束手束腳,申請個記憶體,加個列印,都有可能死鎖。
一個常用的方式是將非同步訊號處理改成同步訊號處理。思路就是將訊號屏蔽掉,專門開一個執行緒開處理訊號。
可以參考 Linux 多執行緒應用中如何編寫安全的訊號處理函數
這裡貼下 man pthread_sigmask
中的例子,主執行緒中先屏蔽一些訊號,然後創建了一個特定的執行緒,通過 sigwait
來檢測處理這些訊號。如此一來處理訊號就是在正常的上下文中完成的,不必考慮執行緒安全問題。
EXAMPLE
The program below blocks some signals in the main thread, and then creates a dedicated thread to fetch those signals via sigwait(3).
The following shell session demonstrates its use:
$ ./a.out &
[1] 5423
$ kill -QUIT %1
Signal handling thread got signal 3
$ kill -USR1 %1
Signal handling thread got signal 10
$ kill -TERM %1
[1]+ Terminated ./a.out
Program source
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>
/* Simple error handling functions */
#define handle_error_en(en, msg) \
do { errno = en; perror(msg); exit(EXIT_FAILURE); } while (0)
/* 訊號處理執行緒 */
static void *
sig_thread(void *arg)
{
sigset_t *set = arg;
int s, sig;
for (;;) {
s = sigwait(set, &sig); /* 主動等待指定的訊號集 */
if (s != 0)
handle_error_en(s, "sigwait");
printf("Signal handling thread got signal %d\n", sig); /* 進行訊號處理,此時不必局限於調用非同步訊號安全的函數 */
}
}
int
main(int argc, char *argv[])
{
pthread_t thread;
sigset_t set;
int s;
/* Block SIGQUIT and SIGUSR1; other threads created by main()
will inherit a copy of the signal mask. */
sigemptyset(&set); /* 創建一個空訊號集 */
sigaddset(&set, SIGQUIT); /* 將SIGQUIT加入訊號集 */
sigaddset(&set, SIGUSR1); /* 將SIGUSR1加入訊號集 */
s = pthread_sigmask(SIG_BLOCK, &set, NULL); /* 屏蔽訊號集,屏蔽後內核收到這些訊號,不會觸發任何非同步的訊號處理函數,只是登記下來 */
if (s != 0)
handle_error_en(s, "pthread_sigmask");
s = pthread_create(&thread, NULL, &sig_thread, (void *) &set); /* 創建訊號處理執行緒,傳入屏蔽的訊號集,也就是要同步處理的訊號集 */
if (s != 0)
handle_error_en(s, "pthread_create");
/* Main thread carries on to create other threads and/or do
other work */
pause(); /* Dummy pause so we can test program */
}
了解了這種同步訊號處理模型,那目前的問題能否套用呢 ? 很遺憾不行,因為這種方式需要屏蔽訊號,而訊號的屏蔽是會被 fork
繼承的,回到問題本身,這次的主角是 1號進程procd
,整個用戶空間的其他進程全是它的子進程,牽一髮而動全身,訊號屏蔽還是暫不考慮了。
方案二
既然不能屏蔽訊號,那非同步訊號處理函數就還是存在。可以考慮把原來的訊號處理函數做到事情挪出來,放到獨立的一個執行緒中去做,非同步訊號處理函數只負責通知下這個執行緒幹活。
怎麼通知呢? man 7 signal
看看有什麼非同步訊號安全的函數可以用,看起來 sim_post
似乎不錯。
首先初始化一個 semaphore
, 然後在訊號處理執行緒中調用 sem_wait
, 等到後執行實際的訊號處理 , 而在非同步訊號處理函數中僅調用 sem_post
,起到通知的作用。
這個方案的問題在於引入了多執行緒。本來 procd
是單執行緒的,其中用到的 uloop
等也並未考慮多執行緒下的執行緒安全,因此這裡是有風險的,搞不好解 bug
就變成寫 bug
了。
方案三
方案二的思路是沒問題的,非同步訊號處理函數中只做最簡單的事情,安全可靠,實際上的複雜操作留給正常的執行緒處理。
如果要避免多執行緒,那就得想辦法在主執行緒中加入對訊號的等待和處理,然後只在訊號處理函數中進行簡單操作,觸發主執行緒處理。
具體的實現就多種多樣了,例如最簡單的,訊號處理函數中將訊號記錄到全局變數中,主執行緒輪詢。但輪詢消耗資源呀,所以更好的做法是主執行緒阻塞在某個操作上,在訊號到來打斷這個阻塞操作後進行處理。
對於 procd
,其循環是使用的 uloop
,而 uloop
中會使用 epoll
監控指定的 fd
,並調用回調函數。
看看訊號安全函數列表,read
和 write
都是非同步訊號安全的函數,由此我們可以開一個 pipe
或者 socket
,一端由非同步訊號處理函數寫入,另一端由工作在正常進程上下文中的回調函數讀出並處理。
最終我們使用了方案三,具體的是使用了管道,並直接復用了 openwrt
的 ustream
,這裡展開就得涉及到 procd init
的工作流程分析了,後續有機會再寫吧。
有一點可以提下,方案一和二用在 procd
中還有一個問題,就是不能跟原有的 uloop
中的 epoll
順暢配合,會導致 reboot
要做的事情堆積在隊列中卻觸發不了處理,需要等其他事件來打斷這個 epoll
, 而方案三則沒有這個問題。這也是 procd
和 uloop
的實現導致的,暫不展開。
其他
訊號的細節還是蠻多的,例如同一訊號多次發生會怎樣,多個阻塞訊號的到達順序,進程級別的屏蔽處理和執行緒級別的屏蔽處理的差異,fork
和 exec
時的行為等。
非同步訊號同步化的方式,也有很多文章闡述,例如 signalfd
等本文都沒提及。
說回 procd
,為什麼原生的實現可以這麼任性,直接在訊號處理函數中調用非非同步訊號安全的函數呢? 這可能是 openwrt
默認 C庫
是用的 musl
的原因吧。
部落格://www.cnblogs.com/zqb-all/p/12735146.html