這篇文章里,我們將會討論1些 iOS 和 OS X 都可使用的底層 API。除 dispatch_once ,我們1般不鼓勵使用其中的任何1種技術。
但是我們想要揭露出表面之下深層次的1些可利用的方面。這些底層的 API 提供了大量的靈活性,隨之而來的是大量的復雜度和更多的責任。在我們的文章常見的后臺實踐中提到的高層的 API 和模式能夠讓你專注于手頭的任務并且免于大量的問題。通常來講,高層的 API 會提供更好的性能,除非你能承受起使用底層 API 帶來的糾結于調試代碼的時間和努力。
雖然如此,了解深層次下的軟件堆棧工作原理還是有很有幫助的。我們希望這篇文章能夠讓你更好的了解這個平臺,同時,讓你更加感謝這些高層的 API。
首先,我們將會分析大多數組成 Grand Central Dispatch 的部份。它已存在了好幾年,并且蘋果公司延續添加功能并且改良它。現在蘋果已將其開源,這意味著它對其他平臺也是可用的了。最后,我們將會看1下原子操作——另外的1種底層代碼塊的集合。
也許關于并發編程最好的書是 M. Ben-Ari 寫的《Principles of Concurrent Programming》,ISBN 0⑴3⑺01078⑻。如果你正在做任何與并發編程有關的事情,你需要讀1下這本書。這本書已30多年了,依然非常出色。書中簡潔的寫法,優秀的例子和練習,帶你領略并發編程中代碼塊的基本原理。這本書現在已絕版了,但是它的1些復印版仍然廣為流傳。有1個新版書,名字叫《Principles of Concurrent and Distributed Programming》,ISBN 0⑶21⑶1283-X,好像有很多相同的地方,不過我還沒有讀過。
也許GCD中使用最多并且被濫用功能的就是 dispatch_once 了。正確的用法看起來是這樣的:
上面的 block 只會運行1次。并且在連續的調用中,這類檢查是很高效的。你能使用它來初始化全局數據比如單例。要注意的是,使用 dispatch_once_t 會使得測試變得非常困難(單例和測試不是很好配合)。
要確保 onceToken 被聲明為 static ,或有全局作用域。任何其他的情況都會致使沒法預知的行動。換句話說,不要把 dispatch_once_t 作為1個對象的成員變量,或類似的情形。
退回到遠古時期(其實也就是幾年前),人們會使用 pthread_once ,由于 dispatch_once_t 更容易使用并且不容易出錯,所以你永久都不會再用到 pthread_once 了。
另外一個常見的小火伴就是 dispatch_after 了。它使工作延后履行。它是很強大的,但是要注意:你很容易就墮入到1堆麻煩中。1般用法是這樣的:
第1眼看上去這段代碼是極好的。但是這里存在1些缺點。我們不能(直接)取消我們已提交到 dispatch_after 的代碼,它將會運行。
另外1個需要注意的事情就是,當人們使用 dispatch_after 去處理他們代碼中存在的時序 bug 時,會存在1些有問題的偏向。1些代碼履行的過早而你極可能不知道為何會這樣,所以你把這段代碼放到了 dispatch_after 中,現在1切運行正常了。但是幾周以后,之前的工作不起作用了。由于你其實不10分清楚你自己代碼的履行次序,調試代碼就變成了1場噩夢。所以不要像上面這樣做。大多數的情況下,你最好把代碼放到正確的位置。如果代碼放到 -viewWillAppear 太早,那末也許 -viewDidAppear 就是正確的地方。
通過在自己代碼中建立直接調用(類似 -viewDidAppear )而不是依賴于 dispatch_after ,你會為自己省去很多麻煩。
如果你需要1些事情在某個特定的時刻運行,那末 dispatch_after 也許會是個好的選擇。確保同時斟酌了 NSTimer,這個API雖然有點笨重,但是它允許你取消定時器的觸發。
GCD 中1個基本的代碼塊就是隊列。下面我們會給出1些如何使用它的例子。當使用隊列的時候,給它們1個明顯的標簽會幫自己很多忙。在調試時,這個標簽會在 Xcode (和 lldb)中顯示,這會幫助你了解你的 app 是由甚么決定的:
隊列可以是并行也能夠是串行的。默許情況下,它們是串行的,也就是說,任何給定的時間內,只能有1個單獨的 block 運行。這就是隔離隊列(原文:isolation queues。譯注)的運行方式。隊列也能夠是并行的,也就是同1時間內允許多個 block 1起履行。
GCD 隊列的內部使用的是線程。GCD 管理這些線程,并且使用 GCD 的時候,你不需要自己創建線程。但是重要的外在部份 GCD 會顯現給你,也就是用戶 API,1個很大不同的抽象層級。當使用 GCD 來完成并發的工作時,你沒必要斟酌線程方面的問題,取而代之的,只需斟酌隊列和功能點(提交給隊列的 block)。雖然往下深究,仍然都是線程,但是 GCD 的抽象層級為你慣用的編碼提供了更好的方式。
隊列和功能點同時解決了1個連續不斷的扇出的問題:如果我們直接使用線程,并且想要做1些并發的事情,我們極可能將我們的工作分成 100 個小的功能點,然后基于可用的 CPU 內核數量來創建線程,假定是 8。我們把這些功能點送到這 8 個線程中。當我們處理這些功能點時,可能會調用1些函數作為功能的1部份。寫那個函數的人也想要使用并發,因此當你調用這個函數的時候,這個函數也會創建 8 個線程。現在,你有了 8 × 8 = 64 個線程,雖然你只有 8 個CPU內核——也就是說任什么時候候只有12%的線程實際在運行而另外88%的線程甚么事情都沒做。使用 GCD 你就不會遇到這類問題,當系統關閉 CPU 內核以省電時,GCD 乃至能夠相應地調劑線程數量。
GCD 通過創建所謂的線程池來大致匹配 CPU 內核數量。要記住,線程的創建其實不是無代價的。每一個線程都需要占用內存和內核資源。這里也有1個問題:如果你提交了1個 block 給 GCD,但是這段代碼阻塞了這個線程,那末這個線程在這段時間內就不能用來完成其他工作——它被阻塞了。為了確保功能點在隊列上1直是履行的,GCD 不能不創建1個新的線程,并把它添加到線程池。
如果你的代碼阻塞了許多線程,這會帶來很大的問題。首先,線程消耗資源,另外,創建線程會變得代價高昂。創建進程需要1些時間。并且在這段時間中,GCD 沒法以全速來完成功能點。有很多能夠致使線程阻塞的情況,但是最多見的情況與 I/O 有關,也就是從文件或網絡中讀寫數據。正是由于這些緣由,你不應當在GCD隊列中以阻塞的方式來做這些操作。看1下下面的輸入輸出段落去了解1些關于如何以 GCD 運行良好的方式來做 I/O 操作的信息。
你能夠為你創建的任何1個隊列設置1個目標隊列。這會是很強大的,并且有助于調試。
為1個類創建它自己的隊列而不是使用全局的隊列被普遍認為是1種好的風格。這類方式下,你可以設置隊列的名字,這讓調試變得輕松許多—— Xcode 可讓你在 Debug Navigator 中看到所有的隊列名字,如果你直接使用 lldb。(lldb) thread list 命令將會在控制臺打印出所有隊列的名字。1旦你使用大量的異步內容,這會是非常有用的幫助。
使用私有隊列一樣強調封裝性。這時候你自己的隊列,你要自己決定如何使用它。
默許情況下,1個新創建的隊列轉發到默許優先級的全局隊列中。我們就將會討論1些有關優先級的東西。
你可以改變你隊列轉發到的隊列——你可以設置自己隊列的目標隊列。以這類方式,你可以將不同隊列鏈接在1起。你的 Foo 類有1個隊列,該隊列轉發到 Bar 類的隊列,Bar 類的隊列又轉發到全局隊列。
當你為了隔離目的而使用1個隊列時,這會非常有用。Foo 有1個隔離隊列,并且轉發到 Bar 的隔離隊列,與 Bar 的隔離隊列所保護的有關的資源,會自動成為線程安全的。
如果你希望多個 block 同時運行,那要確保你自己的隊列是并發的。同時需要注意,如果1個隊列的目標隊列是串行的(也就是非并發),那末實際上這個隊列也會轉換為1個串行隊列。
你可以通過設置目標隊列為1個全局隊列來改變自己隊列的優先級,但是你應當克制這么做的沖動。
在大多數情況下,改變優先級不會使事情照你料想的方向運行。1些看起簡單的事情實際上是1個非常復雜的問題。你很容易會碰到1個叫做優先級反轉的情況。我們的文章《并發編程:API 及挑戰》有更多關于這個問題的信息,這個問題幾近致使了NASA的探路者火星漫游器變成磚頭。
另外,使用 DISPATCH_QUEUE_PRIORITY_BACKGROUND 隊列時,你需要格外謹慎。除非你理解了 throttled I/O 和 background status as per setpriority(2) 的意義,否則不要使用它。不然,系統可能會以難以忍耐的方式終止你的 app 的運行。打算以不干擾系統其他正在做 I/O 操作的方式去做 I/O 操作時,1旦和優先級反轉情況結合起來,這會變成1種危險的情況。
隔離隊列是 GCD 隊列使用中非常普遍的1種模式。這里有兩個變種。
多線程編程中,最多見的情形是你有1個資源,每次只有1個線程被允許訪問這個資源。
我們在有關多線程技術的文章中討論了資源在并發編程中意味著甚么,它通常就是1塊內存或1個對象,每次只有1個線程可以訪問它。
舉例來講,我們需要以多線程(或多個隊列)方式訪問 NSMutableDictionary 。我們可能會照下面的代碼來做:
通過以上代碼,只有1個線程可以訪問 NSMutableDictionary 的實例。
注意以下4點:
我們能夠改良上面的那個例子。GCD 有可讓多線程運行的并發隊列。我們能夠安全地使用多線程來從 NSMutableDictionary中讀取只要我們不同時修改它。當我們需要改變這個字典時,我們使用 barrier 來分發這個 block。這樣的1個 block 的運行時機是,在它之前所有計劃好的 block 完成以后,并且在所有它后面的 block 運行之前。
以以下方式創建隊列:
并且用以下代碼來改變setter函數:
當使用并發隊列時,要確保所有的 barrier 調用都是 async 的。如果你使用 dispatch_barrier_sync ,那末你極可能會使你自己(更確切的說是,你的代碼)產生死鎖。寫操作需要 barrier,并且可以是 async 的。
首先,這里有1個正告:上面這個例子中我們保護的資源是1個 NSMutableDictionary,出于這樣的目的,這段代碼運行地相當不錯。但是在真實的代碼中,把隔離放到正確的復雜度層級下是很重要的。
如果你對 NSMutableDictionary 的訪問操作變得非常頻繁,你會碰到1個已知的叫做鎖競爭的問題。鎖競爭其實不是只是在 GCD 和隊列下才變得特殊,任何使用了鎖機制的程序都會碰到一樣的問題——只不過不同的鎖機制會以不同的方式碰到。
所有對 dispatch_async,dispatch_sync 等等的調用都需要完成某種情勢的鎖——以確保唯一1個線程或特定的線程運行指定的代碼。GCD 某些程序上可使用時序(譯注:原詞為 scheduling)來避免使用鎖,但在最后,問題只是稍有變化。根本問題依然存在:如果你有大量的線程在相同時間去訪問同1個鎖或隊列,你就會看到性能的變化。性能會嚴重降落。
你應當從直接復雜層次中隔離開。當你發現了性能降落,這明顯表明朝碼中存在設計問題。這里有兩個開消需要你來平衡。第1個是獨占臨界區資源太久的開消,以致于別的線程都由于進入臨界區的操作而阻塞。第2個是太頻繁出入臨界區的開消。在 GCD 的世界里,第1種開消的情況就是1個 block 在隔離隊列中運行,它可能潛伏的阻塞了其他將要在這個隔離隊列中運行的代碼。第2種開消對應的就是調用 dispatch_async 和 dispatch_sync 。不管再怎樣優化,這兩個操作都不是無代價的。
使人哀傷的,不存在通用的標準來指點如何正確的平衡,你需要自己評測和調劑。啟動 Instruments 視察你的 app 忙于甚么操作。
如果你看上面例子中的代碼,我們的臨界區代碼僅僅做了很簡單的事情。這多是也可能不是好的方式,依賴于它怎樣被使用。
在你自己的代碼中,要斟酌自己是不是在更高的層次保護了隔離隊列。舉個例子,類 Foo 有1個隔離隊列并且它本身保護著對 NSMutableDictionary 的訪問,代替的,可以有1個用到了 Foo 類的 Bar 類有1個隔離隊列保護所有對類 Foo 的使用。換句話說,你可以把類 Foo 變成非線程安全的(沒有隔離隊列),并在 Bar 中,使用1個隔離隊列來確保任什么時候刻只能有1個線程使用 Foo 。
我們在這稍稍轉變以下話題。正如你在上面看到的,你可以同步和異步地分發1個 block,1個工作單元。我們在《并發編程:API 及挑戰》)中討論的1個非常普遍的問題就是死鎖。在 GCD 中,以同步分發的方式非常容易出現這類情況。見下面的代碼:
1旦我們進入到第2個 dispatch_sync 就會產生死鎖。我們不能分發到queueA,由于有人(當前線程)正在隊列中并且永久不會離開。但是有更隱晦的產生死鎖方式:
單獨的每次調用 dispatch_sync() 看起來都沒有問題,但是1旦組合起來,就會產生死鎖。
這是使用同步分發存在的固有問題,如果我們使用異步分發,比如:
1切運行正常。異步調用不會產生死鎖。因此值得我們在任何可能的時候都使用異步分發。我們使用1個異步調用結果 block 的函數,來代替編寫1個返回值(必須要用同步)的方法或函數。這類方式,我們會有更少產生死鎖的可能性。
異步調用的副作用就是它們很難調試。當我們在調試器里中斷代碼運行,回溯并查看已變得沒成心義了。
要牢記這些。死鎖通常是最難處理的問題。
如果你正在給設計1個給他人(或是給自己)使用的 API,你需要記住幾種好的實踐。
正如我們剛剛提到的,你需要偏向于異步 API。當你創建1個 API,它會在你的控制以外以各種方式調用,如果你的代碼能產生死鎖,那末死鎖就會產生。
如果你需要寫的函數或方法,那末讓它們調用 dispatch_async() 。不要讓你的函數調用者來這么做,這個調用應當在你的方法或函數中來做。
如果你的方法或函數有1個返回值,異步地將其傳遞給1個回調解理程序。這個 API 應當是這樣的,你的方法或函數同時持有1個結果 block 和1個將結果傳遞過去的隊列。你函數的調用者不需要自己來做分發。這么做的緣由很簡單:幾近所有時間,函數調用都應當在1個適當的隊列中,而且以這類方式編寫的代碼是很容易瀏覽的。總之,你的函數將會(必須)調用 dispatch_async() 去運行回調解理程序,所以它同時也可能在需要調用的隊列上做這些工作。
如果你寫1個類,讓你類的使用者設置1個回調解理隊列也許會是1個好的選擇。你的代碼可能像這樣:
如果你以這類方式來寫你的類,讓類之間協同工作就會變得容易。如果類 A 使用了類 B,它會把自己的隔離隊列設置為 B 的回調隊列。
如果你正在倒弄1些數字,并且手頭上的問題可以拆分出一樣性質的部份,那末 dispatch_apply 會很有用。
如果你的代碼看起來是這樣的:
小小的改動也許就能夠讓它運行的更快:
代碼運行良好的程度取決于你在循環內部做的操作。
block 中運行的工作必須是非常重要的,否則這個頭部信息就顯得過于沉重了。除非代碼遭到計算帶寬的束縛,每一個工作單元為了很好適應緩存大小而讀寫的內存都是臨界的。這會對性能會帶來顯著的影響。遭到臨界區束縛的代碼可能不會很好地運行。詳細討論這些問題已超越了這篇文章的范圍。使用 dispatch_apply 可能會對性能提升有所幫助,但是性能優化本身就是個很復雜的主題。維基百科上有1篇關于 Memory-bound function 的文章。內存訪問速度在 L2,L3 和主存上變化很顯著。當你的數據訪問模式與緩存大小不匹配時,10倍性能降落的情況其實不少見。
很多時候,你發現需要將異步的 block 組合起來去完成1個給定的任務。這些任務中乃至有些是并行的。現在,如果你想要在這些任務都履行完成后運行1些代碼,"groups" 可以完成這項任務。看這里的例子:
需要注意的重要事情是,所有的這些都是非阻塞的。我們從未讓當前的線程1直等待直到別的任務做完。恰恰相反,我們只是簡單的將多個 block 放入隊列。由于代碼不會阻塞,所以就不會產生死鎖。
同時需要注意的是,在這個小并且簡單的例子中,我們是怎樣在不同的隊列間進切換的。
1旦你將 groups 作為你的工具箱中的1部份,你可能會懷疑為何大多數的異步API不把 dispatch_group_t 作為1個可選參數。這沒有甚么沒法接受的理由,僅僅是由于自己添加這個功能太簡單了,但是你還是要謹慎以確保自己使用 groups 的代碼是成對出現的。
舉例來講,我們可以給 Core Data 的 -performBlock: API 函數添加上 groups,就像這樣: