如何用Xcode8解決多線程問題

Xcode 8誕生有段時日了,不知道大家對其中的新Feature是否都學(xué)習(xí)過一遍了,今天給大家介紹下Xcode 8中一個很實用的特性,Thread Sanitizer,用來解決平時編寫代碼時難以調(diào)試的多線程問題,順道梳理下一些常見的容易混淆的多線程概念。

Thread Sanitizer

這款工具集成在Xcode 8中,主要幫助定位多線程相關(guān)的問題,還沒有了解過的同學(xué)可以先查看 WWDC 2016 Session 412。官方的介紹當(dāng)中它可以查出以下多線程相關(guān)的問題:

  • Use of uninitialized mutexes
  • Thread leaks (missing pthread_join)
  • Unsafe calls in signal handlers (ex:malloc)
  • Unlock from wrong thread
  • Data races

前面四項出現(xiàn)的場景較少,真正體現(xiàn)這款工具強大之處的是最后一項,檢查data races,也是我們平時寫多線程代碼時最容易遇到的問題,一旦踩坑,現(xiàn)象往往是偶現(xiàn)的,難以調(diào)試。

在開始介紹Thread Sanitizer如何使用之前,我們應(yīng)該先花點時間了解下什么是data race,以及它到底有什么危害,建議先看下我之前寫過的一篇關(guān)于iOS多線程安全的文章

data race的定義很簡單:當(dāng)至少有兩個線程同時訪問同一個變量,而且至少其中有一個是寫操作時,就發(fā)生了data race。這段定義只是描述了什么是data race,卻沒有說明data race會帶來什么嚴(yán)重后果,這是因為data race可能會造成多種影響,而且有些影響不一定是致命的(比如crash)。data race也不是什么罕見的場景,只要涉及到多線程編程,遇到的概率非常之高,下面我們看一些data race具體的例子及其危害。

場景一:計算出錯

這也是大學(xué)課程里經(jīng)常舉例的一個場景,Objective C代碼如下:

__block int count = 0;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    for (int i = 0; i < 10000; i ++) {
        count ++;
    }
});
for (int i = 0; i < 10000; i ++) {
    count ++;
}

最后計算的結(jié)果有很大概率小于20000,原因是count ++為非原子操作。這也是data race的場景,這種race沒有crash也沒有memory corruption,因此有些人把這種race稱作benign race(良性的race)。不過上面提到的WWDC視頻中,蘋果的工程師說到:

There is No Such Thing as a “Benign” Race

意思是,只要發(fā)生data race,就沒有良性一說了,因為雖然程序沒有crash,但count最后的值還是出錯了,這種 錯誤必然會導(dǎo)致邏輯上的錯誤,如果這個count值代表的是你銀行卡余額,你應(yīng)該會更加同意蘋果工程師的觀點。

場景二:Crash!

這種場景是真正會導(dǎo)致crash和memory corruption的,發(fā)生在兩個線程同時對同一個變量執(zhí)行寫操作時,比如如下Objective C代碼:

NSMutableString* str = [@"" mutableCopy];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    for (int i = 0; i < 10000; i ++) {
        [str setString:@"1234567890"];
    }
});
for (int i = 0; i < 10000; i ++) {
    [str setString:@"abcdefghigk"];
}

這也屬于data race的場景,一般會出現(xiàn)在對于復(fù)雜對象(class或者struct)的多線程寫操作中,原因是因為寫操作本身不是原子的,而且寫操作背后會調(diào)用更多的內(nèi)存操作,多線程同時寫時,會導(dǎo)致這塊內(nèi)存區(qū)間處于中間的不穩(wěn)定狀態(tài),進而crash,這是真正的惡性的data race。

場景三:亂序

過去幾年Review代碼的經(jīng)歷中,看到過不少如下使用公共變量來做多線程同步的,比如:

//thread 1
count = 10;
countFinished = true;

//thread 2
while (countFinished == false) {
    usleep(1000);
}
NSLog(@"count: %d", count);

按理說,count最后會輸出值10。可實際上,編譯器并不知道thread 2對countcountFinished這兩個變量的賦值順序有依賴,所以基于優(yōu)化的目的,有可能會調(diào)整thread 1中count = 10;countFinished = true;生成的最后指令的執(zhí)行順序,最后也就導(dǎo)致count值輸出的時機不對,雖然最后count的值還是10。這也可以看做是一種benign race,因為也不會crash,而是程序的流程出錯。而且這種錯誤的調(diào)試及其困難,因為邏輯上是完全正確的,不明白其中緣由的同學(xué)甚至?xí)岩墒窍到y(tǒng)bug。

遇到這種多線程讀寫狀態(tài),而且存在順序依賴的場景,不能簡單依賴代碼邏輯。解決這種data race場景有一個簡單辦法:加鎖,比如使用NSLock,將對順序有依賴的代碼塊整個原子化,加鎖之所以有用是因為會生成memory barrier,從而避免了編譯器優(yōu)化。

場景四:內(nèi)存泄漏

iOS剛誕生不久時,還沒有多少Best Practise,不少人寫單例的時候還不會用到dispatch_once_t,而是采用如下直白的寫法:

Singleton *getSingleton() {
    static Singleton *sharedInstance = nil;
    if (sharedInstance == nil) {
        sharedInstance = [[Singleton alloc] init];
    }
    return sharedInstance;
}

這種寫法的問題是,多線程環(huán)境下,thread A和thread B會同時進入sharedInstance = [[Singleton alloc] init];,Singleton被多創(chuàng)建了一次,MRC環(huán)境就產(chǎn)生了內(nèi)存泄漏。

這是個經(jīng)典的例子,也是data race的場景之一,其結(jié)果是造成額外的內(nèi)存泄漏,這種race也可以算作是benign的,但也是我們平時編寫代碼應(yīng)該避免的。

上面幾個是我們寫iOS代碼比較容易遇到的,還有其他一些就不一一舉例了,只要理解了data race的含義都不難分析這些race導(dǎo)致的具體問題。

BOOL是否多線程安全?

在之前那篇iOS多線程安全的文章中,我提到對于BOOL類型的property來說,聲明為atomic并沒有意義,nonatmoic對于BOOL的get,set也是安全的。

@property (nonatomic, assign) BOOL isValid;

原理我也簡單解釋了一下,但之后有一些朋友私底下和我交流,還是對這一觀點存疑。

實際上,上面的WWDC視頻中,蘋果的工程師也提到了這一點:有些人認(rèn)為pointer sized的變量操作時是天然多線程安全的。所謂的pointer size也就是我們指針變量的大小,64位系統(tǒng)為8字節(jié)。這位工程師提到,這種看法是問題的,理由如下:

On some architectures (ex., x86) reads and writes are atomic

But even a “benign” race is undefined behavior in C

May cause issues with new compilers or architectures

C標(biāo)準(zhǔn)對于這種行為定義是undefined behavior,意思是最后的結(jié)果是不確定的,不同的編譯器針對不同的CPU架構(gòu)所產(chǎn)生的最后執(zhí)行文件,其執(zhí)行結(jié)果是沒有規(guī)定的,如果有哪個硬件平臺上出現(xiàn)了crash,那么也沒有違背C的標(biāo)準(zhǔn),因為C沒有規(guī)定其一定是原子操作。

同時,據(jù)我所知(扒過一些資料),以及我這么些年寫iOS代碼的經(jīng)歷,nonatomic修飾的BOOL確實是原子操作且多線程安全的,我也沒找到什么樣的CPU架構(gòu)下,pointer sized的變量是非原子操作的。

所以,更準(zhǔn)確更嚴(yán)格的說法應(yīng)該是:現(xiàn)階段的iOS設(shè)備軟硬件環(huán)境下,BOOL的讀寫是原子的,不過將來不一定,蘋果官方和C標(biāo)準(zhǔn)都沒有做任何保證

如何使用Thread Sanitizer

啟用Thread Sanitizer的方式很簡單,只需要在Xcode的scheme中勾選Thread Sanitizer即可,如下圖:

這里要注意的是,Thread Sanitizer現(xiàn)階段只能在模擬器環(huán)境下執(zhí)行,真機還不支持,而且我測試發(fā)現(xiàn),只支持64位系統(tǒng),也就是說iPhone 5及其更早的模擬器也不支持,iPhone 5s之后才是64位系統(tǒng)。

勾選之后,重新編譯運行代碼即可,我用下面一段代碼做測試:

__block int count = 0;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    for (int i = 0; i < 10000; i ++) {
        count ++;
    }
});
for (int i = 0; i < 10000; i ++) {
    count ++;
}

運行之后會在Xcode中出現(xiàn)如下提示:

很直觀,Xcode直接提示你發(fā)生了data race的變量及其代碼位置,同時還清晰的展示了函數(shù)當(dāng)前的各線程調(diào)用棧,十分清晰,接下來你要做的就是增加同步操作,比如加鎖,從而消除data race,再運行測試是否生效。

原理

Thread Sanitizer的工作原理在WWDC的視頻中也介紹過了,大家可以仔細看下視頻,大致原理是記錄每個線程訪問變量的信息來做分析,值得一提的是,現(xiàn)階段的Thread Sanitizer最多只同時記錄4個線程的訪問信息,在復(fù)雜的場景下,可能出現(xiàn)偶爾檢測不出data race的場景,所以需要長時間經(jīng)常性的運行來盡可能多的發(fā)現(xiàn)data race,這也是為什么蘋果建議默認(rèn)開啟Thread Sanitizer,而且Thread Sanitizer造成的額外性能損耗非常之小。

結(jié)束語

以上就是Xcode 8新增的多線程問題調(diào)試工具Thread Sanitizer,了解背后原理再去使用工具才更得心應(yīng)手,趕緊拿公司項目跑一跑吧,發(fā)現(xiàn)一堆data race可能性一般來說是還是比較高的 :)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容