多線程與線程安全

1. 進程、線程、任務

進程(process),指的是一個正在運行中的可執行文件。每一個進程都擁有獨立的虛擬內存空間和系統資源,包括端口權限等,且至少包含一個主線程和任意數量的輔助線程。另外,當一個進程的主線程退出時,這個進程就結束了;
線程(thread),指的是一個獨立的代碼執行路徑,也就是說線程是代碼執行路徑的最小分支。在 iOS 中,線程的底層實現是基于 POSIX threads API 的,也就是我們常說的 pthreads ;
任務(task),指的是我們需要執行的工作,是一個抽象的概念,用通俗的話說,就是一段代碼。

串行 vs. 并發

從本質上來說,串行和并發的主要區別在于允許同時執行的任務數量。串行,指的是一次只能執行一個任務,必須等一個任務執行完成后才能執行下一個任務;并發,則指的是允許多個任務同時執行。

同步 vs. 異步

同樣的,同步和異步操作的主要區別在于是否等待操作執行完成,亦即是否阻塞當前任務。
同步操作會阻塞當前任務,等待操作執行完成后再繼續執行接下來的代碼,而異步操作則恰好相反,它會在調用后立即返回,不會等待操作的執行結果。

2. Operation Queues vs. Grand Central Dispatch (GCD)

簡單來說,GCD 是蘋果基于 C 語言開發的,一個用于多核編程的解決方案,主要用于優化應用程序以支持多核處理器以及其他對稱多處理系統。而 Operation Queues 則是一個建立在 GCD 的基礎之上的,面向對象的解決方案。它使用起來比 GCD 更加靈活,功能也更加強大。

Operation Queues :相對 GCD 來說,使用 Operation 和 Operation Queues 會增加一點點額外的開銷,但是我們卻換來了非常強大的靈活性和功能,我們可以給 operation 之間添加依賴關系、取消一個正在執行的 operation 、暫停和恢復 operation queue 、設置Operation優先級等;GCD也可以進行suspend/resume,但不能取消。NSOperation作為OC對象,還支持KVO,這也是GCD所沒有的。

GCD :則是一種更輕量級的,以 FIFO 的順序執行并發任務的方式,使用 GCD 時我們并不關心任務的調度情況,而讓系統幫我們自動處理。但是 GCD 的短板也是非常明顯的,比如我們想要給任務之間添加依賴關系、取消或者暫停一個正在執行的任務時就會變得非常棘手。

NSOperation,被添加到隊列中后-(void)start方法會被調用,方法內會檢查和設置op的狀態,之后調用-(void)main方法。如果一個op不打算放入隊列,也可以手動調用開始方法,但是對于已經在隊列中的op,再手動調用開始方法是錯誤的。

GCD使用注意事項

2.1 dispatch_once_t必須是全局或static變量,非全局或非static的dispatch_once_t變量在使用時可能會導致非常不好排查的bug。

2.2 創建隊列dispatch_queue_t dispatch_queue_create ( const char *label, dispatch_queue_attr_t attr );
第二個參數dispatch_queue_attr_t在網上教程中常用NULL,實際提供了更清晰、嚴謹的參數DISPATCH_QUEUE_SERIAL、DISPATCH_QUEUE_CONCURRENT

2.3 dispatch_after是延遲提交,而非延遲運行。需要延遲運行可以使用定時器。

2.4 dispatch_suspend并非立即停止隊列的運行,而是在當前block任務執行完成后,暫停后續的block執行。

2.5 dispatch_apply會“等待”其所有的循環運行完畢才往下執行,也就是會阻塞外部的線程。嵌套使用也會造成死鎖。

dispatch_queue_t queue = dispatch_queue_create("com.my.testQueue", DISPATCH_QUEUE_SERIAL);
dispatch_apply(5, queue, ^(size_t i) {
    NSLog(@"outter loop: %zu", i);
    // 此處造成死鎖
    dispatch_apply(3, queue, ^(size_t j) {
        NSLog(@"inner loop: %zu", j);
    });
});

2.6 dispatchbarrier\(a)sync作用就是向某個隊列插入一個block,當目前正在執行的block運行完成后,阻塞這個block后面添加的block,只運行這個block直到完成,然后再繼續后續的任務。這個效果只在自己創建的并發隊列上有效,使用其他隊列效果則與dispatch_(a)sync一樣。

2.7 dispatch_set_context可以為隊列添加上下文數據,但是因為GCD是C語言接口形式的,所以其context參數類型是“void *”。如果參數用Objective-C的對象,但是要用__bridge等關鍵字轉為Core Foundation對象,同時注意在dispatch_set_finalizer_f對應的函數中釋放,避免內存泄露。

/*
__bridge:         只做了類型轉換,不修改內存管理權;
__bridge_retained(即CFBridgingRetain)轉換類型,同時將內存管理權從ARC中移除,后面需要使用CFRelease來釋放對象;
__bridge_transfer(即CFBridgingRelease)將Core Foundation的對象轉換為Objective-C的對象,同時將內存管理權交給ARC。
*/

void cleanStaff(void *context) {
    //這里用__bridge轉換,不改變內存管理權
    Data *data = (__bridge Data *)(context);
    NSLog(@"In clean, context number: %d", data.number);
    //釋放context的內存!
    CFRelease(context);
}
- (void)testBody {
    //創建隊列
    dispatch_queue_t queue = dispatch_queue_create("me.tutuge.test.gcd", DISPATCH_QUEUE_SERIAL);
    //創建自定義Data類型context數據并初始化
    Data *myData = [Data new];
    myData.number = 10;
    //綁定context
    //這里用__bridge_retained轉換,將context的內存管理權從ARC移除,交由我們自己手動釋放!
    dispatch_set_context(queue, (__bridge_retained void *)(myData));
    //設置finalizer函數,用于在隊列執行完成后釋放對應context內存
    dispatch_set_finalizer_f(queue, cleanStaff);
    dispatch_async(queue, ^{
        //獲取隊列的context數據
        //這里用__bridge轉換,不改變內存管理權
        Data *data = (__bridge Data *)(dispatch_get_context(queue));
        //打印
        NSLog(@"1: context number: %d", data.number);
        //修改context保存的數據
        data.number = 20;
    });
}

2.8 在當前隊列中使用sync提交任務到當前隊列,造成死鎖。需要注意的是,隊列和線程并非同一個概念,每個隊列放的Block任務會在線程中執行,可能是主線程或子線程。

// 代碼在ViewDidLoad方法,即主線程中
/*
 Calling 'dispatch_sync' function and targeting the current queue results in deadlock.
 1. dispatch_sync將block提交到main queue
 2. dispatch_sync在阻塞當前隊列任務的執行,直到Block執行完成
 3. dispatch_sync死鎖,因為要執行Block的隊列被阻塞,Block無法完成
 */
dispatch_sync(dispatch_get_main_queue(), ^{
    NSLog(@"main");
});

// 不會死鎖
/* As an optimization, this function invokes the block on the current thread when possible.
 1. dispatch_sync將block提交到自定義隊列queue
 2. dispatch_sync阻塞當前隊列任務的執行,直到Block執行完成
 3. queue在當前線程(主線程)執行Block,log輸出主線程信息
 */
dispatch_queue_t queue = dispatch_queue_create("com.myQueue", NULL);
dispatch_sync(queue, ^{
    NSLog(@"queue:%@",[NSThread currentThread]); // log main thread info
});

上面自定義隊列的例子,輸出了主線程的信息,因此網上其他博客中常說到的dispatch_sync會阻塞當前線程的說法是錯誤的,畢竟queue中的任務在主線程執行了,因此應該是“阻塞了隊列或者隊列當前任務”更加準確。

2.9 GCD只能suspend和resume隊列,并不能cancel。

3. 多線程安全

多線程的安全,一方面是防止一個進程在寫時,另一個進程也在寫入或者讀取;另一方面是保證一個代碼片段內對內存的連續多次訪問結果一致。
線程安全是有粒度大小的,可能是一個model,可能是model中的一個數組,可能是一段代碼或一個方法。
Apple的多線程編碼文檔: Threading Programming Guide

3.1 @synchronized

@synchronized和其他互斥鎖一樣,它防止不同的線程在同一時間獲取相同的鎖,而且不需要使用代碼直接創建互斥鎖對象,而是直接使用OC對象作為一個lock token。@synchronized 隱式添加了異常處理代碼,如果代碼塊中拋出異常會自動釋放互斥鎖。

@synchronized(obj) {
    // do work
}

// 上面代碼實際上為,objc_sync_enter會創建一個與obj關聯的互斥鎖
@try {
    objc_sync_enter(obj);
    // do work
} @finally {
    objc_sync_exit(obj);    
}

如果傳入的是nil對象,則會是一個空操作,失去了線程安全的功能,應該避免這種情況發生。
在SDWebImage中也有使用@synchronized實現線程安全的情況:

// SDWebImageDownloaderOperation
@synchronized(self) {...}

// SDWebImageManager.m
@synchronized(self.failedURLs) {...}
@synchronized(self.runningOpeations) {...}

3.2 atomic關鍵字

atomic的作用只是給getter和setter加了個鎖,atomic只能保證代碼進入getter或者setter函數內部時是安全的,一旦出了getter和setter,多線程安全只能靠程序員自己保障了。所以atomic屬性和使用property的多線程安全并沒什么直接的聯系。另外,atomic由于加鎖也會帶來一些性能損耗,所以我們在編寫iOS代碼的時候,一般聲明property為nonatomic,在需要做多線程安全的場景,自己去額外加鎖做同步。

@property (atomic, strong) NSString* stringA;

//thread A
for (int i = 0; i < 100000; i ++) {
    if (i % 2 == 0) {
        self.stringA = @"a very long string";
    }
    else {
        self.stringA = @"string";
    }
    NSLog(@"Thread A: %@\n", self.stringA);
}

//thread B
for (int i = 0; i < 100000; i ++) {
    // getter 1
    if (self.stringA.length >= 10) {
        // getter 2
        NSString* subStr = [self.stringA substringWithRange:NSMakeRange(0, 10)];
    }
    NSLog(@"Thread B: %@\n", self.stringA);
}

上面代碼,在線程B中,getter2可能出現崩潰。原因是在getter1時,字符串長度大于10,而在getter2時字符串內容已經在線程A中被修改了,因此發生崩潰。
stringA屬性是原子性的,它的set/get方法都是線程安全的,但是問題發生在set/get方法之外,兩次對stringA內存區域的訪問,內存內容已經發生了改變,因此需要加鎖來保證這一段代碼是原子性的。

//thread A
[_lock lock];
for (int i = 0; i < 100000; i ++) {
    if (i % 2 == 0) {
        self.stringA = @"a very long string";
    }
    else {
        self.stringA = @"string";
    }
    NSLog(@"Thread A: %@\n", self.stringA);
}
[_lock unlock];

//thread B
[_lock lock];
if (self.stringA.length >= 10) {
    NSString* subStr = [self.stringA substringWithRange:NSMakeRange(0, 10)];
}
[_lock unlock];

NSLock 可以協調同一應用程序中多個線程。可以保護全局數據以原子方式訪問或代碼片段以原子方式運行。
調用NSLock的unlock方法時,必須確保和調用lock方法的線程為同一個,如果是在不同線程可能發生不可預期的效果。
不應該使用NSLock來實現遞歸鎖,在同一個線程上調用lock兩次,線程將永遠被鎖住。遞歸鎖應該使用NSRecursiveLock。

OSSpinLock會出現優先級反轉的情況,也會出現空轉耗CPU的情況,不適用于較長時間的任務,而且在iOS 10和macOS10.12標記了Deprecated(沒徹底移除)。引入了一個新的os_unfair_lock,也是忙等機制。

在使用NSThread、NSOperation等OC對象的過程中涉及到多線程安全,常常會使用NSLock、NSConditionLock、NSRecursiveLock、NSCondition等類。

3.3GCD線程安全

3.3.1 dispatch_semaphore信號量

dispatch_semaphore_t signal = dispatch_semaphore_create(1);
dispatch_time_t overTime = dispatch_time(DISPATCH_TIME_NOW, 3 * NSEC_PER_SEC);

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    dispatch_semaphore_wait(signal, overTime);
    NSLog(@"需要線程同步的操作1 開始");
    sleep(2);
    NSLog(@"需要線程同步的操作1 結束");
    dispatch_semaphore_signal(signal);
});

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    sleep(1);
    dispatch_semaphore_wait(signal, overTime);
    NSLog(@"需要線程同步的操作2");
    dispatch_semaphore_signal(signal);
});

3.3.2 dispatch_barrier_(a)sync + 自定義并發隊列
以SDWebImage中的代碼為例

// SDImageCache.m

_ioQueue = dispatch_queue_create("com.hackemist.SDWebImageCache", DISPATCH_QUEUE_SERIAL);

dispatch_sync(_ioQueue, ^{
    _fileManager = [NSFileManager new];
});

dispatch_async(self.ioQueue, ^{
    // 執行緩存圖片的增刪查等操作
});

3.3.3 dispatch_sync + 自定義串行隊列

// SDWebImageDownloader.m
_barrierQueue = dispatch_queue_create("com.hackemist.SDWebImageDownloaderBarrierQueue", DISPATCH_QUEUE_CONCURRENT);

// 對self.URLOperations的增刪
dispatch_barrier_async(self.barrierQueue, ^{
    SDWebImageDownloaderOperation *operation = self.URLOperations[token.url];
    BOOL canceled = [operation cancel:token.downloadOperationCancelToken];
    if (canceled) {
        [self.URLOperations removeObjectForKey:token.url];
    }
});



參考文章:

  1. GCD使用經驗與技巧淺談
  2. 為GCD隊列綁定NSObject類型上下文數據
  3. iOS多線程到底不安全在哪里?
  4. iOS 并發編程之 Operation Queues
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容