Xcode 8誕生有段時(shí)日了,不知道大家對(duì)其中的新Feature是否都學(xué)習(xí)過一遍了,今天給大家介紹下Xcode 8中一個(gè)很實(shí)用的特性,Thread Sanitizer,用來解決平時(shí)編寫代碼時(shí)難以調(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àng)出現(xiàn)的場(chǎng)景較少,真正體現(xiàn)這款工具強(qiáng)大之處的是最后一項(xiàng),檢查data races,也是我們平時(shí)寫多線程代碼時(shí)最容易遇到的問題,一旦踩坑,現(xiàn)象往往是偶現(xiàn)的,難以調(diào)試。
在開始介紹Thread Sanitizer如何使用之前,我們應(yīng)該先花點(diǎn)時(shí)間了解下什么是data race,以及它到底有什么危害,建議先看下我之前寫過的一篇關(guān)于iOS多線程安全的文章。
data race的定義很簡(jiǎn)單:當(dāng)至少有兩個(gè)線程同時(shí)訪問同一個(gè)變量,而且至少其中有一個(gè)是寫操作時(shí),就發(fā)生了data race。這段定義只是描述了什么是data race,卻沒有說明data race會(huì)帶來什么嚴(yán)重后果,這是因?yàn)閐ata race可能會(huì)造成多種影響,而且有些影響不一定是致命的(比如crash)。data race也不是什么罕見的場(chǎng)景,只要涉及到多線程編程,遇到的概率非常之高,下面我們看一些data race具體的例子及其危害。
場(chǎng)景一:計(jì)算出錯(cuò)
這也是大學(xué)課程里經(jīng)常舉例的一個(gè)場(chǎ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 ++;
}
最后計(jì)算的結(jié)果有很大概率小于20000,原因是count ++為非原子操作。這也是data race的場(chǎng)景,這種race沒有crash也沒有memory corruption,因此有些人把這種race稱作benign race(良性的race)。不過上面提到的WWDC視頻中,蘋果的工程師說到:
There is No Such Thing as a “Benign” Race
意思是,只要發(fā)生data race,就沒有良性一說了,因?yàn)殡m然程序沒有crash,但count最后的值還是出錯(cuò)了,這種 錯(cuò)誤必然會(huì)導(dǎo)致邏輯上的錯(cuò)誤,如果這個(gè)count值代表的是你銀行卡余額,你應(yīng)該會(huì)更加同意蘋果工程師的觀點(diǎn)。
場(chǎng)景二:Crash!
這種場(chǎng)景是真正會(huì)導(dǎo)致crash和memory corruption的,發(fā)生在兩個(gè)線程同時(shí)對(duì)同一個(gè)變量執(zhí)行寫操作時(shí),比如如下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的場(chǎng)景,一般會(huì)出現(xiàn)在對(duì)于復(fù)雜對(duì)象(class或者struct)的多線程寫操作中,原因是因?yàn)閷懖僮鞅旧聿皇窃拥?,而且寫操作背后?huì)調(diào)用更多的內(nèi)存操作,多線程同時(shí)寫時(shí),會(huì)導(dǎo)致這塊內(nèi)存區(qū)間處于中間的不穩(wěn)定狀態(tài),進(jìn)而crash,這是真正的惡性的data race。
場(chǎng)景三:亂序
過去幾年Review代碼的經(jīng)歷中,看到過不少如下使用公共變量來做多線程同步的,比如:
//thread 1
count = 10;
countFinished = true;
//thread 2
while (countFinished == false) {
usleep(1000);
}
NSLog(@"count: %d", count);
按理說,count最后會(huì)輸出值10。可實(shí)際上,編譯器并不知道thread 2對(duì)count和countFinished這兩個(gè)變量的賦值順序有依賴,所以基于優(yōu)化的目的,有可能會(huì)調(diào)整thread 1中count = 10;和countFinished = true;生成的最后指令的執(zhí)行順序,最后也就導(dǎo)致count值輸出的時(shí)機(jī)不對(duì),雖然最后count的值還是10。這也可以看做是一種benign race,因?yàn)橐膊粫?huì)crash,而是程序的流程出錯(cuò)。而且這種錯(cuò)誤的調(diào)試及其困難,因?yàn)檫壿嬌鲜峭耆_的,不明白其中緣由的同學(xué)甚至?xí)岩墒窍到y(tǒng)bug。
遇到這種多線程讀寫狀態(tài),而且存在順序依賴的場(chǎng)景,不能簡(jiǎn)單依賴代碼邏輯。解決這種data race場(chǎng)景有一個(gè)簡(jiǎn)單辦法:加鎖,比如使用NSLock,將對(duì)順序有依賴的代碼塊整個(gè)原子化,加鎖之所以有用是因?yàn)闀?huì)生成memory barrier,從而避免了編譯器優(yōu)化。
場(chǎng)景四:內(nèi)存泄漏
iOS剛誕生不久時(shí),還沒有多少Best Practise,不少人寫單例的時(shí)候還不會(huì)用到dispatch_once_t,而是采用如下直白的寫法:
Singleton *getSingleton() {
static Singleton *sharedInstance = nil;
if (sharedInstance == nil) {
sharedInstance = [[Singleton alloc] init];
}
return sharedInstance;
}
這種寫法的問題是,多線程環(huán)境下,thread A和thread B會(huì)同時(shí)進(jìn)入sharedInstance = [[Singleton alloc] init];,Singleton被多創(chuàng)建了一次,MRC環(huán)境就產(chǎn)生了內(nèi)存泄漏。
這是個(gè)經(jīng)典的例子,也是data race的場(chǎng)景之一,其結(jié)果是造成額外的內(nèi)存泄漏,這種race也可以算作是benign的,但也是我們平時(shí)編寫代碼應(yīng)該避免的。
上面幾個(gè)是我們寫iOS代碼比較容易遇到的,還有其他一些就不一一舉例了,只要理解了data race的含義都不難分析這些race導(dǎo)致的具體問題。
BOOL是否多線程安全?
在之前那篇iOS多線程安全的文章中,我提到對(duì)于BOOL類型的property來說,聲明為atomic并沒有意義,nonatmoic對(duì)于BOOL的get,set也是安全的。
@property (nonatomic, assign) BOOL isValid;
原理我也簡(jiǎn)單解釋了一下,但之后有一些朋友私底下和我交流,還是對(duì)這一觀點(diǎn)存疑。
實(shí)際上,上面的WWDC視頻中,蘋果的工程師也提到了這一點(diǎn):有些人認(rèn)為pointer sized的變量操作時(shí)是天然多線程安全的。所謂的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)對(duì)于這種行為定義是undefined behavior,意思是最后的結(jié)果是不確定的,不同的編譯器針對(duì)不同的CPU架構(gòu)所產(chǎn)生的最后執(zhí)行文件,其執(zhí)行結(jié)果是沒有規(guī)定的,如果有哪個(gè)硬件平臺(tái)上出現(xiàn)了crash,那么也沒有違背C的標(biāo)準(zhǔn),因?yàn)镃沒有規(guī)定其一定是原子操作。
同時(shí),據(jù)我所知(扒過一些資料),以及我這么些年寫iOS代碼的經(jīng)歷,nonatomic修飾的BOOL確實(shí)是原子操作且多線程安全的,我也沒找到什么樣的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的方式很簡(jiǎn)單,只需要在Xcode的scheme中勾選Thread Sanitizer即可,如下圖:

這里要注意的是,Thread Sanitizer現(xiàn)階段只能在模擬器環(huán)境下執(zhí)行,真機(jī)還不支持,而且我測(cè)試發(fā)現(xiàn),只支持64位系統(tǒng),也就是說iPhone 5及其更早的模擬器也不支持,iPhone 5s之后才是64位系統(tǒng)。
勾選之后,重新編譯運(yùn)行代碼即可,我用下面一段代碼做測(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 ++;
}
運(yùn)行之后會(huì)在Xcode中出現(xiàn)如下提示:

很直觀,Xcode直接提示你發(fā)生了data race的變量及其代碼位置,同時(shí)還清晰的展示了函數(shù)當(dāng)前的各線程調(diào)用棧,十分清晰,接下來你要做的就是增加同步操作,比如加鎖,從而消除data race,再運(yùn)行測(cè)試是否生效。
原理
Thread Sanitizer的工作原理在WWDC的視頻中也介紹過了,大家可以仔細(xì)看下視頻,大致原理是記錄每個(gè)線程訪問變量的信息來做分析,值得一提的是,現(xiàn)階段的Thread Sanitizer最多只同時(shí)記錄4個(gè)線程的訪問信息,在復(fù)雜的場(chǎng)景下,可能出現(xiàn)偶爾檢測(cè)不出data race的場(chǎng)景,所以需要長(zhǎng)時(shí)間經(jīng)常性的運(yùn)行來盡可能多的發(fā)現(xiàn)data race,這也是為什么蘋果建議默認(rèn)開啟Thread Sanitizer,而且Thread Sanitizer造成的額外性能損耗非常之小。
結(jié)束語
以上就是Xcode 8新增的多線程問題調(diào)試工具Thread Sanitizer,了解背后原理再去使用工具才更得心應(yīng)手,趕緊拿公司項(xiàng)目跑一跑吧,發(fā)現(xiàn)一堆data race可能性一般來說是還是比較高的 :)