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

寫在前面

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

問題背景

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 的原因吧。

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

公眾號://sourl.cn/iaArrv

Tags: