這個問題被叫作競態條件,在多線程裏面訪問一個共享的資源,若是沒有一種機制來確保在線程 A 結束訪問一個共享資源以前,線程 B 就不會開始訪問該共享資源的話,資源競爭的問題就老是會發生。若是你所寫入內存的並非一個簡單的整數,而是一個更復雜的數據結構,可能會發生這樣的現象:當第一個線程正在寫入這個數據結構時,第二個線程卻嘗試讀取這個數據結構,那麼獲取到的數據多是新舊參半或者沒有初始化。爲了防止出現這樣的問題,多線程須要一種互斥的機制來訪問共享資源。html
在實際的開發中,狀況甚至要比上面介紹的更加複雜,由於現代 CPU 爲了優化目的,每每會改變向內存讀寫數據的順序(亂序執行)。算法
互斥訪問的意思就是同一時刻,只容許一個線程訪問某個特定資源。爲了保證這一點,每一個但願訪問共享資源的線程,首先須要得到一個共享資源的互斥鎖,一旦某個線程對資源完成了操做,就釋放掉這個互斥鎖,這樣別的線程就有機會訪問該共享資源了。編程
除了確保互斥訪問,還須要解決代碼無序執行所帶來的問題。若是不能確保 CPU 訪問內存的順序跟編程時的代碼指令同樣,那麼僅僅依靠互斥訪問是不夠的。爲了解決由 CPU 的優化策略引發的反作用,還須要引入內存屏障。經過設置內存屏障,來確保沒有無序執行的指令能跨過屏障而執行。安全
固然,互斥鎖自身的實現是須要沒有競爭條件的。這其實是很是重要的一個保證,而且須要在現代 CPU 上使用特殊的指令。更多關於原子操做(atomic operation)的信息,請閱讀 Daniel 寫的文章:底層併發技術。數據結構
從語言層面來講,在 Objective-C 中將屬性以 atomic 的形式來聲明,就能支持互斥鎖了。事實上在默認狀況下,屬性就是 atomic 的。將一個屬性聲明爲 atomic 表示每次訪問該屬性都會進行隱式的加鎖和解鎖操做。雖然最把穩的作法就是將全部的屬性都聲明爲 atomic,可是加解鎖這也會付出必定的代價。多線程
在資源上的加鎖會引起必定的性能代價。獲取鎖和釋放鎖的操做自己也須要沒有競態條件,這在多核系統中是很重要的。另外,在獲取鎖的時候,線程有時候須要等待,由於可能其它的線程已經獲取過資源的鎖了。這種狀況下,線程會進入休眠狀態。當其它線程釋放掉相關資源的鎖時,休眠的線程會獲得通知。全部這些相關操做都是很是昂貴且複雜的。併發
鎖也有不一樣的類型。當沒有競爭時,有些鎖在沒有鎖競爭的狀況下性能很好,可是在有鎖的競爭狀況下,性能就會大打折扣。另一些鎖則在基本層面上就比較耗費資源,可是在競爭狀況下,性能的惡化會沒那麼厲害。(鎖的競爭是這樣產生的:當一個或者多個線程嘗試獲取一個已經被別的線程獲取過了的鎖)。app
在這裏有一個東西須要進行權衡:獲取和釋放鎖所是要帶來開銷的,所以你須要確保你不會頻繁地進入和退出臨界區段(好比獲取和釋放鎖)。同時,若是你獲取鎖以後要執行一大段代碼,這將帶來鎖競爭的風險:其它線程可能必須等待獲取資源鎖而沒法工做。這並非一項容易解決的任務。異步
咱們常常能看到原本計劃並行運行的代碼,但實際上因爲共享資源中配置了相關的鎖,因此同一時間只有一個線程是處於激活狀態的。對於你的代碼會如何在多核上運行的預測每每十分重要,你可使用 Instrument 的 CPU strategy view 來檢查是否有效的利用了 CPU 的可用核數,進而得出更好的想法,以此來優化代碼。async
互斥鎖解決了競態條件的問題,但很不幸同時這也引入了一些其餘問題,其中一個就是死鎖。當多個線程在相互等待着對方的結束時,就會發生死鎖,這時程序可能會被卡住。
看看下面的代碼,它交換兩個變量的值:
void swap(A, B) { lock(lockA); lock(lockB); int a = A; int b = B; A = b; B = a; unlock(lockB); unlock(lockA); }
大多數時候,這可以正常運行。可是當兩個線程使用相反的值來同時調用上面這個方法時:
swap(X, Y); // 線程 1 swap(Y, X); // 線程 2
此時程序可能會因爲死鎖而被終止。線程 1 得到了 X 的一個鎖,線程 2 得到了 Y 的一個鎖。 接着它們會同時等待另一把鎖,可是永遠都不會得到。
再說一次,你在線程之間共享的資源越多,你使用的鎖也就越多,同時程序被死鎖的機率也會變大。這也是爲何咱們須要儘可能減小線程間資源共享,並確保共享的資源儘可能簡單的緣由之一。建議閱讀一下底層併發編程 API 中的所有使用異步分發一節。
當你認爲已經足夠了解併發編程面臨的問題時,又出現了一個新的問題。鎖定的共享資源會引發讀寫問題。大多數狀況下,限制資源一次只能有一個線程進行讀取訪問實際上是很是浪費的。所以,在資源上沒有寫入鎖的時候,持有一個讀取鎖是被容許的。這種狀況下,若是一個持有讀取鎖的線程在等待獲取寫入鎖的時候,其餘但願讀取資源的線程則由於沒法得到這個讀取鎖而致使資源飢餓的發生。
爲了解決這個問題,咱們須要使用一個比簡單的讀/寫鎖更聰明的方法,例如給定一個 writer preference,或者使用 read-copy-update 算法。Daniel 在底層併發編程 API 中有介紹瞭如何用 GCD 實現一個多讀取單寫入的模式,這樣就不會被寫入資源飢餓的問題困擾了。
本節開頭介紹了美國宇航局發射的開拓者號火星探測器在火星上遇到的併發問題。如今咱們就來看看爲何開拓者號幾近失敗,以及爲何有時候咱們的程序也會遇到相同的問題,該死的優先級反轉。
優先級反轉是指程序在運行時低優先級的任務阻塞了高優先級的任務,有效的反轉了任務的優先級。因爲 GCD 提供了擁有不一樣優先級的後臺隊列,甚至包括一個 I/O 隊列,因此咱們最好了解一下優先級反轉的可能性。
高優先級和低優先級的任務之間共享資源時,就可能發生優先級反轉。當低優先級的任務得到了共享資源的鎖時,該任務應該迅速完成,並釋放掉鎖,這樣高優先級的任務就能夠在沒有明顯延時的狀況下繼續執行。然而高優先級任務會在低優先級的任務持有鎖的期間被阻塞。若是這時候有一箇中優先級的任務(該任務不須要那個共享資源),那麼它就有可能會搶佔低優先級任務而被執行,由於此時高優先級任務是被阻塞的,因此中優先級任務是目前全部可運行任務中優先級最高的。此時,中優先級任務就會阻塞着低優先級任務,致使低優先級任務不能釋放掉鎖,這也就會引發高優先級任務一直在等待鎖的釋放。
在你的實際代碼中,可能不會像發生在火星的事情那樣戲劇性地不停重啓。遇到優先級反轉時,通常沒那麼嚴重。
解決這個問題的方法,一般就是不要使用不一樣的優先級。一般最後你都會以讓高優先級的代碼等待低優先級的代碼來解決問題。當你使用 GCD 時,老是使用默認的優先級隊列(直接使用,或者做爲目標隊列)。若是你使用不一樣的優先級,極可能實際狀況會讓事情變得更糟糕。
從中獲得的教訓是,使用不一樣優先級的多個隊列聽起來雖然不錯,但畢竟是紙上談兵。它將讓原本就複雜的並行編程變得更加複雜和不可預見。若是你在編程中,遇到高優先級的任務忽然沒理由地卡住了,可能你會想起本文,以及那個美國宇航局的工程師也遇到過的被稱爲優先級反轉的問題。
咱們但願經過本文你可以瞭解到併發編程帶來的複雜性和相關問題。併發編程中,不管是看起來多麼簡單的 API ,它們所能產生的問題會變得很是的難以觀測,並且要想調試這類問題每每也都是很是困難的。
但另外一方面,併發其實是一個很是棒的工具。它充分利用了現代多核 CPU 的強大計算能力。在開發中,關鍵的一點就是儘可能讓併發模型保持簡單,這樣能夠限制所須要的鎖的數量。
咱們建議採納的安全模式是這樣的:從主線程中提取出要使用到的數據,並利用一個操做隊列在後臺處理相關的數據,最後回到主隊列中來發送你在後臺隊列中獲得的結果。使用這種方式,你不須要本身作任何鎖操做,這也就大大減小了犯錯誤的概率。