1. 程式人生 > >iOS 執行緒2--互斥,鎖,優先順序 翻轉

iOS 執行緒2--互斥,鎖,優先順序 翻轉

上一篇文章介紹了OC中併發程式設計的相關API,本文我們接著來看看併發程式設計中面臨的一些挑戰。

目錄

1、介紹
2、OS X和iOS中的併發程式設計
    2.1、Threads
   2.2、Grand Central Dispatch
   2.3、Operation Queues
   2.4、Run Loops
3、併發程式設計中面臨的挑戰
   3.1、資源共享
   3.2、互斥
   3.3、死鎖
   3.4、飢餓
   3.5、優先順序反轉
4、小結

正文

1和2兩部分內容請看上一篇文章

3、併發程式設計中面臨的挑戰

使用併發程式設計會帶來許多陷進。儘管開發者做得足夠到位了,還是難以觀察並行執行中相互作用的多工的不同狀態。問題往往發生在一些不確定性(不可預見性)的地方,在除錯相關併發程式碼時會感覺到很無助。

關於併發程式設計的不可預見性有一個非常典型的例子:在1995年,NASA(美國宇航局)傳送了火星探測器,但是當探測器成功著陸的時候,任務嘎然而止,火星探測器莫名其妙的不停重啟——在計算機領域內,遇到的這中現象被定為為優先順序反轉,也就是說低優先順序的執行緒一直阻塞著高優先順序的執行緒。稍後我們會看到更多相關介紹。通過該示例,可以告訴我們即使擁有豐富的資源和大量優秀工程師,但是也會遭遇使用併發程式設計帶來的陷阱。

3.1、資源共享

併發程式設計中許多問題的根源就是在多執行緒中訪問共享資源。資源可以是一個屬性、一個物件,通用的記憶體、網路裝置和檔案等等。在多執行緒中任意共享的資源都有一個潛在的衝突,開發者必須防止相關衝突的發生。

為了演示衝突問題,我們來看一個關於資源的簡單示例:利用一個整型值作為計數器。在程式執行過程中,有兩個並行執行緒A和B,這兩個執行緒都嘗試著同時增加計數器的值。問題來了,通過C或OC寫的程式碼(增加計數器的值)不僅僅是一條指令,而是包括好多指令——要想增加計數器的值,需要從記憶體中讀取出當前值,然後再增加計數器的值,最後還需要就愛那個這個增加的值寫回記憶體中。

我們可以試著想一下,如果兩個執行緒同時做上面涉及到的操作,會發生什麼問題。例如,執行緒A和B都從記憶體中讀取出了計數器的值,假設為17,然後執行緒A將計數器的值加1,並將結果18寫回到記憶體中。同時,執行緒B也將計數器的值加1,並將結果18寫回到記憶體中。實際上,此時計數器的值已經被破壞掉了——因為計數器的值17被加1了兩次,應該為19,但是記憶體中的值為18。

race-condition@2x

這個問題成為資源競爭,或者叫做,在多執行緒裡面訪問一個共享的資源,如果沒有一種機制來確保執行緒A結束訪問一個共享資源之前,執行緒B就開始訪問該共享資源,那麼資源競爭的問題總是會發生。試想一下,如果如果程式在記憶體中訪問的資源不是一個簡單的整型,而是一個複雜的資料結構,可能會發生這樣的現象:當第一個執行緒正在讀寫這個資料結構時,第二個執行緒也來讀這個資料結構,那麼獲取到的資料可能是新舊參半。為了防止出現這樣的問題,在多執行緒訪問共享資源時,需要一種互斥的機制。

在實際的開發中,情況甚至要比上面介紹的複雜,因為現代CPU為了對程式碼執行達到最優化,對改變從記憶體中讀寫資料的順序(亂序執行)。

3.2、互斥

互斥訪問的意思就是同一時刻,只允許一個執行緒訪問某個資源。為了保證這一點,每個希望訪問共享資源的執行緒,首先需要獲得一個共享資源的互斥鎖,一旦某個執行緒對資源完成了讀寫操作,就釋放掉這個互斥鎖,這樣別的執行緒就有機會訪問該共享資源了。

locking@2x

除了確保互斥鎖的訪問,還需要解決程式碼無序執行所帶來的問題。如果不能確保CPU訪問記憶體的順序跟程式設計時的程式碼指令一樣,那麼僅僅依靠互斥鎖的訪問是不夠的。為了解決由CPU的優化策略引起的程式碼無序執行,需要引入記憶體屏障()。通過設定記憶體屏障,來確保無序執行時能夠正確跨越設定的屏障。

當然,互斥鎖的實現是需要自由的競爭條件。這實際上是非常重要的一個保證,並且需要在現代CPU上使用特殊的指令。更多關於原子操作(atomic operation),請閱讀Daniel寫的文章:。

從語言層面來說,在Objective-C中將屬性以atomic的形式來宣告,就能支援互斥鎖了。實際上,預設情況下,屬性是atomic的。將一個屬性宣告為atomic表示每次訪問該屬性都會進行加鎖和解鎖操作。雖然最把穩的做法就是將所有的屬性都宣告為atomic,但是這也會付出一定的代價。

獲取資源上的鎖會引發一定的效能代價。獲取和釋放鎖需要自由的競爭條件(race-condition free),這在多核系統中是很重要的。另外,在獲取鎖的時候,執行緒有時候需要等待——因為其它的執行緒已經獲得了資源的鎖。這種情況下,執行緒會進入休眠狀態,當其它執行緒釋放掉相關資源的鎖時,休眠的執行緒會得到通知。其實所有這些相關操作都是非常昂貴且複雜的。

這有一些不同型別的鎖。當沒有競爭時,有些鎖是很廉價的(cheap),但是在競爭情況下,效能就會打折扣。同等條件下,另外一些鎖則比較昂貴(expensive),但是在競爭情況下,會表現更好(鎖的競爭是這樣產生的:當一個或者多個執行緒嘗試獲取一個已經被別的執行緒獲取了的鎖)。

在這裡有一個東西需要進行權衡:獲取和釋放鎖所帶來的開銷。開發者需要確保程式碼中有獲取鎖和釋放鎖的語句。同時,如果獲取鎖之後,要執行一大段程式碼,這將帶來風險:其它執行緒可能因為資源的競爭而無法工作(需要釋放掉相關的鎖才行)。

我們經常能看到並行執行的程式碼,但實際上由於共享資源中配置了相關的鎖,所以有時候只有一個執行緒是出於啟用狀態的。要想預測一下程式碼在多核上的排程情況,有時候也顯得很重要。我們可以使用Instrument的CPU strategy view來檢查是否有效的利用了CPU的可用核數,進而得出更好的想法,以此來優化程式碼。

3.3、死鎖

互斥解決了資源競爭的問題,但同時這也引入了一個新的問題:死鎖。當多個執行緒在相互等待著對方的結束時,就會發生死鎖,這是程式可能會被卡住。

dead-lock@2x

看看下面的程式碼——交換兩個變數的值:

  1. void swap(A, B)
  2. {
  3. lock(lockA);
  4. lock(lockB);
  5. int a = A;
  6. int b = B;
  7. A = b;
  8. B = a;
  9. unlock(lockB);
  10. unlock(lockA);
  11. }

大多數時候,這能夠正常執行。但是當兩個執行緒同時呼叫上面這個方法呢——使用兩個相反的值:

  1. swap(X, Y);// thread 1
  2. swap(Y, X);// thread 2

此時程式可能會由於死鎖而被終止。執行緒1獲得了X的一個鎖,執行緒2獲得了Y的一個鎖。 接著它們會同時等待另外一把鎖,但是永遠都不會獲得。

記住:線上程之間共享更多的資源,會使用更多的鎖,同時也會增加死鎖的概率。這也是為什麼我們需要儘量減少執行緒間資源共享,並確保共享的資源儘量簡單的原因之一。建議閱讀以下中的。

3.4、飢餓

當你認為已經足夠了解併發程式設計面臨的陷阱 時,拐角處又出現了新的問題。鎖定的共享資源會引起讀寫問題。大多數情況下,限制資源一次只能有一個執行緒進行訪問,這是非常浪費的,比如一個讀取鎖只允許讀,而不對資源進行寫操作,這種情況下,同時可能會有另外一個執行緒等著著獲取一個寫鎖。

3.5、優先順序反轉

本節開頭介紹了美國宇航局發射的火星探測器在火星上遇到的併發問題。現在我們就來看看為什麼那個火星探測器會失敗,以及為什麼有時候我們的程式也會遇到相同的問題——該死的優先順序反轉。

優先順序反轉是指程式在執行時低優先順序的任務阻塞了高優先順序的任務,有效的反轉了任務的優先順序。由於GCD提供了後臺執行佇列(擁有不同的優先順序),包括I/O佇列,所以通過GCD我們可以很好的來了解一下優先順序反轉的可能性。

高優先順序和低優先順序的任務之間在共享一個資源時,就可能發生優先順序反轉。當低優先順序的任務獲得了共享資源的鎖時,該任務應該迅速完成,並釋放掉鎖,然後讓高優先順序的任務在沒有明顯的延時下繼續執行。然而當低優先順序阻塞著高優先順序期間(低優先順序獲得的時間又比較少),如果有一箇中優先順序的任務(該任務不需要那個共享資源),那麼可能會搶佔低優先順序任務,而被執行——因為此時高優先順序任務是被阻塞的,所以中優先順序任務是目前所有可執行任務中優先順序最高的。此時,中優先順序任務就會阻塞著低優先順序任務,導致低優先順序任務不能釋放掉鎖,也就會引起高優先順序任務一直在等待鎖的釋放。

priority-inversion@2x

在我們的實際程式碼中,可能不會像火星探測器那樣,遇到優先順序反轉時,不同的重啟。

解決這個問題的方法,通常就是不要使用不同的優先順序——將高優先順序的程式碼和低優先順序的程式碼修改為相同的優先順序。當使用GCD時,總是使用預設的優先順序佇列。如果使用不同的優先順序,就可能會引發事故。

雖然有些文章上說,在不同的佇列中使用不同的優先順序,這聽起來不錯,但是這回增加併發程式設計的複雜度和不可預見性。如果程式設計中,在高優先順序任務中突然沒有理由的卡住了,可能你會想起本文,以及稱為優先順序反轉的問題,甚至還會想起美國宇航局的工程師也遇到這樣的問題。

4、小結

希望通過本文你能夠了解到併發程式設計帶來的複雜性和相關問題。併發程式設計中,看起來,無論是多麼簡單的API,由此產生的問題會變得非常的難以觀測,並且要想除錯這類問題,往往都是比較困難的。

另外,併發實際上是一個非常棒的功能——它充分利用了現代多核CPU的強大計算能力。在開發中,關鍵的一點就是儘量讓併發模型簡單,這樣可以限制鎖的數量。

我們建議採納的安全模式是這樣的:從主執行緒中提取出使用到的資料,並利用一個操作佇列在後臺處理相關的資料,然後將後臺處理的結果反饋到主佇列中。使用這種方式,開發者不需要自己負責任何的鎖,這也就減少了犯錯誤的概率。