一道題考你對__autoreleasing和__block的理解
- 2019 年 10 月 11 日
- 筆記
考慮下面的程式碼,有哪些問題,如何把他改成正確的形式?
@interface TestObj : NSObject @end @implementation TestObj - (void)methodWillSetError:(NSError **)error group:(dispatch_group_t)group { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ *error = [NSError errorWithDomain:@"domain" code:1 userInfo:nil]; dispatch_group_leave(group); }); } @end void testBlockAndAutoReleasePool() { NSLog(@"Hello, World!"); NSError *error; TestObj *testObj = [TestObj new]; dispatch_group_t group = dispatch_group_create(); dispatch_group_enter(group); [testObj methodWillSetError:&error group:group]; dispatch_async(dispatch_get_global_queue(0, 0), ^{ dispatch_group_wait(group, DISPATCH_TIME_FOREVER); NSLog(@"error is %@", error); }); } int main(int argc, const char * argv[]) { @autoreleasepool { // insert code here... testBlockAndAutoReleasePool(); NSRunLoop *runLoop = [NSRunLoop currentRunLoop]; [runLoop run]; } return 0; } methodWillSetError會去非同步設置error的值,然後另外一個地方在error設置後去訪問error的值。
實際上現在新版的Xcode已經會對
*error = [NSError errorWithDomain:@"domain" code:1 userInfo:nil];
進行警告
Block captures an autoreleasing out-parameter, which may result in use-after-free bugs
那麼這個警告是什麼意思呢?
實際上這個方法
- (void)methodWillSetError:(NSError * *)error group:(dispatch_group_t)group
的error,雖然沒有明確指定記憶體的修飾符(strong, weak, autoreleasing,但是如果你直接定義NSError **error的臨時變數,在arc下xcode會編譯失敗,要求你明確指定記憶體關係)但是編譯器會默認轉成NSError * __autoreleasing*,而在block中捕獲一個__autoreleasing的out-parameter是很容易造成錯誤的。
為什麼這麼說呢?
void testAutoReleaseError(NSError **error) { [@[@1, @2] enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { if (idx == 0) { *error = [[NSError alloc] initWithDomain:@"domain" code:1 userInfo:nil]; } }]; NSLog(@"error:%@" , *error); } 我們用個簡化例子來看一下,這個是很容易隨手寫下的程式碼。打開Xcode的Zombie,會發現在
NSLog(@"error:%@" , *error);
那一行crash掉,訪問了個已經釋放的對象,error已經被dealloc掉了。
為什麼呢?前面說了error默認是NSError *__autoreleasing *,也就是說*error指向的對象是個__autoreleasing對象,所以
*error = [[NSError alloc] initWithDomain:@"domain" code:1 userInfo:nil];
的賦值在arc下會加個autorelease的調用變成
*error = [[[NSError alloc] initWithDomain:@"domain" code:1 userInfo:nil] autorelease];
而eumerateXXX這一系列的容器介面,裡面的實現是包了一層Autorelease Pool的,所以在block運行完後Autorelease Pool被釋放了附帶著把*error指向的對象給釋放了,*error就指向了個野指針,考慮到block運行時候外層存在是否包裹著一層Autorelease Pool的不確定性,所以clang直接把在block里捕獲__autoreleasing的out-parameter給警告了。解決這個問題有兩種方案,一種是指定error類型為NSError *__strong *。
把一開始的案例中的__autoreleasing修改成__strong後,會發現error打出來還是空的,這是為什麼呢?
因為block捕獲變數的時候是捕獲變數的當前值,你對變數之後的重新賦值對block里的變數不會有影響。而在block裡面也不能對變數做修改(block里的error實際上是個拷貝了當前block定義時候error的值的和block綁定的同名變數)
實際上除了error打出來是空的問題,這裡還有個嚴重的可能會導致各種異常情況的bug,普通debug可能看不出來,但是打開Xcode的Address Sanitizer 的 Detect use of stack after return,就會發現在
*error = [NSError errorWithDomain:@"domain" code:1 userInfo:nil];
賦值這裡會提示說Use of stack memory after return,因為在
void testBlockAndAutoReleasePool() { NSLog(@"Hello, World!"); NSError *error; TestObj *testObj = [TestObj new]; dispatch_group_t group = dispatch_group_create(); dispatch_group_enter(group); [testObj methodWillSetError:&error group:group]; dispatch_async(dispatch_get_global_queue(0, 0), ^{ dispatch_group_wait(group, DISPATCH_TIME_FOREVER); NSLog(@"error is %@", error); }); } 這一層的error是個棧變數,對其取地址得到是棧上的空間,等到dispatch_after去設置error的值的時候,棧空間由於函數已經返回了已經被銷毀了,這裡對error的寫入會導致棧的破壞(可能某個棧變數的值就被覆蓋了),導致各種奇怪crash你還無從定位。
這裡就需要__block,__block的修飾可以將變數從棧空間的作用域提升到堆上。這裡還有個小知識點,如果你直接加__block會發現還是在同樣的地方會報出Use of stack memory after return,因為雖然__block可以將變數從棧空間的作用域提升到堆上,但它這個時機是在block被copy的時候才發生的,在我們的程式碼里,是先調用了methodWillSetError再調用dispatch_async,在methodWillSetError對error取地址的時候變數還在棧上,所以需要將async和methodWillSetError交換一下順序才能保證程式碼正常運行。
回過頭來再來看下,__autoreleasing到底是什麼,為什麼clang要把__autoreleasing作為默認選項,它和__strong的區別是什麼?起初我們定義NSError *error的時候這裡arc下不是默認是NSError * __strong error嗎,把它的地址傳遞給個NSError * __autoreleasing *會發生什麼?
https://clang.llvm.org/docs/AutomaticReferenceCounting.html
雖然clang的這篇文章是最權威也最全的文檔,但是裡面介紹還是很繞口的。所以這裡就再說明一下。
把一個NSError *__strong*傳遞給一個接收NSError *__autoreleasing*參數的方法的時候,clang採用了一個pass-by-writeback的策略。
具體說來就是,在這一步驟
[testObj methodWillSetError:&error group:group];
clang 會改寫成
NSError *__autoreleasing temp_error = error; [testObj methodWillSetError:&temp_error group:group]; error = temp_error; 所以即使error的作用域已經被提升到了堆空間,但是如果error的修飾符是`NSError *__autoreleasing*,就會被轉成一個在棧上的臨時變數,傳遞到方法里非同步去設置error的時候還是會造成棧的地址破壞。
那麼為什麼默認是__autoreleasing呢?在大部分的程式碼中其實往往就是一個局部變數(默認__strong類型),傳遞給一個out-parameter變數,這樣就要經歷這個__autoreleasing的轉來轉去的過程。
其實沒啥特殊的原因,主要就是慣例,就和alloc,copy,mutableCopy和new家族的方法默認返回的是Retained return values,而其他函數通常返回的就是個Unretained return values一樣。在Cocoa編程中,out-parameter返回的就是個autoreleasing的對象。(所以如果你在mrc下寫一個傳出out-parameter的方法,要確保這個out-parameter在離開這個方法的時候是個autoreleasing的狀態,如果是個+1所有權的對象,那麼就會有記憶體泄漏風險)。對比如果要把所有這種out-parameter的方法的參數加上個__autoreleasing的修飾,還不如直接所有的out-parameter默認就是__autoreleasing。所以這個只是一個最不壞的方案。
最後,給讀者出個小問題,前面說了解決Block captures an autoreleasing out-parameter有兩個辦法,在我們的方法中,由於是要去非同步設置error的值,所以語義上就應該是個__strong的修飾符,這是方法一,那麼如果只是同步方法,又想要在block里設置這個out-parameter,應該要怎麼做呢,這個就留給讀者思考了。