並發危險:解決多線程代碼中的 11 個常見的問題(C#樣本) from MSDN

來源:互聯網
上載者:User
轉自:http://msdn.microsoft.com/zh-cn/magazine/cc817398.aspx並發危險解決多線程代碼中的 11 個常見的問題Joe Duffy

本文將介紹以下內容:

  • 基本並發概念
  • 並發問題和抑制措施
  • 實現安全性的模式
  • 橫切概念
本文使用了以下技術:
多線程、.NET Framework

 目錄資料爭用

忘記同步

粒度錯誤

讀寫撕裂

無鎖定重新排序

重新進入

死結

鎖保護

戳記

兩步舞曲

優先順序反轉

實現安全性的模式

不變性

純度

隔離
並發現象無處不在。伺服器端程式長久以來都必須負責處理基本並發編程模型,而隨著多核處理器的日益普及,用戶端程式也將需要執行一些任務。隨著並行作業的不斷增加,有關確保安全的問題也浮現出來。也就是說,在面對大量邏輯並行作業和不斷變化的物理硬體並行性程度時,程式必須繼續保持同樣層級的穩定性和可靠性。與對應的順序代碼相比,正確設計的並發代碼還必須遵循一些額外的規則。對記憶體的讀寫以及對共用資源的訪問必須使用同步機制進行管制,以防發生衝突。另外,通常有必要對線程進行協調以協同完成某項工作。這些附加要求所產生的直接結果是,可以從根本上確保線程始終保持一致並且保證其順利向前推進。同步和協調對時間的依賴性很強,這就導致了它們具有不確定性,難於進行預測和測試。這些屬性之所以讓人覺得有些困難,只是因為人們的思路還未轉變過來。沒有可供學習的專門 API,也沒有可進行複製和粘貼的程式碼片段。實際上的確有一組基礎概念需要您學習和適應。很可能隨著時間的推移某些語言和庫會隱藏一些概念,但如果您現在就開始執行並行作業,則不會遇到這種情況。本文將介紹需要注意的一些較為常見的挑戰,並針對您在軟體中如何運用它們給出一些建議。首先我將討論在並發程式中經常會出錯的一類問題。我把它們稱為“安全隱患”,因為它們很容易發現並且後果通常比較嚴重。這些危險會導致您的程式因崩潰或記憶體問題而中斷。

當從多個線程並發訪問資料時會發生資料爭用(或競爭條件)。特別是,在一個或多個線程寫入一段資料的同時,如果有一個或多個線程也在讀取這段資料,則會發生這種情況。之所以會出現這種問題,是因為 Windows 程式(如 C++ 和 Microsoft .NET Framework 之類的程式)基本上都基於共用記憶體概念,進程中的所有線程均可訪問駐留在同一虛擬位址空間中的資料。靜態變數和堆分配可用於共用。請考慮下面這個典型的例子:

複製代碼

static class Counter {    internal static int s_curr = 0;    internal static int GetNext() {         return s_curr++;     }}
Counter 的目標可能是想為 GetNext 的每個調用分發一個新的唯一數字。但是,如果程式中的兩個線程同時調用 GetNext,則這兩個線程可能被賦予相同的數字。原因是 s_curr++ 編譯包括三個獨立的步驟:
  1. 將當前值從共用的 s_curr 變數讀入處理器寄存器。
  2. 遞增該寄存器。
  3. 將寄存器值重新寫入共用 s_curr 變數。
按照這種順序執行的兩個線程可能會在本地從 s_curr 讀取了相同的值(比如 42)並將其遞增到某個值(比如 43),然後發布相同的結果值。這樣一來,GetNext 將為這兩個線程返回相同的數字,導致演算法中斷。雖然簡單語句 s_curr++ 看似不可分割,但實際卻並非如此。

忘記同步這是最簡單的一種資料爭用情況:同步被完全遺忘。這種爭用很少有良性的情況,也就是說雖然它們是正確的,但大部分都是因為這種正確性的根基存在問題。這種問題通常不是很明顯。例如,某個對象可能是某個大型複雜物件圖表的一部分,而該圖表恰好可使用靜態變數訪問,或在建立新線程或將工作排入線程池時通過將某個對象作為閉包的一部分進行傳遞可變為共用圖表。當對象(圖表)從私人變為共用時,一定要多加註意。這稱為發布,在後面的隔離上下文中會對此加以討論。反之稱為私人化,即對象(圖表)再次從共用變為私人。對這種問題的解決方案是添加正確的同步。在計數器樣本中,我可以使用簡單的聯鎖:

複製代碼

static class Counter {    internal static volatile int s_curr = 0;    internal static int GetNext() {         return Interlocked.Increment(ref s_curr);     }}
它之所以起作用,是因為更新被限定在單一記憶體位置,還因為(這一點非常方便)存在硬體指令 (LOCK INC),它相當於我嘗試進行原子化操作的軟體語句。或者,我可以使用成熟的鎖定:

複製代碼

static class Counter {    internal static int s_curr = 0;    private static object s_currLock = new object();    internal static int GetNext() {        lock (s_currLock) {             return s_curr++;         }    }}
lock 語句可確保試圖訪問 GetNext 的所有線程彼此之間互斥,並且它使用 CLR System.Threading.Monitor 類。C++ 程式使用 CRITICAL_SECTION 來實現相同目的。雖然對這個特定的樣本不必使用鎖定,但當涉及多個操作時,幾乎不可能將其併入單個互鎖操作中。

粒度錯誤即使使用正確的同步對共用狀態進行訪問,所產生的行為仍然可能是錯誤的。粒度必須足夠大,才能將必須視為原子的操作封裝在此地區中。這將導致在正確性與縮小地區之間產生衝突,因為縮小地區會減少其他線程等待同步進入的時間。例如,讓我們看一看圖 1 所示的銀行帳戶抽象。一切都很正常,對象的兩個方法(Deposit 和 Withdraw)看起來不會發生並發錯誤。一些銀行業應用程式可能會使用它們,而且不擔心餘額會因為並發訪問而遭到損壞。

 圖 1 銀行帳戶

複製代碼

class BankAccount {    private decimal m_balance = 0.0M;    private object m_balanceLock = new object();    internal void Deposit(decimal delta) {        lock (m_balanceLock) { m_balance += delta; }    }    internal void Withdraw(decimal delta) {        lock (m_balanceLock) {            if (m_balance < delta)                throw new Exception("Insufficient funds");            m_balance -= delta;        }    }}
但是,如果您想添加一個 Transfer 方法該怎麼辦?一種天真的(也是不正確的)想法會認為由於 Deposit 和 Withdraw 是安全隔離的,因此很容易就可以合并它們:

複製代碼

class BankAccount {    internal static void Transfer(      BankAccount a, BankAccount b, decimal delta) {        Withdraw(a, delta);        Deposit(b, delta);    }    // As before }
這是不正確的。實際上,在執行 Withdraw 與 Deposit 調用之間的一段時間內資金會完全丟失。正確的做法是必須提前對 a 和 b 進行鎖定,然後再執行方法調用:

複製代碼

class BankAccount {    internal static void Transfer(      BankAccount a, BankAccount b, decimal delta) {        lock (a.m_balanceLock) {            lock (b.m_balanceLock) {                Withdraw(a, delta);                Deposit(b, delta);            }        }    }    // As before }
事實證明,此方法可解決粒度問題,但卻容易發生死結。稍後,您會瞭解到如何修複它。

讀寫撕裂如前所述,良性爭用允許您在沒有同步的情況下訪問變數。對於那些對齊的、自然分割大小的字 — 例如,用指標分割大小的內容在 32 位處理器中是 32 位的(4 位元組),而在 64 位元處理器中則是 64 位元的(8 位元組)— 讀寫操作是原子的。如果某個線程唯讀取其他線程將要寫入的單個變數,而沒有涉及任何複雜的不變體,則在某些情況下您完全可以根據這一保證來略過同步。但要注意。如果試圖在未對齊的記憶體位置或未採用自然分割大小的位置這樣做,可能會遇到讀寫撕裂現象。之所以發生撕裂現象,是因為此類位置的讀或寫實際上涉及多個實體記憶體操作。它們之間可能會發生並行更新,並進而導致其結果可能是之前的值和之後的值通過某種形式的組合。例如,假設 ThreadA 處於迴圈中,現在需要僅將 0x0L 和 0xaaaabbbbccccddddL 寫入 64 位元變數 s_x 中。ThreadB 在迴圈中讀取它(參見圖 2)。

 圖 2 將要發生的撕裂現象

複製代碼

internal static volatile long s_x;void ThreadA() {    int i = 0;    while (true) {        s_x = (i & 1) == 0 ? 0x0L : 0xaaaabbbbccccddddL;        i++;    }}void ThreadB() {    while (true) {        long x = s_x;        Debug.Assert(x == 0x0L || x == 0xaaaabbbbccccddddL);    }}
您可能會驚訝地發現 ThreadB 的聲明可能會被觸發。原因是 ThreadA 的寫入操作包含兩部分(高 32 位和低 32 位),具體順序取決於編譯器。ThreadB 的讀取也是如此。因此 ThreadB 可以見證值 0xaaaabbbb00000000L 或 0x00000000aaaabbbbL。

無鎖定重新排序有時編寫無鎖定代碼來實現更好的延展性和可靠性是一種非常誘人的想法。這樣做需要深入瞭解目標平台的記憶體模型(有關詳細資料,請參閱 Vance Morrison 的文章 "Memory Models:Understand the Impact of Low-Lock Techniques in Multithreaded Apps",網址為
msdn.microsoft.com/magazine/cc163715)。如果不瞭解或不注意這些規則可能會導致記憶體重新排序錯誤。之所以發生這些錯誤,是因為編譯器和處理器在處理或最佳化期間可自由重新排序記憶體操作。例如,假設 s_x 和 s_y 均被初始化為值 0,如下所示:

複製代碼

internal static volatile int s_x = 0;internal static volatile int s_xa = 0;internal static volatile int s_y = 0;internal static volatile int s_ya = 0;void ThreadA() {    s_x = 1;    s_ya = s_y;}void ThreadB() {    s_y = 1;    s_xa = s_x;}
是否有可能在 ThreadA 和 ThreadB 均運行完成後,s_ya 和 s_xa 都包含值 0?看上去這個問題很可笑。或者 s_x = 1 或者 s_y = 1 會首先發生,在這種情況下,其他線程會在開始處理其自身的更新時見證這一更新。至少理論上如此。遺憾的是,處理器隨時都可能重新排序此代碼,以使在寫入之前載入操作更有效。您可以藉助一個顯式記憶體屏障來避免此問題:

複製代碼

void ThreadA() {    s_x = 1;    Thread.MemoryBarrier();    s_ya = s_y;}
.NET Framework 為此提供了一個特定 API,C++ 提供了 _MemoryBarrier 和類似的宏。但這個樣本並不是想說明您應該在各處都插入記憶體屏障。它要說明的是在完全弄清記憶體模型之前,應避免使用無鎖定代碼,而且即使在完全弄清之後也應謹慎行事。

在 Windows(包括 Win32 和 .NET Framework)中,大多數鎖定都支援遞迴獲得。這隻是意味著,即使當前線程已持有鎖但當它試圖再次獲得時,其要求仍會得到滿足。這使得通過較小的原子操作構成較大的原子操作變得更加容易。實際上,之前給出的 BankAccount 樣本依靠的就是遞迴獲得:Transfer 對 Withdraw 和 Deposit 都進行了調用,其中每個都重複獲得了 Transfer 已獲得的鎖定。但是,如果最終發生了遞迴獲得操作而您實際上並不希望如此,則這可能就是問題的根源。這可能是因為重新進入而導致的,而發生重新進入的原因可能是由於對動態代碼(如虛擬方法和委託)的顯式調用或由於隱式重新輸入的代碼(如 STA 訊息提取和非同步程序呼叫)。因此,最好不要從鎖定地區對動態方法進行調用。例如,設想某個方法暫時破壞了不變體,然後又調用委託:

複製代碼

class C {    private int m_x = 0;    private object m_xLock = new object();    private Action m_action = ...;    internal void M() {        lock (m_xLock) {            m_x++;            try { m_action(); }            finally {                Debug.Assert(m_x == 1);                m_x--;            }        }    }}
C 的方法 M 可確保 m_x 不發生改變。但會有很短的一段時間,m_x 會先遞增 1,然後再重新遞減。對 m_action 的調用看起來沒有任何問題。遺憾的是,如果它是從 C 類使用者接受的委託,則表示任何代碼都可以執行它所請求的操作。這包括回調到同一執行個體的 M 方法。如果發生了這種情況,finally 中的聲明可能會被觸發;同一堆棧中可能存在多個針對 M 的活動的調用(即使您未直接執行此操作),這必然會導致 m_x
包含的值大於 1。

當多個線程遇到死結時,系統會直接停止回應。多篇《MSDN 雜誌》文章都介紹了死結的發生原因以及使死結變得能夠接受的一些方法,其中包括我自己的文章 "No More Hangs:Advanced Techniques to Avoid and Detect Deadlocks in .NET Apps"(網址為
msdn.microsoft.com/magazine/cc163618)以及 Stephen Toub 的 2007 年 10 月 .NET 相關問題專欄(網址為
msdn.microsoft.com/magazine/cc163352),因此這裡只做簡單的討論。總而言之,只要出現了迴圈等待鏈 — 例如,ThreadA 正在等待 ThreadB 持有的資源,而 ThreadB 反過來也在等待 ThreadA 持有的資源(也許是間接等待第三個 ThreadC 或其他資源)— 則所有向前的推進工作都可能會停下來。

此問題的常見根源是互斥鎖。實際上,之前所示的 BankAccount 樣本遇到的就是這個問題。如果 ThreadA 試圖將 $500 從帳戶 #1234 轉移到帳戶 #5678,與此同時 ThreadB 試圖將 $500 從 #5678 轉移到 #1234,則代碼可能發生死結。使用一致的獲得順序可避免死結,如 圖 3 所示。此邏輯可概括為“同步鎖獲得”之類的名稱,通過此操作可依照各個鎖之間的某種順序動態排序多個可鎖定的對象,從而使得在以一致的順序獲得兩個鎖的同時必須維持兩個鎖的位置。另一個方案稱為“鎖矯正”,可用於拒絕被認定以不一致的順序完成的鎖獲得。

 圖 3 一致的獲得順序

複製代碼

class BankAccount {    private int m_id; // Unique bank account ID.    internal static void Transfer(      BankAccount a, BankAccount b, decimal delta) {        if (a.m_id < b.m_id) {            Monitor.Enter(a.m_balanceLock); // A first            Monitor.Enter(b.m_balanceLock); // ...and then B        } else {            Monitor.Enter(b.m_balanceLock); // B first            Monitor.Enter(a.m_balanceLock); // ...and then A         }        try {            Withdraw(a, delta);            Deposit(b, delta);        } finally {            Monitor.Exit(a.m_balanceLock);            Monitor.Exit(b.m_balanceLock);        }    }    // As before ...}
但鎖並不是導致死結的唯一根源。喚醒丟失是另一種現象,此時某個事件被遺漏,導致線程永遠休眠。在 Win32 自動重設和手動重設事件、CONDITION_VARIABLE、CLR Monitor.Wait、Pulse 以及 PulseAll 調用等同步事件中經常會發生這種情況。喚醒丟失通常是一種跡象,表示同步不正確,無法重設等待條件或在 wake-all(WakeAllConditionVariable 或 Monitor.PulseAll)更為適用的情況下使用了
wake-single 基元(WakeConditionVariable 或 Monitor.Pulse)。此問題的另一個常見根源是自動重設事件和手動重設事件訊號丟失。由於此類事件只能處於一個狀態(有訊號或無訊號),因此用於設定此事件的冗餘調用實際上將被忽略不計。如果代碼認定要設定的兩個調用始終需要轉換為兩個喚醒的線程,則結果可能就是喚醒丟失。

鎖保護當某個鎖的到達率與其鎖獲得率相比始終居高不下時,可能會產生鎖保護。在極端的情況下,等待某個鎖的線程超過了其承受力,就會導致災難性後果。對於伺服器端的程式而言,如果用戶端所需的某些受鎖保護的資料結構需求量大增,則經常會發生這種情況。例如,請設想以下情況:平均來說,每 100 毫秒會到達 8 個請求。我們將八個線程用於服務要求(因為我們使用的是 8-CPU 電腦)。這八個線程中的每一個都必須獲得一個鎖並保持 20 毫秒,然後才能展開實質的工作。遺憾的是,對這個鎖的訪問需要進行序列化處理,因此,全部八個線程需要 160 毫秒才能進入並離開鎖。第一個退出後,需要經過 140 毫秒第九個線程才能訪問該鎖。此方案本質上無法進行調整,因此備份的請求會不斷增長。隨著時間的推移,如果到達率不降低,用戶端請求就會開始逾時,進而發生災難性後果。眾所周知,在鎖中是通過公平性對鎖進行保護的。原因在於在鎖本來已經可用的時間段內,鎖被人為封閉,使得到達的線程必須等待,直到所選鎖的擁有者線程能夠喚醒、切換上下文以及獲得和釋放該鎖為止。為解決這種問題,Windows 已逐漸將所有內部鎖都改為不公平鎖,而且 CLR 監視器也是不公平的。對於這種有關保護的基本問題,唯一的有效解決方案是減少鎖持有時間並分解系統以儘可能減少熱鎖(如果有的話)。雖然說起來容易做起來難,但這對於延展性來說還是非常重要的。

“蜂擁”是指大量線程被喚醒,使得它們全部同時從 Windows 線程排程器爭奪關注點。例如,如果在單個手動設定事件中有 100 個阻塞的線程,而您設定該事件…嗯,算了吧,您很可能會把事情弄得一團糟,特別是當其中的大部分線程都必須再次等待時。實現阻塞隊列的一種途徑是使用手動設定事件,當隊列為空白時變為無訊號而在隊列非空時變為有訊號。遺憾的是,如果從零個元素過渡到一個元素時存在大量正在等待的線程,則可能會發生蜂擁。這是因為只有一個線程會得到此單一元素,此過程會使隊列變空,從而必須重設該事件。如果有 100 個線程在等待,那麼其中的 99 個將被喚醒、切換上下文(導致所有緩衝丟失),所有這些換來的只是不得不再次等待。

兩步舞曲有時您需要在持有鎖的情況下通知一個事件。如果喚醒的線程需要獲得被持有的鎖,則這可能會很不湊巧,因為在它被喚醒後只是發現了它必須再次等待。這樣做非常浪費資源,而且會增加環境切換的總數。此情況稱為兩步舞曲,如果涉及到許多鎖和事件,可能會遠遠超出兩步的範疇。Win32 和 CLR 的條件變數支援在本質上都會遇到兩步舞曲問題。它通常是不可避免的,或者很難解決。兩步舞曲問題在單一處理器電腦上情況更糟。在涉及到事件時,核心會將優先權提升應用到喚醒的線程。這幾乎可以保證搶先佔用線程,使其能夠在有機會釋放鎖之前設定事件。這是在極端情況下的兩步舞曲,其中設定 ThreadA 已切換出上下文,使得喚醒的 ThreadB 可以嘗試獲得鎖;當然它無法做到,因此它將進行環境切換以使 ThreadA 可再次運行;最終,ThreadA 將釋放鎖,這將再次提升 ThreadB 的優先順序,使其優先於
ThreadA,以便它能夠運行。如您所見,這涉及了多次無用的環境切換。

優先順序反轉修改線程優先順序常常是自找苦吃。當不同優先順序的許多線程共用對同樣的鎖和資源的訪問權時,可能會發生優先順序反轉,即較低優先順序的線程實際無限期地阻止較高優先順序線程的進度。這個樣本所要說明的道理就是儘可能避免更改線程優先順序。下面是一個優先順序反轉的極端樣本。假設低優先順序的 ThreadA 獲得某個鎖 L。隨後高優先順序的 ThreadB 介入。它嘗試獲得 L,但由於 ThreadA 佔用使得它無法獲得。下面就是“反轉”部分:好像 ThreadA 被人為臨時賦予了一個高於 ThreadB 的優先順序,這一切只是因為它持有 ThreadB 所需的鎖。當 ThreadA 釋放了鎖後,此情況最終會自行解決。遺憾的是,如果涉及到中等優先順序的 ThreadC,設想一下會發生什麼情況。雖然 ThreadC 不需要鎖 L,但它的存在可能會從根本上阻止 ThreadA 運行,這將間接地阻止高優先順序 ThreadB 的運行。最終,Windows Balance Set Manager 線程會注意到這一情況。即使 ThreadC 保持永遠可運行狀態,ThreadA 最終(四秒鐘後)也將接收到作業系統發出的臨時優先權提升指令。但願這足以使其運行完畢並釋放鎖。但這裡的延遲(四秒鐘)相當巨大,如果涉及到任何使用者介面,則應用程式使用者肯定會注意到這一問題。

實現安全性的模式現在我已經找出了一個又一個的問題,好訊息是我這裡還有幾種設計模式,您可以遵循它們來降低上述問題(尤其是正確性危險)的發生頻率。大多數問題的關鍵是由於狀態在多個線程之間共用。更糟的是,此狀態可被隨意控制,可從一致狀態轉換為不一致狀態,然後(但願)又重新轉換回來,具有令人驚訝的規律性。當開發人員針對單線程程式編寫代碼時,所有這些都非常有用。在您向最終的正確目標邁進的過程中,很可能會使用共用記憶體作為一種暫存器。多年來 C 語言風格的命令式程式設計語言一直使用這種方式工作。但隨著並發現象越來越多,您需要對這些習慣密切加以關注。您可以按照 Haskell、LISP、Scheme、ML 甚至 F#(一種符合 .NET 的新語言)等函數式程式設計語言行事,即採用不變性、純度和隔離作為一類設計概念。

不變性具有不變性的資料結構是指在構建後不會發生改變的結構。這是並發程式的一種奇妙屬性,因為如果資料不改變,則即使許多線程同時訪問它也不會存在任何衝突風險。這意味著同步並不是一個需要考慮的因素。不變性在 C++ 中通過 const 提供支援,在 C# 中通過唯讀修飾符支援。例如,僅具有唯讀欄位的 .NET 類型是淺層不變的。預設情況下,F# 會建立固定不變的類型,除非您使用可變修飾符。再進一步,如果這些欄位中的每個欄位本身都指向欄位均為唯讀(並僅指向深層不可變類型)的另一種類型,則該類型是深層不可變的。這將產生一個保證不會改變的完整對象圖表,它會非常有用。所有這一切都說明不變性是一個靜態屬性。按照慣例,對象也可以是固定不變的,即在某種程度上可以保證狀態在某個時間段不會改變。這是一種動態屬性。Windows Presentation Foundation (WPF) 的可凍結功能恰好可實現這一點,它還允許在不同步的情況下進行並行訪問(但是無法以處理靜態支援的方式對其進行檢查)。對於在整個生存期內需要在固定不變和可變之間進行轉換的對象來說,動態不變性通常非常有用。不變性也存在一些弊端。只要有內容需要改變,就必鬚生成原始對象的副本並在此過程中應用更改。另外,在對象圖表中通常無法進行迴圈(除動態不變性外)。例如,假設您有一個 ImmutableStack<T>,如圖 4 所示。您需要從包含已應用更改的對象中返回新的 ImmutableStack<T> 對象,而不是一組變化的 Push 和 Pop 方法。在某些情況下,可以靈活使用一些技巧(與堆棧一樣)在各執行個體之間共用記憶體。

 圖 4 使用 ImmutableStack

複製代碼

public class ImmutableStack<T> {    private readonly T m_value;    private readonly ImmutableStack<T> m_next;    private readonly bool m_empty;    public ImmutableStack() { m_empty = true; }    internal ImmutableStack(T value, Node next) {        m_value = value;        m_next = next;        m_empty = false;    }    public ImmutableStack<T> Push(T value) {        return new ImmutableStack(value, this);    }    public ImmutableStack<T> Pop(out T value) {        if (m_empty) throw new Exception("Empty.");        return m_next;    }}
節點被推入時,必須為每個節點分配一個新對象。在堆棧的標準連結清單實現中,必須執行此操作。但是要注意,當您從堆棧中彈出元素時,可以使用現有的對象。這是因為堆棧中的每個節點是固定不變的。固定不變的類型無處不在。CLR 的 System.String 類是固定不變的,還有一個設計指導原則,即所有新實值型別都應是固定不變的。此處給出的指導原則是在可行和合適的情況下使用不變性並抵抗執行變化的誘惑,而最新一代的語言會使其變得非常方便。

純度即使是使用固定不變的資料類型,程式所執行的大部分操作仍是方法調用。方法調用可能存在一些副作用,它們在並發代碼中會引發問題,因為副作用意味著某種形式的變化。通常這隻是表示寫入共用記憶體,但它也可能是實際變化的操作,如資料庫事務、Web 服務調用或檔案系統操作。在許多情況下,我希望能夠調用某種方法,而又不必擔心它會導致並發危險。有關這一方面的一些很好樣本就是 GetHashCode 和 ToString on System.Object
等簡單的方法。很多人都不希望它們帶來副作用。純方法始終都可以在並發設定中運行,而無需添加同步。儘管純度沒有任何常見語言支援,但您可以非常簡單地定義純方法:

  1. 它只從共用記憶體讀取,並且唯讀取不變狀態或常態。
  2. 它必須能夠寫入局部變數。
  3. 它可以只調用其他純方法。
因此,純方法可以實現的功能非常有限。但當與不變類型結合使用時,純度就會成為可能而且非常方便。一些函數式語言預設情況下都採用純度,特別是 Haskell,它的所有內容都是純的。任何需要執行副作用的內容都必須封裝到一個被稱為 monad 的特殊內容中。但是我們中的多數人都不使用 Haskell,因此我們必須遵照純度約定。

隔離前面我們只是簡單提及了發布和私人化,但它們卻擊中了一個非常重要的問題的核心。由於狀態通常在多個線程之間共用,因此同步是必不可少的(不變性和純度也很有趣味)。但如果狀態被限制在單個線程內,則無需進行同步。這會導致軟體在本質上更具伸縮性。實際上,如果狀態是隔離的,則可以自由變化。這非常方便,因為變化是大部分 C 風格語言的基本內建功能。程式員已習慣了這一點。這需要進行訓練以便能夠在編程時以函數式風格為主,對大多數開發人員來說這都相當困難。嘗試一下,但不要自欺欺人地認為世界會在一夜之間改為使用函數式風格編程。所有權是一件很難跟蹤的事情。對象是何時變為共用的?在初始化時,這是由單線程完成的,對象本身還不能從其他線程訪問。將對某個對象的引用儲存在靜態變數中、儲存在已線上程建立或排列隊列時共用的某個位置或儲存在可從其中的某個位置傳遞性訪問的對象欄位中之後,該對象就變為共用對象。開發人員必須特別關注私人與共用之間的這些轉換,並小心處理所有共用狀態。

Joe Duffy 在 Microsoft 是 .NET 並行擴充方面的開發主管。他的大部分時間都在攻擊代碼、監督庫的設計以及管理夢幻Team Dev。他的最新著作是《Concurrent Programming on Windows》

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.