關於如何構建一個線程安全的數組引發的思考

0,背景

最近在做一個需求,對於一個BufferArray(NSMutableArray並非線程安全的),進行add,remove等操作,內部做了隊列調度,避免因外部介麵線程環境的複雜造成的異常。但是這樣總是有一種走鋼絲的感覺,每一次對於BufferArray進行操作時總是要小心翼翼的GCD切換一下隊列,否則就有非常大的風險,一旦第二條線程訪問數組,我們的程序也就GG了。

NSMutableArray *bufferArray = [NSMutableArray array]; //多線程訪問,GG dispatch_async(dispatch_get_global_queue(0, 0), ^{ [bufferArray addObject:obj]; }); dispatch_async(dispatch_get_global_queue(0, 0), ^{ [bufferArray removeObject:obj]; });

這並非我們想看到的,考慮到這裡對於性能的要求並不苛刻(線程安全的操作由於需要隊列的調度,是需要消耗很大一部分性能的),而且考慮到未來的長治久安,決定動手做一個線程安全的數組或對象。

同時線程安全數組即是構建成功了,也要做操作數組的安全控制,因為多線程的問題,一個數組對象可能同時被多個線程訪問,可以安全的訪問了,並不代表多線程操作數據的時候不會對彼此造成影響,所以這裡是有待商榷的。往往構建一個不符合蘋果設計的產物出現時,我們往往需要考慮我們是否的確需要這個東西,這裡不往下深入探討,因為構建的過程中帶來的靈感和收穫遠遠大於這次的成果。

現在Target有了,僅僅需要付出對應的Action了,不要著急寫,先思考下構建這個線程安全數組的初步設計和預期效果。

  1. 能夠實現多線程的安全訪問,同時進行讀寫操作也不會發生Exception
  2. 使用者能夠很方便的調用,方法也得和原本的數組對象相同吧
  3. 有那麼多可變非線程安全的容器類(Array, Set, Dictionary),能不能做個基類,包裝下容器,讓繼承這個基類的對象,自動就可以線程安全,對於使用者而言也可以輕鬆實現learn once, write anywhere。

初期設計就這麼愉快的決定了,那麼我們需要考慮幾個技術點。

  1. 需要一個同步鎖方案。
  2. 我們這個線程安全的子類可能會隨時會需要添加新的方法,如果每一個新的方法都需要在裡面進行鎖保護的實現,又顯得很愚蠢了。
  3. 如何能夠保證我們想要的類能夠通過繼承的方式,將方法調用交給內部容器。

那麼我們就一個接一個的解決問題,本文旨在提供一個設計的思路,所以對於一些技術的選擇方案,並不是很細緻,甚至闡述的有些粗糙。

1,同步鎖方案

作為一個開發者,鎖的方案多種多樣,諸如GCD可以為我們提供的group,barrier,semaphone,NSLock,OS的自旋鎖,@synchronized等等都很多,這邊我選擇了barrier,僅僅是一種方案的選擇罷了,至少不能選擇其他一些性能廣受詬病的,當然我們距離性能的瓶頸其實還是很遠的。

所以我們的一些對於數組的操作就變成了如下這樣:

//In, async nice performance! dispatch_barrier_async(self.queue, ^{ [bufferArray addObject:obj]; }) //out, sync return at once! __block id ret; dispatch_barrier_sync(self.queue, ^{ ret = [bufferArray count]; }) return ret;

相信很多同學有這方面需求的時候,到網上去查也會是類似的代碼,這樣我們就可以保證了bufferArray的安全訪問。

這樣的話我們構建一個基類Class,內部放置一個id類型容器,使用方繼承這個Class,然後,設置容器設置為自己實際會使用的容器,把需要用到的方法諸如-addObject,-count,依次重寫,在實現裡面加上鎖機制,這樣就實現了可變容器的線程安全,可是這麼麻煩的方式使用方也難以接受啊,線程安全的代碼還是移交給了使用方,你除了做了個容器基類啥也沒實現啊,更過分的是使用者每次添加一個方法,都要寫一遍這線程調度方法,血腥程度,令人髮指,寫的越多,出現問題的概率越大,能不能把核心代碼收縮在一個小區域之中呢?

2,如何能夠讓使用方省心,讓功能更加便於擴展

得益於做這個基礎組件前,某個業務需要我要讀protobuf編譯出來的Objective-C的源碼實現,裡面對於消息轉發的部分觸動了我,這不就滿足我們的實際場景么,讓所有的方法都會在一個地方發生,進而去約束具體的實現。

+ (BOOL)resolveInstanceMethod:(SEL)sel { const GPBDescriptor *descriptor = [self descriptor]; if (!descriptor) { return NO; } ResolveIvarAccessorMethodResult result = {NULL, NULL}; for (GPBFieldDescriptor *field in descriptor->fields_) { BOOL isMapOrArray = GPBFieldIsMapOrArray(field); if (!isMapOrArray) { // Single fields. if (sel == field->getSel_) { ResolveIvarGet(field, &result); break; } else if (sel == field->setSel_) { ResolveIvarSet(field, descriptor.file.syntax, &result); ... } return [super resolveInstanceMethod:sel];}

消息轉發觸發的時機了解OC消息機制的同學並不陌生,當runtime查找不到一個Selector的IMP時,就會觸發消息轉發。說白了意思就是,你接了個活,我們以為你行,結果你不行,那現在我給你最後一個機會,活放在你面前,你是選擇馬上學會怎麼辦,還是交給別人辦,你要是也不管,那我們可就不管了,直接拋異常,程序終止掉吧。

我們可以把-addObject, -count這種Selector調用轉發給內部的容器去實現,在實現的外圍包裹上我們的線程調度代碼,一切都變得那麼完美。

那麼問題就順利成章的轉變成了如何觸發消息的轉發,也就是說要給我們的線程安全對象傳遞一個它並不能處理的方法,當然這個方法最好還要能提示出來。

這裡採用protocol的@optional來作為方法的聲明,通過遵循這個協議,而不做對應的實現,就可以實現消息的轉發,那麼這裡就需要嚴格限制了,也就是協議方法的Selector必須和實際容器能夠對應,保證實際容器自己具有實現。

最後的協議會是這樣的樣子

NS_ASSUME_NONNULL_BEGIN@protocol XXMutableArrayProtocol <NSObject>@optional- (id)lastObject;- (id)objectAtIndex:(NSUInteger)index;- (NSUInteger)count;- (void)addObject:(id)anObject;- (void)insertObject:(id)anObject atIndex:(NSUInteger)index;- (void)removeLastObject;- (void)removeObjectAtIndex:(NSUInteger)index;- (void)removeAllObjects;- (void)replaceObjectAtIndex:(NSUInteger)index withObject:(id)anObject;@endNS_ASSUME_NONNULL_END

這些方法都可以通過到實際的類中去copy獲得,成本也不高,而且可以按照自己的需求任意填充,不需要寫功能代碼,十分方便擴展。

那麼就剩最後一個問題了,如何讓子類不必實現過多的代碼,這裡其實也就是實現下基類的消息轉發就可以了。

3,基類消息轉發的代碼如何實現

消息轉發分為多種,增加實現,轉發target,構建方法簽名打包轉發,這裡不做消息轉發特性的科普。在我們這個場景下,我們希望的是方法和調用方能夠同時出現在我們隊列調度的上下文里,所以毫無疑問需要選擇方法簽名轉發的方案。

- (void)forwardInvocation:(NSInvocation *)anInvocation OBJC_SWIFT_UNAVAILABLE("");- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector OBJC_SWIFT_UNAVAILABLE("");

剩下的就是實現了

#pragma mark - forward method- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector { return [[self.container class] instanceMethodSignatureForSelector:aSelector];}- (void)forwardInvocation:(NSInvocation *)anInvocation { NSMethodSignature *signature = anInvocation.methodSignature; const char *returnType = signature.methodReturnType; @weakify(self) if (!strcmp(returnType, "v")) { dispatch_barrier_async(self.queue, ^{ @strongify(self) [anInvocation invokeWithTarget:self.container]; }); } else { dispatch_barrier_sync(self.queue, ^{ @strongify(self) [anInvocation invokeWithTarget:self.container]; }); }}#pragma mark - method overrite//稍加修飾下- (NSString *)description { return self.container.description;}

通過NSInvocation獲取的方法簽名中的類型編碼可以判斷出返回值類型,來選擇使用同步或者是非同步操作,v是無返回值,這種也就是In操作,非v的,往往是獲取數組中的信息,也就是Out操作。

最後總結一下整個使用,繼承基類,子類中Override掉init方法為container填充正確的容器類(Array, Dictionary, Set),構建出一個協議,從容器類裡面copy出你認為需要線程安全的方法聲明填充並標註為optional,子類實現協議,非常簡單好用。

似乎一切都那麼完美,然後在Example工程中到ViewController裡面-viewDidLoad中使用了下,發現也還不錯,多線程操作也是安全的,滿心歡喜以為可以告一段落了。

4,問題的排查和解決

可是到了寫Unit Test的時候,意外發生了,EXC_BAD錯誤發生了,感謝單測,救了我一命,提早給了我一次bugfix的機會。

//單測代碼 let array = XXThreadSafeMutableArray() for _ in 1...100 { let obj = NSObject() array.add(obj) } ...

EXC_BAD的錯誤很清晰,懸垂指針。代碼只做了一件事,-addObject: 這個方法僅會涉及到兩個對象,一個是數組,一個是obj對象,數組是不可能出問題的,那麼就說明obj這個對象出了問題,obj在for循環的上下文中是不可能被釋放的,出了上下文,array也已經引用了obj,也不應該出現問題。

回想一開始的代碼,這裡會有一次非同步操作,obj是包裝在invocation的參數列表中,也就說明invocation在包裝message的時候,參數列表中僅僅是引用而沒有持有obj對象,出了for循環對象就應該被釋放掉了,而我們的非同步隊列任務很有可能還沒有開始!

dispatch_barrier_async(self.queue, ^{ @strongify(self) [anInvocation invokeWithTarget:self.container]; });

可是為什麼在-viewDidLoad中沒事,而單測中卻發生了這個問題,那麼就思考了單測環境和-viewDidLoad的差別,也就是runloop的有無。在單測中是沒有runloop的,而-viewDidLoad中的對象會因為runloop構建出的自動釋放池得以為內部構建的對象續一波命,就是續這一波,給我們造成了任務已經完成的假象,在這一次runloop結束之前,自動釋放池不會向對象發送release消息。為了驗證這個問題,我在-viewDidLoad中手動添加自動釋放池,提前了向對象發送release的時機,果然也拋出了相同的異常,也就是說obj對象過早的釋放掉了。

//viewDidLoad中 let array = XXThreadSafeMutableArray() for _ in 1...100 { autoreleasepool { let obj = NSObject() array.add(obj) } }

想要解決這個問題,第一個想法就是非同步改為同步,立即執行,但是這樣就放棄了多線程的優勢,在構建這個數組的使用場景中,我並不需要數組中的對象有序(多線程同時操作數組的時候,也不可能會有序了),所以核心的問題是如何增加obj對象的持有者,使其不會過早被釋放。

NSInvocation具有一個實例方法- retainArguments,使其能夠持有參數列表中的參數,通過調用這個方法可以解決這個問題(過程中一開始並沒有想從這裡下手,改MRC,增加引用計數,各種方式無所不用其極)。

- retainArguments

For efficiency, newly created NSInvocation objects don』t retain or copy their arguments, nor do they retain their targets, copy C strings, or copy any associated blocks. You should instruct an NSInvocation object to retain its arguments if you intend to cache it, because the arguments may otherwise be released before the invocation is invoked. NSTimer objects always instruct their invocations to retain their arguments, for example, because there』s usually a delay before a timer fires.

這樣一切可以說是完事大吉了,最後的代碼如下

- (void)forwardInvocation:(NSInvocation *)anInvocation { [anInvocation retainArguments]; NSMethodSignature *signature = anInvocation.methodSignature; const char *returnType = signature.methodReturnType; @weakify(self) if (!strcmp(returnType, "v")) { dispatch_barrier_async(self.queue, ^{ @strongify(self) [anInvocation invokeWithTarget:self.container]; }); } else { dispatch_barrier_sync(self.queue, ^{ @strongify(self) [anInvocation invokeWithTarget:self.container]; }); }}

5,一些思考

剛開始接觸到-retainArguments方法時,有想過,蘋果默認不持有參數列表中的參數,那麼當message發送完成後,如果持有了如何對參數進行release呢,我們是沒辦法判斷一個對象a是否持有對象b的,也怪我沒有仔細看對應的文檔,語言層面無法解決的問題,蘋果通過增加狀態變數實現了,NSInvocation對象是具備argumentsRetained這樣的一個屬性,來判斷是否需要對參數發送release消息,這樣流程也就完善了。

6,總結

  • 在設計一個組件或者模塊時,一定要將可變狀態降低到最小的上下文當中,也就是所說的高內聚
  • 善用OC提供的一些基礎機制,進行好的設計,能夠降低很大的開發精力。
  • 時刻注意使用場景,在不同的上下文當中,相同的代碼可能會發生截然不如的結果。
  • 單元測試,單元測試,單元測試。

推薦閱讀:

TAG:iOS開發 | 多線程 | ObjectiveC |