1. 程式人生 > >濫用單例之dispatch_once死鎖

濫用單例之dispatch_once死鎖

 不錯的一篇libdispatch原始碼的文章,雖然看過,但記錄一下。

轉載連線:http://satanwoo.github.io/2016/04/11/dispatch-once/

現象

上週排查了一個bug,現象很簡單,就是個Crash問題。但是讀了一下crash Log以後,卻發現堆疊報的錯誤資訊卻是第一次見到(吹牛的說,我在國內的iOS也能算第十二人了),包含以下還未符號化資訊:

Application Specific Information:
com.xxx.yyy failed to scene-create in time

Elapsed total CPU time (seconds): hhh秒 (user
hhh, system 0.000), k% CPU Elapsed application CPU time (seconds): 0.h秒, k% CPU Thread 0 name: Dispatch queue: com.apple.main-thread Thread 0: 0 libsystem_kernel.dylib 0x36cb2540 semaphore_wait_trap + 8 1 libsystem_platform.dylib 0x36d3d430 _os_semaphore_wait + 8 2 libdispatch.dylib 0x36be04a6
dispatch_once_f + 250 3 xxxx 偏移量 0x4000 + 947290 ... ...

無符號化的crash 堆疊暫時不去管它,我們重點關注com.xxx.yyy failed to scene-create in time。如果理解無誤的話,這句話提示我們:我們的應用程式在規定的時間沒能載入成功,無法顯示。看起來這個原因是啟動載入過長直接被幹掉。那麼問題來了,原因具體是啥?

檢視堆疊

首先我們需要符號化一下,這裡涉及公司內部資訊,所以我們自己構造個demo試試。
demo的程式碼很簡單,如下:

#import "ManageA.h"
@implementation ManageA + (ManageA *)sharedInstance { static ManageA *manager = nil; static dispatch_once_t token; dispatch_once(&token, ^{ manager = [[ManageA alloc] init]; }); return manager; } - (instancetype)init { self = [super init]; if (self) { [ManageB sharedInstance]; } return self; } @end @implementation ManageB + (ManageB *)sharedInstance { static ManageB *manager = nil; static dispatch_once_t token; dispatch_once(&token, ^{ manager = [[ManageB alloc] init]; }); return manager; } - (instancetype)init { self = [super init]; if (self) { [ManageA sharedInstance]; } return self; }

執行後的堆疊基本如下:

#0    0x000000011054acd2 in semaphore_wait_trap ()
#1    0x00000001101b1b1a in _dispatch_thread_semaphore_wait ()
#2    0x00000001101b1d48 in dispatch_once_f ()
#3    0x000000010d01c857 in _dispatch_once [inlined] at once.h:68
#4    0x000000010d01c839 in +[ManageA sharedInstance] at ManageA.m:18
#5    0x000000010d01cad8 in -[ManageB init] at ManageA.m:54
#6    0x000000010d01ca42 in __25+[ManageB sharedInstance]_block_invoke at ManageA.m:44
#7    0x00000001101c649b in _dispatch_client_callout ()
#8    0x00000001101b1e28 in dispatch_once_f ()
#9    0x000000010d01c9e7 in _dispatch_once [inlined] at once.h:68
#10    0x000000010d01c9c9 in +[ManageB sharedInstance] at ManageA.m:43
#11    0x000000010d01c948 in -[ManageA init] at ManageA.m:29
#12    0x000000010d01c8b2 in __25+[ManageA sharedInstance]_block_invoke at ManageA.m:19
#13    0x00000001101c649b in _dispatch_client_callout ()
#14    0x00000001101b1e28 in dispatch_once_f ()
#15    0x000000010d01c857 in _dispatch_once [inlined] at once.h:68
#16    0x000000010d01c839 in +[ManageA sharedInstance] at /ManageA.m:18
#17    0x000000010d01c5cc in -[AppDelegate application:didFinishLaunchingWithOptions:]         at /AppDelegate.m:21

從中我們可以發現,的確在這段呼叫棧中,出現了多次敏感字樣sharedInstancedispatch_once_f字樣。

在查閱相關資料後,感覺是dispatch_once_f函式造成了訊號量的永久等待,從而引發死鎖。那麼,為什麼dispatch_once會死鎖呢?以前說的最安全的單例構造方式還正確不正確呢?

所以,我們一起來看看下面關於dispatch_once的原始碼分析。

dispatch_once原始碼分析

libdispatch獲取最新版本程式碼,進入對應的檔案once.c。去除註釋後代碼如下,共66行程式碼,但是真的是有很多奇妙的地方。

#include "internal.h"

#undef dispatch_once
#undef dispatch_once_f

struct _dispatch_once_waiter_s {
    volatile struct _dispatch_once_waiter_s *volatile dow_next;
    _dispatch_thread_semaphore_t dow_sema;
};

#define DISPATCH_ONCE_DONE ((struct _dispatch_once_waiter_s *)~0l)

#ifdef __BLOCKS__
// 1. 我們的應用程式呼叫的入口
void
dispatch_once(dispatch_once_t *val, dispatch_block_t block)
{
    struct Block_basic *bb = (void *)block;

    // 2. 內部邏輯
    dispatch_once_f(val, block, (void *)bb->Block_invoke);
}
#endif

DISPATCH_NOINLINE
void
dispatch_once_f(dispatch_once_t *val, void *ctxt, dispatch_function_t func)
{
    struct _dispatch_once_waiter_s * volatile *vval =
            (struct _dispatch_once_waiter_s**)val;

    // 3. 地址類似於簡單的哨兵位
    struct _dispatch_once_waiter_s dow = { NULL, 0 };

    // 4. 在Dispatch_Once的block執行期進入的dispatch_once_t更改請求的連結串列
    struct _dispatch_once_waiter_s *tail, *tmp;

    // 5.區域性變數,用於在遍歷連結串列過程中獲取每一個在連結串列上的更改請求的訊號量
    _dispatch_thread_semaphore_t sema;

    // 6. Compare and Swap(用於首次更改請求)
    if (dispatch_atomic_cmpxchg(vval, NULL, &dow)) {
        dispatch_atomic_acquire_barrier();

        // 7.呼叫dispatch_once的block
        _dispatch_client_callout(ctxt, func);

        dispatch_atomic_maximally_synchronizing_barrier();
        //dispatch_atomic_release_barrier(); // assumed contained in above

        // 8. 更改請求成為DISPATCH_ONCE_DONE(原子性的操作)
        tmp = dispatch_atomic_xchg(vval, DISPATCH_ONCE_DONE);
        tail = &dow;

        // 9. 發現還有更改請求,繼續遍歷
        while (tail != tmp) {

            // 10. 如果這個時候tmp的next指標還沒更新完畢,等一會
            while (!tmp->dow_next) {
                _dispatch_hardware_pause();
            }

            // 11. 取出當前的訊號量,告訴等待者,我這次更改請求完成了,輪到下一個了
            sema = tmp->dow_sema;
            tmp = (struct _dispatch_once_waiter_s*)tmp->dow_next;
            _dispatch_thread_semaphore_signal(sema);
        }
    } else {
        // 12. 非首次請求,進入這塊邏輯塊
        dow.dow_sema = _dispatch_get_thread_semaphore();
        for (;;) {
            // 13. 遍歷每一個後續請求,如果狀態已經是Done,直接進行下一個
            // 同時該狀態檢測還用於避免在後續wait之前,訊號量已經發出(signal)造成
            // 的死鎖
            tmp = *vval;
            if (tmp == DISPATCH_ONCE_DONE) {
                break;
            }
            dispatch_atomic_store_barrier();
            // 14. 如果當前dispatch_once執行的block沒有結束,那麼就將這些
            // 後續請求新增到連結串列當中
            if (dispatch_atomic_cmpxchg(vval, tmp, &dow)) {
                dow.dow_next = tmp;
                _dispatch_thread_semaphore_wait(dow.dow_sema);
            }
        }
        _dispatch_put_thread_semaphore(dow.dow_sema);
    }
}

根據以上註釋對原始碼的分析,我們可以大致知道如下幾點:

  1. dispatch_once並不是簡單的只執行一次那麼簡單
  2. dispatch_once本質上可以接受多次請求,會對此維護一個請求連結串列
  3. 如果在block執行期間,多次進入呼叫同類的dispatch_once函式(即單例函式),會導致整體連結串列無限增長,造成永久性死鎖。(其實只要進入兩次就完蛋,其原因在於block_invoke的完成依賴於第二次進入的請求的完成,而第二次請求的完成又必須依賴之前訊號量的出發。可是第一次block不結束,訊號量壓根不會觸發)

備註

  1. 根據以上分析,相對應地寫了一個簡易的死鎖Demo,就是在兩個單例的初始化呼叫中直接相互呼叫。A<->B。也許這個Demo過於簡單,大家輕易不會犯。但是如果是A->B->C->A,甚至是更多個模組的相互引用,那又該如何輕易避免呢?
  2. 以上的Demo,如果在Xcode模擬器測試環境下,是不會死鎖從而導致應用啟動被殺。這是因為模擬器不具備守護程序,如果要觀察現象,可以輸出Log或者直接利用真機進行測試。
  3. 有時候,啟動耗時是因為佔用了太多的CPU資源。但是從我們的Crash Log中可以發現,我們僅僅佔用了Elapsed application CPU time (seconds): 0.h秒, k% CPU。通過這個,我們也可以發現,CPU佔用率高並不是導致啟動階段APP Crash的唯一原因。

反思

雖然這次的問題直接原因是dispatch_once引出的死鎖問題,但是個人認為,這卻是濫用單例造成的後果。各位可以開啟自己公司的app原始碼檢視一下,究竟存在著多少的單例。

實話實說,單例和全域性變數幾乎沒有任何區別,不僅僅佔用了全生命週期的記憶體,還對解耦造成了巨大的負作用。寫起來容易,但是對於整個專案的架構梳理卻是有著巨大的影響,因為在不讀完整個相關程式碼的前提下,你壓根不知道究竟哪裡會觸發單例的呼叫。

因此在這裡,談談個人認為可以不使用單例的幾個方面:

  1. 僅僅使用一次的模組,可以不使用單例,可以採用在對應的週期內維護成員例項變數進行替換
  2. 和狀態無關的模組,可以採用靜態(類)方法直接替換
  3. 可以通過頁面跳轉進行依賴注入的模組,可以採用依賴注入或者變數傳遞等方式解決

當然,的確有一些情況我們仍然需要使用單例。那在這種情況,也請將dispatch_once呼叫的block內減少儘可能多的任務,最好是僅僅負責初始化,剩下的配置、呼叫等等在後續進行。