程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> .NET網頁編程 >> 關於.NET >> 並發危險:解決多線程代碼中的11個常見的問題

並發危險:解決多線程代碼中的11個常見的問題

編輯:關於.NET

本文將介紹以下內容:

基本並發概念

並發問題和抑制措施

實現安全性的模式

橫切概念

本文使用了以下技術:

多線程、.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++ 編譯包括三個獨立的步驟 :

將當前值從共享的 s_curr 變量讀入處理器寄存器。

遞增該寄存器。

將寄存器值重新寫入共享 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 並行擴展方面的開發主管。他的大部分時間都在攻擊代碼、監督庫 的設計以及管理夢幻開發團隊。他的最新著作是《Concurrent Programming on Windows》。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved