解決多線程代碼中的 11 個常見的問題

                                           目錄程序員

                                                           數據爭用                                                            
                                                           忘記同步                                                            
                                                           粒度錯誤                                                            
                                                           讀寫撕裂                                                            
                                                           無鎖定從新排序                                                          
                                                           從新進入                                                            
                                                           死鎖                                                            
                                                           鎖保護                                                            
                                                           戳記                                                            
                                                           兩步舞曲                                                            
                                                           優先級反轉                                                            
                                                           實現安全性的模式                                                         
                                                           不變性                                                            
                                                           純度                                                            
                                                           隔離                                                            
                                                 
算法

                                                                               

並發現象無處不在。服 務器端程序長久以來都必須負責處理基本併發編程模型,而隨着多核處理器的日益普及,客戶端程序也將須要執行一些任務。隨着併發操做的不斷增長,有關確保安 全的問題也浮現出來。也就是說,在面對大量邏輯併發操做和不斷變化的物理硬件並行性程度時,程序必須繼續保持一樣級別的穩定性和可靠性。數據庫

與對應的順序代碼相比,正確設計的併發代碼還必須遵循一些額外的規則。對內存的讀寫以及對共享資源的訪問必須使用同步機制進行管制,以防發生衝突。另外,一般有必要對線程進行協調以協同完成某項工做。編程

這些附加要求所產生的直接結果是,能夠從根本上確保線程始終保持一致而且保證其順利向前推動。同步和協調對時間的依賴性很強,這就致使了它們具備不肯定性,難於進行預測和測試。設計模式

這 些屬性之因此讓人以爲有些困難,只是由於人們的思路還未轉變過來。沒有可供學習的專門 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 並行擴展方面的開發主管。他的大部分時間都在攻擊代碼、監督庫的設計以及管理夢幻開發團隊。他的最新著做是《Concurrent Programming on Windows》

相關文章
相關標籤/搜索