處理多並發和可重入性問題,是每個庫發展過程中面臨的比較困難的挑戰之一。在Parse平台上,我們盡最大的努力保證你在使用我的SDKs時所做的操作都是線程安全的,保證不會出現性能問題。
在這篇文章中我們將會復習一些關於如何以簡潔、安全、干淨的方式處理多並發和競爭條件下的基本概念。
首先,在進入細節討論之前,我們先定義以下概念:
談到線程安全我們經常討論的首要事情是線程安全的是天生難以實現的。由於線程調度方式、內存垃圾回收,緩存錯誤,分支預測等等復雜的工作,與線程有關的問題很難被記錄下來,也很難修復。鑒於這些因素,無論何時只要有可能,不要寫可能陷入多線程環境的代碼。如果你遵守下面的指導原則,避免多線程環境就會相當容易:
競爭條件
競爭狀態是多線程系統的克星。當你不直接控制調度(如發生在單個線程的事情),你怎麼能確保事情發生的順序符合你的預期?網上有很多好的關於如何追蹤競態條件的建議,但很少有關於如何避免它們的介紹。
大多數競爭狀態是由共享可變狀態引起的,如以下事例:
void thread1() { _sharedState = 1; // Do stuff if (someCondition) { _sharedState = 0; } // Do stuff _sharedState = 1; } void thread2() { // Do stuff if (_sharedState == 0) { _sharedState = 1; } // Do stuff }
如果線程1的someCondition變量的值為true ,_shareState的值是0還是1?這取決於線程2的狀態,無論線程1是否有條件和指定值。
可變狀態並不一定意味著變量。包括文件系統、網絡、系統調用,等等的狀態可能在你的應用程序之外被改變。
States and Copying
避免可變狀態的最好方法之一是有一個嚴格的關於如何把管理的狀態作為一個整體的指導方針。在Parse庫中,我們堅持一下三個規則:
記住全局狀態是不好的(包括單例),盡可能的避免使用它。在Parse庫中,我們更喜歡使用依賴注入(也稱控制反轉)設計模式而不是單例(例如:-initWithObjectController: vs [ObjectController shareController]),原因是它幫助我們一直記錄對象的用法,同時也加強我們對線程的推理能力,如果必要的話,可以使用本地線程存儲替代全局變量。
正如上面提到的,可變的狀態(以及全局變量)使處理並發性更難。所以不惜一切代價避免它。
原子性
正如上文中所說的那樣,原子性的定義如下:
一個保證操作完成或者失敗的屬性,這個屬性永遠不會產生一個中間狀態或者一個無效的狀態。
這個定義看起來很神秘,有點難以理解。但是,它在實踐中這意味著什麼呢?
假如你有一個計數變量y,它需要在多線程裡被更新。解決這問題比較天真的方法是讓y直接增加,例如y++。然而,這種做法有一個重要缺陷,就是如果有兩個線程同時增加y,那怎麼辦。這就迫使你去找其他解決方案。
有一個解決方案是在計數變量上附加一個鎖,但這將顯著降低性能。另一個解決方案(根據情況)可能是在每一個單獨的線程的上使用各子的計數器,但這增加了程序的內存使用和認知負荷。
但是,我們還有更好的方法。使用指示器的某些特殊指令,這些指令是從中分離出來的功能,他們能確保在一個內存地址上所有的操作都是正確同步的。這些操作是指示器發出的,而不是系統操作。那些創建無鎖數據結構的基礎理論是很實用的,但是不在本文的討論范圍中。
一般來說,如果你在一個指定的地址上操作是原子性的,那麼沒有讀取那個地址不可能使你的應用處於無效狀態。當這些參數一旦和原子性屬性聯合,就能確保單個參數不能處於無效狀態。注意作為一個整體的對象仍然可能處於無效狀態,原因是每個原子性操作的表現是完全獨立於其他正在另外那些內存地址上執行的原子性操作的。
鎖
當原子性不能滿足你的目的時,在鎖定線程安全方面,你的確有很多傳統的方法。鎖存在多種形式,問題是要在眾多的形式中找到一個最好的方式,來解決許多矛盾重生的困境。下面我們將討論iOS/OS中一些默認的情況。
在討論鎖之前,我們首先要知道什麼時候需要鎖。在線程安全開發時最大的錯誤之一是輕易的大量使用鎖。當然,如果你你鎖定每一個調用對象的方法,那是不可能有競爭條件的。但是,如果你在獲取可變狀態的時候,將狀態和線程分離,這樣會更好。
下面,我們將演示幾種一下幾種鎖的,一下面的例子開始
這簡單的函數,看起來是完全沒有問題,但是它既不是線程安全的也不是可重入的。使用者段代碼的時候,會出現很多問題。
在並發的實際使用實例中,操作符*=不是原子性的。這就意味著如果有兩個線程同時調用incrementFooBy:方法,我們最終會得到一個中間值,並且它不代表任何有效的狀態。
在可重入的實際使用實例中,如果在上面例子中的乘法和賦值中間引起了一個中斷,我們會遇到和上面相似的問題,就是我們會得到一個奇怪的中間值。
所有上面的代碼不能正常工作,我們需要做一些改變使它更好。
方法1:使用 @synchronized 關鍵字
這解決了並發問題和可重入問題,但是也產生了幾個新問題。第一,很明顯的是我們通過同步對象本身,限制了其他線程對該對象的同步,如果大量使用這個函數,將會出現很糟糕的情況。
第二問題是由@synchronized帶來的,眾數周知,@synchronized的在性能方面的表現是很糟糕的。但是,在Objective - C 中,它是創建鎖的一個最簡單的方法。這並不意味著不存在更好的方法,創建鎖。
方法2:串行隊列
從某種意義上說,在你的Cocoa/Cocoa Touch編程生涯,你一定能接觸到串行隊列中的一個,那就是主線程。一個串行的調度隊列是一個以線性方式執行的任務列表,這些任務都是來自OS系統的線程。然而,調度隊列有一些獨特的特性使它比@synchronized更適合創建線程鎖。
@implementation SomeObject() { dispatch_queue_t _barQueue; // = dispatch_queue_create("com.parse.example.queue", DISPATCH_QUEUE_SERIAL); } - (NSInteger)foo { __block NSInteger result = 0; dispatch_sync(_barQueue, ^{ result = _foo; }); return result; } - (void)incrementFooBy:(NSInteger) x { dispatch_sync(_barQueue, ^{ _foo += x; }); } @end
然而,當你的資源是相互排斥的時候,使用調度隊列會產生以下缺點包括:
在大多數場景下這些性能優勢得權衡是值得的,並且要廣泛應用在SDK中。
方法3:並行隊列
在讀寫平衡的場景中(例如相同數量的get和set方法),方法2是很好的。但是,在實際生活中,那種情況是很少出現的。你經常遇到的情況是多次讀取某個數據,只是偶爾去寫數據。
調度以並行對列的形式建立在支持所謂的讀寫鎖的基礎之上。但是,他們的工作和其他大多數隊列一樣,他們試圖讓更多的執行人盡可能的單獨訪問dispatch_barrier塊。這就允許隊列在並行隊列的上下文中單獨運行,並幫助我們加速無競爭條件下得用例。
@implementation SomeObject() { dispatch_queue_t _barQueue; // = dispatch_queue_create("com.parse.example.queue", DISPATCH_QUEUE_CONCURRENT); } - (NSInteger)foo { __block NSInteger result = 0; dispatch_sync(_barQueue, ^{ result = _foo; }); return result; } - (void)incrementFooBy:(NSInteger)x { dispatch_barrier_sync(_barQueue, ^{ _foo += x; }); } @end
上面代碼的另一個優點是,它使我們更清楚的知道那些函數更新實例變量,而那些函數沒有。
知道並行隊列的性能開銷比串行隊列的開銷要大得多時很重要的。在競爭環境下(例如dispatch_barrier_sync的多次調用),有一個顯而易見的基准就是一個並行隊列
在其內部旋轉鎖上花費的時間比一個串行隊列多的多。
結論
在Parse庫中,我們努力創造最好的APIs接口,最好的線程支持。我們在這個SDK內部使用的大量機制,對任何一個移動應用和都是最好的。請繼續關注我們,未來幾周我們會繼續發布類似的文章。我們會分享更多關於測試理念,知識等等。