記一個openwrt reboot非同步訊號處理死鎖問題

all發表於2020-04-20

寫在前面

覺得本頁面排版單調的話,可以嘗試到這裡看。

問題背景

openwrt 上碰到了一個偶現的 reboot 失效問題。執行 reboot 之後系統並沒有重啟,此時控制檯還能工作。

初步排查

首先復現問題,發現復現後控制檯仍可正常執行,但此時重複執行 reboot 也無效,執行 reboot -f 則可正常觸發重啟。

此處 reboot 是一個指向 busybox 的軟連結,從 help 資訊

-f	Force (don't go through init)

中可以看出 rebootreboot -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

從棧資訊看,似乎在等待某個鎖。

跟蹤工具

情況又清晰了一點,但還不夠,下一步用跟蹤工具看下。

先上 stracestrace 是跟蹤程式行為的利器, 可以直接用 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,並呼叫回撥函式。

看看訊號安全函式列表,readwrite 都是非同步訊號安全的函式,由此我們可以開一個 pipe 或者 socket,一端由非同步訊號處理函式寫入,另一端由工作在正常程式上下文中的回撥函式讀出並處理。

最終我們使用了方案三,具體的是使用了管道,並直接複用了 openwrtustream ,這裡展開就得涉及到 procd init 的工作流程分析了,後續有機會再寫吧。

有一點可以提下,方案一和二用在 procd 中還有一個問題,就是不能跟原有的 uloop 中的 epoll 順暢配合,會導致 reboot 要做的事情堆積在佇列中卻觸發不了處理,需要等其他事件來打斷這個 epoll, 而方案三則沒有這個問題。這也是 procduloop 的實現導致的,暫不展開。

其他

訊號的細節還是蠻多的,例如同一訊號多次發生會怎樣,多個阻塞訊號的到達順序,程式級別的遮蔽處理和執行緒級別的遮蔽處理的差異,forkexec 時的行為等。

非同步訊號同步化的方式,也有很多文章闡述,例如 signalfd 等本文都沒提及。

說回 procd,為什麼原生的實現可以這麼任性,直接在訊號處理函式中呼叫非非同步訊號安全的函式呢? 這可能是 openwrt 預設 C庫 是用的 musl 的原因吧。

部落格:https://www.cnblogs.com/zqb-all/p/12735146.html

公眾號:https://sourl.cn/iaArrv

相關文章