奇怪的GCD

原文地址html

多線程一直是我至關感興趣的技術知識之一,我的尤爲喜好GCD這個輕量級的多線程解決方案,爲了瞭解其實現,不厭其煩的翻閱libdispatch的源碼。甚至由於太喜歡了,原本想要寫這相應的源碼解析系列文章,但懼怕寫的很差,因而除了開篇的類型介紹,也是草草了事,沒了下文swift

剛好這幾天好友出了幾道有關GCD的題目,運行結果出於意料,仔細摸索後,發現蘋果基於libdispatch作了一些有趣的修改工做,因而想將這兩道題目分享出來。因爲朋友提供的運行代碼爲Swift書寫,在此我轉換成等效的OC代碼進行講述。你若是瞭解了下面兩個概念,會讓後續的閱讀更加容易:api

  • 同步與異步的概念
  • 隊列與線程的區別

被誤解的概念

對於主線程和主隊列,咱們可能會有這麼一個理解多線程

主線程只會執行主隊列的任務。一樣,主隊列只會在主線程上被執行異步

主線程只會執行主隊列的任務

首先是主線程只會執行主隊列的任務。在iOS中,只有主線程才擁有權限向渲染服務提交打包的圖層樹信息,完成圖形的顯示工做。而咱們在work queue中提交的UI更新老是無效的,甚至致使崩潰發生。而因爲主隊列只有一條,其餘的隊列所有都是work queue,所以能夠得出主線程只會執行主隊列的任務這一結論。可是,有下面這麼一段代碼:async

dispatch_queue_t mainQueue = dispatch_get_main_queue();
dispatch_queue_t globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);

dispatch_queue_set_specific(mainQueue, "key", "main", NULL);
dispatch_sync(globalQueue, ^{
    BOOL res1 = [NSThread isMainThread];
    BOOL res2 = dispatch_get_specific("key") != NULL;
    
    NSLog(@"is main thread: %zd --- is main queue: %zd", res1, res2);
});
複製代碼

根據正常邏輯的理解來講,這裏的兩個判斷結果應該都是NO,但運行後,第一個判斷爲YES,後者爲NO,輸出說明了主線程此時執行了work queue的任務函數

dispatch_sync

上面的代碼在換成async以後就會獲得預期的判斷結果,但在同步執行的狀況下就會致使這個問題。在查找緣由以前,借用bestswifter文章中的代碼一用,首先sync的調用棧以及大體源碼以下:oop

dispatch_sync  
    └──dispatch_sync_f
        └──_dispatch_sync_f2
            └──_dispatch_sync_f_slow


static void _dispatch_sync_f_slow(dispatch_queue_t dq, void *ctxt, dispatch_function_t func) {  
    _dispatch_thread_semaphore_t sema = _dispatch_get_thread_semaphore();
    struct dispatch_sync_slow_s {
        DISPATCH_CONTINUATION_HEADER(sync_slow);
    } dss = {
        .do_vtable = (void*)DISPATCH_OBJ_SYNC_SLOW_BIT,
        .dc_ctxt = (void*)sema,
    };
    _dispatch_queue_push(dq, (void *)&dss);

    _dispatch_thread_semaphore_wait(sema);
    _dispatch_put_thread_semaphore(sema);
    // ...
}
複製代碼

能夠看到對於libdispatch對於同步任務的處理是採用sema信號量的方式堵塞調用線程直到任務被處理完成,這也是爲何sync嵌套使用是一個死鎖問題。根據源碼能夠獲得執行的流程圖:性能

image

但實際運行後,block是執行在主線程上的,代碼真正流程是這樣的:學習

image

所以能夠作一個猜測:

因爲sync函數自己會堵塞當前執行線程直到任務執行。爲了減小線程切換的開銷,以及避免線程被堵塞的資源浪費,因而對sync函數進行了改進:在大多數狀況下,直接在當前線程執行同步任務

既然有了猜測,就須要驗證。之因此說是大多數狀況,是由於目前主隊列只在主線程上被執行仍是有效的,所以咱們排除global -sync-> main這種條件。所以爲了驗證效果,須要建立一個串行線程:

dispatch_queue_t serialQueue = dispatch_queue_create("serial.queue", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);

dispatch_sync(globalQueue, ^{
    BOOL res1 = [NSThread isMainThread];
    BOOL res2 = dispatch_get_specific("key") != NULL;
    
    NSLog(@"is main thread: %zd --- is main queue: %zd", res1, res2);
});

dispatch_async(globalQueue, ^{
    NSThread *globalThread = [NSThread currentThread];
    dispatch_sync(serialQueue, ^{
        BOOL res = [NSThread currentThread] == globalThread;
        NSLog(@"is same thread: %zd", res);
    });
});
複製代碼

運行後,兩次判斷的結果都是YES,結果足以驗證猜測,能夠肯定蘋果爲了提升性能,已經對sync作了修改。另外global -sync-> main測試結果發現sync的調用過程不會被優化

主隊列只會在主線程上執行

上面說過,只有主線程纔有權限提交渲染任務。一樣的,出於下面兩個設定,這個理解應當是成立的:

  • 主隊列老是能夠調用UIKit的接口api
  • 同時只有一條線程可以執行串行隊列的任務

一樣的,朋友給出了另外一份代碼:

dispatch_queue_set_specific(mainQueue, "key", "main", NULL);

dispatch_block_t log = ^{
    printf("main thread: %zd", [NSThread isMainThread]);
    void *value = dispatch_get_specific("key");
    printf("main queue: %zd", value != NULL);
}

dispatch_async(globalQueue, ^{
    dispatch_async(dispatch_get_main_queue(), log);
});

dispatch_main();
複製代碼

運行以後,輸出結果分別爲NOYES,也就是說此時主隊列的任務並無在主線程上執行。要弄清楚這個問題的緣由顯然難度要比上一個問題難度大得多,由於若是子線程能夠執行主隊列的任務,那麼此時是沒法提交打包圖層信息到渲染服務的

一樣的,咱們能夠先猜想緣由。不一樣於正常的項目啓動代碼,這個Swift文件的運行更像是腳本運行,由於缺乏了一段啓動代碼:

@autoreleasepool
{
    return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
複製代碼

爲了找到答案,首先須要對問題主線程只會執行主隊列的任務的代碼進行改造一下。另外因爲第二個問題涉及到執行任務所在的線程mach_thread_self函數會返回當前線程的id,能夠用來判斷兩個線程是否相同:

thread_t threadId = mach_thread_self();

dispatch_queue_t mainQueue = dispatch_get_main_queue();
dispatch_queue_t serialQueue = dispatch_queue_create("serial.queue", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);

dispatch_async(globalQueue, ^{
    dispatch_async(mainQueue, ^{
        NSLog(@"%zd --- %zd", threadId == mach_thread_self(), [NSThread isMainThread]);
    });
});

@autoreleasepool
{
    return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
複製代碼

這段代碼的運行結果都是YES,說明在UIApplicationMain函數先後主隊列任務執行的線程id是相同的,所以能夠得出兩個條件:

  • 主隊列的任務老是在同一個線程上執行
  • UIApplicationMain函數調用後,isMainThread返回了正確結果

結合這兩個條件,能夠作出猜測:在UIApplicationMain中存在某個操做使得本來執行主隊列任務的線程變成了主線程,其猜測圖以下:

因爲UIApplicationMain是個私有api,咱們沒有其實現代碼,可是咱們都知道在這個函數調用以後,主線程的runloop會被啓動,那麼這個線程的變更是否是跟runloop的啓動有關呢?爲了驗證這個判斷,在手動啓動runloop定時的去檢測線程:

dispatch_block_t log = ^{
    printf("is main thread: %zd\n", [NSThread isMainThread]);
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), log);
}

dispatch_async(globalQueue, ^{
    dispatch_async(dispatch_get_main_queue(), log);
});

[[NSRunLoop currentRunLoop] run];
複製代碼

runloop啓動後,全部的檢測結果都是YES

// console log
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
複製代碼

代碼的運行結果驗證了這個猜測,但結論就變成了:

thread -> runloop -> main thread

這樣的結論,隨便啓動一個work queuerunloop就能輕易的推翻這個結論,那麼是否可能只有第一次啓動runloop的線程纔有可能變成主線程?爲了驗證這個猜測,繼續改造代碼:

dispatch_queue_t serialQueue = dispatch_queue_create("serial.queue", DISPATCH_QUEUE_SERIAL);

dispatch_block_t logSerial = ^{
    printf("is main thread: %zd\n", [NSThread isMainThread]);
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), serialQueue, log);
}

dispatch_async(serialQueue, ^{
    [[NSRunLoop currentRunLoop] run];
});
dispatch_async(globalQueue, ^{
    dispatch_async(serialQueue, logSerial);
});

dispatch_main();
複製代碼

在保證了子線程的runloop是第一個被啓動的狀況下,全部運行的輸出結果都是NO,也就是說由於runloop修改了線程的priority的猜測是不成立的,那麼基於UIApplicationMain測試代碼的兩個條件沒法解釋主隊列爲何沒有運行在主線程上

主隊列不老是在同一個線程上執行

通過來回推敲,我發現主隊列老是在同一個線程上執行這個條件限制了進一步擴大猜測的可能性,爲了驗證這個條件,經過定時輸出主隊列任務所在的threadId來檢測這個條件是否成立:

thread_t threadId = mach_thread_self();
dispatch_queue_t serialQueue = dispatch_queue_create("serial.queue", DISPATCH_QUEUE_SERIAL);
printf("current thread id is: %d\n", threadId);

dispatch_block_t logMain = ^{
    printf("=====main queue======> thread id is: %d\n", mach_thread_self());
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), logMain);
}

dispatch_block_t logSerial = ^{
    printf("serial queue thread id is: %d\n", mach_thread_self());
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), serialQueue, logSerial);
}

dispatch_async(globalQueue, ^{
    dispatch_async(serialQueue, logSerial);
    dispatch_async(dispatch_get_main_queue(), logMain);
});

dispatch_main();
複製代碼

在測試代碼中增長子隊列定時作對比,發現無論是serial queue仍是main queue,都有可能運行在不一樣的線程上面。可是若是去掉了子隊列做爲對比,main queue只會執行在一條線程上,但該線程的threadId老是不等同於咱們保存下來的數值:

// console log
current thread id is: 775
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 7171"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 1547"
"serial queue thread id is: 1547"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 1547"
"serial queue thread id is: 1547"
"=====main queue======> thread id is: 6403"
"=====main queue======> thread id is: 1547"
"serial queue thread id is: 6403"
"serial queue thread id is: 4355"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 4355"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 4355"
"=====main queue======> thread id is: 4355"
"serial queue thread id is: 6403"
"serial queue thread id is: 1547"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 1547"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 1547"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 1547"
"serial queue thread id is: 1547"
複製代碼

發現了這一個新的現象後,結合以前的信息來看,能夠得出一個新的猜測:

有一個專用啓動線程用於啓動主線程的runloop,啓動前主隊列會被這個線程執行

要測試這個猜測也很簡單,只要對比runloop先後的threadId是否一致就能夠了:

thread_t threadId = mach_thread_self();
printf("current thread id is: %d\n", threadId);

dispatch_block_t logMain = ^{
    printf("=====main queue======> thread id is: %d\n", mach_thread_self());
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), logMain);
}

dispatch_block_t logSerial = ^{
    printf("serial queue thread id is: %d\n", mach_thread_self());
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), serialQueue, logSerial);
}

dispatch_async(globalQueue, ^{
    dispatch_async(serialQueue, logSerial);
    dispatch_async(dispatch_get_main_queue(), logMain);
});

[[NSRunLoop currentRunLoop] run];

// console log
current thread id is: 775
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
複製代碼

運行結果說明了並不存在什麼啓動線程,一旦runloop啓動後,主隊列就會一直執行在同一個線程上,而這個線程就是主線程。因爲runloop自己是一個不斷循環處理事件的死循環,這纔是它啓動後主隊列一直運行在一個主線程上的緣由。最後爲了測試啓動runloop對串行隊列的影響,單獨啓動子隊列和一塊兒啓動後,發現另外一個現象:

  • 主隊列的runloop一旦啓動,就只會被該線程執行任務
  • 子隊列的runloop沒法綁定隊列和線程的執行關係

因爲在源碼中async調用對於主隊列和子隊列的表現不一樣,後者會直接啓用一個線程來執行子隊列的任務,這就是致使了runloop在主隊列和子隊列上差別化的緣由,也能說明蘋果並無大肆修改libdispatch的源碼。

有趣的runloop喚醒機制

若是你看過runloop相關的博客或者文檔,那麼應該會它是一個不斷處理消息、事件的死循環,但死循環是會消耗大量的cpu資源的(自旋鎖就是死循環空轉)。runloop爲了提升線程的使用效率以及減小沒必要要的損耗,在沒有事件處理的時候,假如此時存在timer、port、source任一一種,那麼進入休眠狀態;假如不存在三者其中之一,那麼runloop將會退出

所以爲了探討runloop的喚醒,咱們能夠經過添加一個空端口來維持runloop的運轉:

CFRunLoopRef runloop = NULL;
NSThread *thread = [[NSThread alloc] initWithBlock: ^{
    runloop = [NSRunLoop currentRunLoop].getCFRunLoop;
    [[NSRunLoop currentRunLoop] addPort: [NSMachPort new] forMode: NSRunLoopCommonModes];
    [[NSRunLoop currentRunLoop] run];
}];
複製代碼

這裏主要討論的是倉鼠大佬的第五題,原問題能夠直接到最下面翻連接。主要要說明的是問題中提到的兩個api,用於添加任務到這個runloop中:

CFRunLoopPerformBlock(runloop, NSRunLoopCommonModes, ^{
    NSLog(@"runloop perform block 1");
});

[NSObject performSelector: @selector(log) onThread: thread withObject: obj waitUntilDone: NO];

CFRunLoopPerformBlock(runloop, NSRunLoopCommonModes, ^{
    NSLog(@"runloop perform block 2");
});
複製代碼

上面的代碼若是去掉了第二個perform調用,那麼第一個調用不會輸出,反之就會都輸出。從名字上看,兩個調用都是往所在的線程裏面添加執行任務,區別在於後者的調用實際上並非直接插入任務block,而是將任務包裝成一個timer事件來添加,這個事件會喚醒runloop。固然,前提是runloop處在休眠中。

CFRunLoopPerformBlock提供了往runloop中添加任務的功能,但又不會喚醒runloop,在事件不多的狀況下,這個api能有效的減小線程狀態切換的開銷

其餘

過了一個漫長的春節假期以後,感受急需一個節假日來休息,惋惜這只是奢望。因爲節後綜合徵,在這周從新返工的狀態感受通常,也偶爾會提不起神來,但願本身儘快恢復過來。另外隨着不斷的積累,一些自覺得熟悉的奇怪問題又總能帶來新的認知和收穫,我想這就是學習最大的快樂了

關於使用代碼

因爲Swift語法上和OC始終存在差別,第二段代碼並不能很好的還原,若是對此感興趣的朋友能夠關注下方倉鼠大佬的博客連接,大佬放話後續會放出源碼。另外若是不想閱讀libdispatch源碼又想對這部分的邏輯有所瞭解的朋友能夠看下面的連接文章

擴展閱讀

倉鼠大佬

深刻了解GCD

關注個人公衆號獲取更新信息
相關文章
相關標籤/搜索