iOS面試備戰-多線程

iOS面試中多線程絕對是最重要的知識點之一,它在平常開發中會被普遍使用,並且多線程是有不少區分度很高的題目可供考察的。這篇文章會梳理下多線程和GCD相關的概念和幾個典型問題。由於GCD相關的API用OC看着更直管一些,因此這期實例就都用OC語言書寫。

概念篇

在面對一些咱們常見的概念時,咱們常有種這個東西我熟,就認爲本身理解了,其實這種程度是不夠的。當咱們能夠清晰準確的向別人描述一個東西,並能理解其官方定義的每一個用語的含義,纔算是咱們熟悉理解了它。因此這裏單獨抽一節講下多線程中的概念。面試

進程,線程,任務,隊列

進程:資源分配的最小單位。在iOS中一個應用的啓動就是開啓了一個進程。 線程:CPU調度的最小單位。一個進程裏會有多個線程。 你們能夠思考下,進程和線程爲何是從資源分配和CPU調度層面進行定義的。objective-c

任務:每次執行的一段代碼,好比下載一張圖片,觸發一個網絡請求。 隊列:隊列是用來組織任務的,一個隊列包含多個任務。安全

GCD

GCD(Grand Central Dispatch)是異步執行任務的技術之一。開發者只須要定義想執行的任務並追加到適當的Dispatch Queue中,GCD就能生成必要的線程執行該任務。這裏的線程管理是由系統處理的,咱們沒必要關心線程的建立銷燬,這大大方便了咱們的開發效率。也能夠說GCD是一種簡化線程操做的多線程使用技術方案。markdown

安卓沒有跟GCD徹底相同的一套技術方案的,雖然它能夠處理GCD實現的一系列效果。網絡

串行,並行,併發

GCD的使用都是經過調度隊列(Dispatch Queue)的形式進行的,調度隊列有如下 幾種形式:多線程

串行(serial):多任務中某時刻只能有一個任務被運行;併發

並行(parallel):相對於串行,某時刻有多個任務同時被執行,須要多核能力;異步

併發(concurrent):引入時間片和搶佔以後纔有了併發的說法,某個時間片只有一個任務在執行,執行完時間片後進行資源搶佔,到下一個任務去執行,即「微觀串行,宏觀併發」,因此這種狀況下只有一個空閒的某核,多核空閒就又能夠實現並行運行了;async

咱們經常使用的調度隊列有如下幾種:函數

// 串行隊列
dispatch_queue_t serialQueue = dispatch_queue_create("com.gcd.serialQueue", DISPATCH_QUEUE_SERIAL);
// 併發隊列
dispatch_queue_t concurrentQueue = dispatch_queue_create("com.gcd.concurrentQueue", DISPATCH_QUEUE_CONCURRENT);
// 全局併發隊列
dispatch_queue_t globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
// 主隊列
let mainQueue = DispatchQueue.main
複製代碼

注意GCD建立的是併發隊列而不是並行隊列。但這裏的併發隊列是一個相對寬泛的定義,它包含並行的概念,GCD做爲一個智能的中心調度系統會根據系統狀況判斷當前可否使用多核能力分攤多個任務,若是知足的話此時就是在並行的執行隊列中的任務。

同步,異步

同步:函數會阻塞當前線程直到任務完成返回才能進行其它操做;

異步:在任務執行完成以前先將函數值返回,不會阻塞當前線程;

串行、併發和同步、異步相互結合可否開啓新線程

串行隊列 併發隊列 主隊列
同步 不開啓新線程 不開啓新線程 不開啓新線程
異步 開啓新線程 開啓新線程 不開啓新線程

主線程和主隊列

主線程是一個線程,主隊列是指主線程上的任務組織形式。

主隊列只會在主線程執行,但主線程上執行的不必定就是主隊列,還有多是別的同步隊列。由於前說過,同步操做不會開闢新的線程,因此當你自定義一個同步的串行或者並行隊列時都是還在主線程執行。

判斷當前是不是主線程:

BOOL isMainThread = [NSThread isMainThread];
複製代碼

判斷當前是否在主隊列上:

static void *mainQueueKey = "mainQueueKey";
dispatch_queue_set_specific(dispatch_get_main_queue(), mainQueueKey, &mainQueueKey, NULL);
BOOL isMainQueue = dispatch_get_specific(mainQueueKey));
複製代碼

隊列與線程的關係

隊列是對任務的描述,它能夠包含多個任務,這是應用層的一種描述。線程是系統級的調度單位,它是更底層的描述。一個隊列(並行隊列)的多個任務可能會被分配到多個線程執行。

問題

代碼分析

一、分析下面代碼的執行邏輯

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    [self syncMainTask];
}

- (void)syncMainTask {
    dispatch_queue_main_t mainQueue = dispatch_get_main_queue();
    dispatch_sync(mainQueue, ^{
        NSLog(@"main queue task");
    });
}
複製代碼

這段代碼會輸出task1,而後發生死鎖,致使crash。

追加問題一:爲何會死鎖?死鎖就會致使crash?

咱們先分析crash的狀況,正常死鎖應該就是卡死的狀況,不該該致使carsh。那爲何會carsh呢,看崩潰信息:

是一個EXC_BAD_INSTRUCTION類型的crash,執行了一個出錯的命令。

而後看__DISPATCH_WAIT_FOR_QUEUE__的調用棧信息:

右側彙編代碼給出了更詳細的crash信息:BUG IN CLIENT OF LIBDISPATCH: dispatch_sync called on queue already owned by current thread

在當前線程已經擁有的隊列中執行dispatch_sync同步操做會致使crash。

libdispatch的源碼中咱們能夠找到該函數的定義:

DISPATCH_NOINLINE
static void
__DISPATCH_WAIT_FOR_QUEUE__(dispatch_sync_context_t dsc, dispatch_queue_t dq) {
    uint64_t dq_state = _dispatch_wait_prepare(dq);
    if (unlikely(_dq_state_drain_locked_by(dq_state, dsc->dsc_waiter))) {
	DISPATCH_CLIENT_CRASH((uintptr_t)dq_state,
			"dispatch_sync called on queue "
			"already owned by current thread");
    }
    /*...*/
}
複製代碼

因此咱們知道了,這個carsh是libdispatch內部拋出的,當它檢測到可能發生死鎖時,就直接觸發崩潰,事實上它不能徹底判斷出全部死鎖的狀況。

咱們分析這裏爲何會發生死鎖。首先syncMainTask就是在主隊列中的,咱們在主隊列先添加dispatch_sync而後再添加其內部的block。主隊列FIFO,只有sync執行完了纔會執行內部的block,而此時是一個同步隊列,block執行完纔會退出sync,因此致使了死鎖。

對於死鎖的解釋我也查了好幾篇文章,有些說法實際上是經不起推敲的,這個解釋是我認爲相對合理的。

附一篇參考文章:GCD死鎖

引出問題二:什麼狀況下會發生死鎖?

GCD中發生死鎖須要知足兩個條件:

  • 同步執行串行隊列
  • 執行sync的隊列和block所在隊列爲同一個隊列

引出問題三:如何避免死鎖?這段代碼應該如何修改?

根據上面提到的條件,咱們能夠將任務異步執行,或者換成一個併發隊列。另外將block放到一個非主隊列裏執行也是能夠的。

二、分析一下代碼執行結果

int a = 0;
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
while (a < 2) {
    dispatch_async(queue, ^{
        a++;
    });
}
NSLog(@"a = %d", a);
複製代碼

首先該段代碼會編譯不過,編譯器檢測到變量a被block截獲,並嘗試修改就報如下錯誤:

Variable is not assignable (missing __block type specifier)。若是咱們要在block裏對外界變量從新複製,須要添加__block的聲明:__block int a = 0;

咱們分析這段代碼,在開始while以後加入一個異步任務,再以後呢,這個是不肯定了,多是執行a++也多是因不知足退出條件再次執行加入異步任務,直到知足a<2纔會退出while循環。那輸出結果也就是不肯定了,由於可能在判斷跳出循環和輸出結果的時候另外的線程又執行了一次a++

再擴展下,若是將那個併發隊列改爲主隊列,執行邏輯仍是同樣的嗎?

首先主隊列是不會開啓新線程的,主隊列上的異步操做執行時機是等別的任務都執行完了,再來執行添加的a++。顯然在while循環裏,主隊列既有任務還未執行完畢,因此就不會執行a++,也就致使while循環不會退出,造成死循環。

其它問題

什麼是線程安全,爲何UI操做必須在主線程執行

線程安全:當多個線程訪問某個方法時,無論你經過怎樣的調用方式或者說這些線程如何交替的執行,咱們在主程序中不須要去作任何的同步,這個類的結果行爲都是咱們設想的正確行爲,那麼咱們就能夠說這個類時線程安全的。

爲何UI操做必須放到主線程:首先UIKit不是線程安全的,多線程訪問會致使UI效果不可預期,因此咱們不能使用多個線程去處理UI。那既然要單線程處理UI爲何是在主線程呢,這是由於UIApplication做爲程序的起點是在主線程初始化的,因此咱們後續的UI操做也都要放到主線程處理。

關於這個問題展開討論能夠參閱這篇文章:iOS拾遺——爲何必須在主線程操做UI

開啓新的線程有哪些方法

一、NSThread

二、NSOperationQueue

三、GCD

四、NSObject的performSelectorInBackground方法

五、pthread

多線程任務要實現順序執行有哪些方法

一、dispatch_group

二、dispatch_barrier

三、dispatch_semaphore_t

四、NSOperation的addDependency方法

如何實現一個多讀單寫的功能?

多讀單寫的意思就是能夠有多個線程同時參與讀取數據,可是寫數據時不能有讀操做的參與切只有一個線程在寫數據。

咱們寫一個示例程序,看下在不作限制的多讀多寫程序中會發生什麼。

// 計數器
self.count = 0;
// 併發隊列
self.concurrentQueue = dispatch_get_global_queue(0, 0);
for (int i = 0; i< 10; i++) {
    dispatch_async(self.concurrentQueue, ^{
        [self read];
    });
    dispatch_async(self.concurrentQueue, ^{
        [self write];
    });
}
// 讀寫操做
- (void)read {
    NSLog(@"read---- %d", self.count);
}

- (void)write {
    self.count += 1;
    NSLog(@"write---- %d", self.count);
}

// 輸出內容
2020-07-18 11:47:03.612175+0800 GCD_OC[76121:1709312] read---- 0
2020-07-18 11:47:03.612273+0800 GCD_OC[76121:1709311] read---- 1
2020-07-18 11:47:03.612230+0800 GCD_OC[76121:1709314] write---- 1
2020-07-18 11:47:03.612866+0800 GCD_OC[76121:1709312] write---- 2
2020-07-18 11:47:03.612986+0800 GCD_OC[76121:1709311] write---- 3
2020-07-18 11:47:03.612919+0800 GCD_OC[76121:1709314] read---- 2
2020-07-18 11:47:03.613252+0800 GCD_OC[76121:1709312] read---- 3
2020-07-18 11:47:03.613346+0800 GCD_OC[76121:1709314] write---- 4
2020-07-18 11:47:03.613423+0800 GCD_OC[76121:1709311] read---- 4
複製代碼

每次運行的輸出結果都會不同,根據這個輸出內容,咱們能夠看到在尚未執行到輸出write----1的時候,就已經執行了read----1,在write---- 3以後 read的結果倒是2。這絕對是咱們所不指望的。其實在程序設計中咱們是不該該設計出多讀多寫這種行爲,由於這個結果是不可控。

解決方案之一是對讀寫操做都加上鎖作成單獨單寫,這樣是沒問題但有些浪費性能,正常寫操做肯定以後結果就肯定了,讀的操做能夠多線程同時進行,而不須要等別的線程讀完它才能讀,因此有了多讀單寫的需求。

解決多讀單寫常見有兩種方案,第一種是使用讀寫鎖pthread_rwlock_t

讀寫鎖具備一些幾個特性:

  • 同一時間,只能有一個線程進行寫的操做
  • 同一時間,容許有多個線程進行讀的操做。
  • 同一時間,不容許既有寫的操做,又有讀的操做。

這跟咱們的多讀單寫需求完美吻合,也能夠說讀寫鎖的設計就是爲了實現這一需求的。它的實現方式以下:

// 執行讀寫操做以前須要定義一個讀寫鎖
@property (nonatomic,assign) pthread_rwlock_t lock;
pthread_rwlock_init(&_lock,NULL);
// 讀寫操做
- (void)read {
    pthread_rwlock_rdlock(&_lock);
    NSLog(@"read---- %d", self.count);
    pthread_rwlock_unlock(&_lock);
}

- (void)write {
    pthread_rwlock_wrlock(&_lock);
    _count += 1;
    NSLog(@"write---- %d", self.count);
    pthread_rwlock_unlock(&_lock);
}
// 輸出內容
2020-07-18 12:00:29.363875+0800 GCD_OC[77172:1722472] read---- 0
2020-07-18 12:00:29.363875+0800 GCD_OC[77172:1722471] read---- 0
2020-07-18 12:00:29.364195+0800 GCD_OC[77172:1722469] write---- 1
2020-07-18 12:00:29.364325+0800 GCD_OC[77172:1722472] write---- 2
2020-07-18 12:00:29.364450+0800 GCD_OC[77172:1722470] read---- 2
2020-07-18 12:00:29.364597+0800 GCD_OC[77172:1722471] write---- 3
2020-07-18 12:00:29.366490+0800 GCD_OC[77172:1722469] read---- 3
2020-07-18 12:00:29.366703+0800 GCD_OC[77172:1722472] write---- 4
2020-07-18 12:00:29.366892+0800 GCD_OC[77172:1722489] read---- 4
複製代碼

咱們查看輸出日誌,因此的讀操做結果都是最近一次寫操做所賦的值,這是符合咱們預期的。

還有一種實現多讀單寫的方案是使用GCD中的柵欄函數dispatch_barrier。柵欄函數的目的就是保證在同一隊列中它以前的操做所有執行完畢再執行後面的操做。爲了保證寫操做的互斥行,咱們要對寫操做執行「柵欄」:

// 咱們定義一個用於讀寫的併發對列
self.rwQueue = dispatch_queue_create("com.rw.queue", DISPATCH_QUEUE_CONCURRENT);

- (void)read {
    dispatch_sync(self.rwQueue, ^{
        NSLog(@"read---- %d", self.count);
    });
}

- (void)write {
    dispatch_barrier_async(self.rwQueue, ^{
        self.count += 1;
        NSLog(@"write---- %d", self.count);
    });
}
複製代碼

這個輸出結果跟讀寫鎖實現是同樣的,也是符合預期的。

這裏多說幾句,這裏的讀和寫分別使用syncasync。讀操做要用同步是爲了阻塞線程儘快返回結果,不用擔憂沒法實現多讀,由於咱們使用了併發隊列,是能夠實現多讀的。至於寫操做使用異步的柵欄函數,是爲了寫時不阻塞線程,經過柵欄函數實現單寫。若是咱們將讀寫都改爲sync或者async,因爲柵欄函數的機制是會順序先讀後寫。若是反過來,讀操做異步,寫操做同步也是能夠達到多讀單寫的目的的,但讀的時候不當即返回結果,網上有人說只能使用異步方式,防止發生死鎖,這個說法其實不對,由於同步隊列是不會發生死鎖的。

用GCD如何實現一個控制最大併發數且執行任務FIFO的功能?

這個相對簡單,經過信號量實現併發數的控制,經過併發隊列實現任務的FIFO的執行

int maxConcurrent = 3;
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
dispatch_semaphore_t semaphore = dispatch_semaphore_create(maxConcurrent);
dispatch_async(queue, ^{
    dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
    // task
    dispatch_semaphore_signal(semaphore);
});
複製代碼

相關文章
相關標籤/搜索