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

博客:https://www.cnblogs.com/zqb-all/p/12735146.html

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM