寫在前面
覺得本頁面排版單調的話,可以嘗試到這裡看。
問題背景
在 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
的原因吧。