標籤:

單例模式小結

單例模式的標準定義是只生成對象的一個實例,寬泛點也可以說是保證一個操作只被執行一次。本文重點不在於討論「模式」的設計意義(如節省系統資源等等),而在於「單例」的實現方式。

通常我們只有在多線程的環境下,才有使用單例模式的需求。話休煩絮,先看以下代碼:

//以下代碼來自參考網址public sealed class Singleton{ private static Singleton instance=null; private Singleton() { } public static Singleton Instance { get { if (instance==null) { instance = new Singleton(); } return instance; } }}

當有2個(或更多)線程競相訪問Singleton的Instance屬性時,我們假設線程A更快些,正準備執行「instance = new Singleton();」這行代碼,此時系統切換到線程B(關於多線程機制的詳細介紹,建議參考《CLR via C#》,筆者看的是第4版),線程B剛好開始執行instance的null判斷,此時線程A由於尚未執行new的操作,所以對線程B來說「instance == null」結果仍為True,也開始準備執行「new Singleton()」。當線程A、B執行完畢後,雖然instance作為靜態變數還是只有一個實例,但是事實上線程A、B都創建過了Singleton的實例,具體instance最終的值是誰創建的,取決於兩個或多個線程誰最後執行完創建實例的操作。很顯然,這樣的代碼在多線程環境下是很浪費資源的,所以……只能放在各種單例模式的博客、討論、文章(包括本文)里作為反面教材

事實上,如果可以確保每次只有一個線程能執行上述代碼,其實也是可以的,於是又有了以下代碼:

//以下代碼來自參考網址public sealed class Singleton{ private static Singleton instance = null; private static readonly object padlock = new object(); Singleton() { } public static Singleton Instance { get { lock (padlock) { if (instance == null) { instance = new Singleton(); //Do a heavy task } return instance; } } }}

現在我們將執行NullCheck以及New實例的操作加鎖(lock),仍以我們上文討論的2個線程為例:現在線程A在執行該操作時,在未創建完實例退出鎖之前,線程B將不能執行NullCheck操作,處於停滯狀態。等到線程A執行完畢後,線程B才能執行鎖內的代碼,此時NullCheck的結果返回false。這樣就確保了Singleton的實例只會被創建一次,而壞處其實就是鎖帶來的性能損耗。形象點來說:這裡的instance就像個單間廁所,線程A在如廁的時候,線程B只能眼巴巴地在門外憋著等,什麼都做不了。示例中鎖內的代碼只是執行了很簡單的操作,如果是比較複雜耗時的操作,那麼後來的線程C、D、F……只能跟線程B一樣乖乖在「廁所」門外憋著肚子排隊了。(在本文的參考網址中,作者還特別有心地提到了為什麼特意加了一個「padlock」作為鎖的對象,不清楚鎖的可以了解下。這裡簡而言之,其實"lock(this)"是可以的,只是這樣的代碼習慣受人詬病,因為如果不對多線程十分理解且對當前代碼塊的功能定位十分自信的話,可能會導致「死鎖(DeadLock)」或者引發其他性能問題,相當於埋下一顆雷,至於是你本人在未來的某天踩還是後面接任的人踩……都不是件令人開心的事情)

現在,我們有些心疼在「廁所」門外憋著肚子的線程B等眾兄弟,於是有人提出了一個解決方案:「線程A在廁所拉完肚子沖完水就可以了,後面擦屁股、提褲子等操作就完全沒必要再占著茅坑了,線程A在自己的虛空世界中完成這些操作吧。」代碼來了:

//以下代碼來自參考網址public sealed class Singleton{ private static Singleton instance = null; private static readonly object padlock = new object(); Singleton() { } public static Singleton Instance { get { if (instance == null) { lock (padlock) { if (instance == null) { instance = new Singleton(); //Do a heavy task } } } return instance; } }}

這就是大名鼎鼎的「雙重鎖(Double-Checked Locking)」。現在和線程A競爭的線程B,一起突破了第一關的NullCheck,線程B還是得老老實實地等線程A執行完鎖內的代碼;但是一旦線程A創建完實例,後來的線程C、D、F等一眾壯漢,在第一關NullCheck時就繞道了,直接獲得了instance的值,根本不需要在鎖面前斗個你死我活。之前的「廁所」是A進了B必須等著、B進了C必須等著,現在「廁所」則是不巧跟線程A競爭的線程B必須等著,但是只要A沖完水(實例已經被創建),稍晚來的C、D、F等可以迅速獲得坑位(這裡突然覺得廁所的例子不太好,因為對多線程環境來說,此時C等一眾兄弟相當於是重疊在同一個坑上迅速地拉完肚子沖完水……那畫面……額……)。當初筆者第一次接觸到雙重鎖,深感此方式通俗易懂、簡便易行,工作中也時常使用。然而(又要長見識了!)——雙重鎖並不是完全沒有缺點的。參考網址的作者給出了4個缺點,本文略作翻譯:

  • 對Java來說該方法失效。具體原因懶得翻譯了,畢竟C#才是我的真愛,跟Java不熟。
  • 依賴內存壁壘(Memory Barrier)技術。說實話現在的我也不是很懂(好尬),初步理解是作者好像更在意ECMA批准的CLI標準(在任意操作系統上的.Net平台都遵守該標準,如Mono),而微軟可能針對自家Windows平台的.Net加了一些更強大的語義,這些語義可能會在其他平台上失效或不可用。這時候就需要對instance加volatile約束(關於volatile的用途,可以查閱MSDN),或者必須顯式調用內存壁壘,一旦面臨後者的情況,作者吐槽說:「連專家都很難分清哪些壁壘是必需的」。所以作者建議避免使用這種「不可控」的方案。
  • 更容易出錯。這個容易理解,我們假設instance是一個ArrayList,鎖內代碼包含一些較重的任務,如instance值的初始化、增加元素、排序等等操作。第一個執行鎖內代碼的線程A,在創建實例後,後來的線程C就可以跳過鎖直接獲取到instance的值。然而線程A在創建實例後,仍會對instance進行一些操作,而線程C在獲取到值後也可能在外部代碼中對instance有操作,這就容易產生多線程之間的衝突,會導致各種不可測的結果(大多數都是我們不想要的)或拋出異常。
  • 還有比它性能更好的方案。

所以……性能更好的方案呢?先看最簡單的:

//以下代碼來自參考網址public sealed class Singleton{ private static readonly Singleton instance = new Singleton(); // Explicit static constructor to tell C# compiler not to mark type as beforefieldinit static Singleton() { } private Singleton() { } public static Singleton Instance { get { return instance; } }}

如果從實現單例出發,這個方案的本質不難理解:類的靜態構造函數在每個AppDomian里僅會執行一次。但那行注釋很有意思:「顯示調用靜態構造函數以告訴C#編譯器不要將類型標記為BeforeFieldInit」。「BeforeFieldInit」是啥?這是比較底層的玩意兒。對.Net來說,一個類的初始化過程是在構造器中進行的。構造器分為類型構造器和對象構造器,前者負責初始化靜態成員,後者負責初始化類的實例。類型構造器強制是私有的、隱式調用的。JIT編譯器生成靜態構造函數的代碼後,使用了2種方式決定何時執行靜態構造函數:

  • BeforeFieldInit。這是默認的方式,即讓CLR去決定該何時執行類的靜態構造函數。也就是說,CLR可能在類未接收到任何調用之前,就已經執行了靜態構造函數,以生成運行效率更高的代碼。對上文給出的例子來說,若不顯示聲明靜態構造函數,Singleton類極有可能在尚未被使用前靜態成員instance就已經生成了;如果Singleton類還有其他靜態成員,都會一起被初始化。假若該類一直未被調用,就造成了資源(主要是內存)浪費。
  • Precise。該方式會剛好在類的第一次創建實例之前,或第一次調用類的一個非繼承欄位或成員之前執行靜態構造函數。它需要顯示聲明靜態構造函數。

如果按照延遲性(Laziness)的要求(調用的時候才創建),我們就需要顯示聲明Singleton類的靜態構造函數,讓CLR採取Precise的方式執行相關代碼。這可能會造成相對顯著的性能影響,尤其對於循環體,例如:在一個循環中調用單例(包含首次調用),BeforeFieldInit方式可以讓CLR決定在循環前就調用靜態構造函數,該代碼只會執行一次;而Precise模式只會在循環中執行靜態構造函數,並在之後每次調用都會檢查靜態構造函數是否已被執行。所以如果Singleton類的初始化工作不是很繁重,且不會對其他的代碼造成副作用,那麼完全不必顯示聲明靜態構造函數。

如果我們還是希望保證延遲性,同時還對性能有略苛刻的需求,可以對上面的方案再做優化:

//以下代碼來自參考網址public sealed class Singleton{ private Singleton() { } public static Singleton Instance { get { return Nested.instance; } } private class Nested { // Explicit static constructor to tell C# compiler // not to mark type as beforefieldinit static Nested() { } internal static readonly Singleton instance = new Singleton(); }}

這種方案兼顧了延遲性和性能,是比較好的實現。Nested類是私有內部類,所以儘管instance是internal或者public的訪問許可權,但還是只有Singleton類里的成員可以訪問,依然是安全的實現方式。就一個壞處……寫起來有些麻煩。

於是,微軟爸爸出手了。從.Net 4.0開始,官方加入了封裝好的「黑科技」:MSDN_Lazy<T>。示例代碼如下:

//以下代碼來自參考網址,筆者做了些微修改public sealed class Singleton{ private Lazy<Singleton> lazy; public Singleton Instance { get { return lazy.Value; } } public Singleton() { lazy = new Lazy<Singleton>(InitializeSingleton); } private Singleton InitializeSingleton() { Singleton data = new Singleton(); //Do something return data; }}

Lazy<T>本身是比較「輕量級」的。關於Lazy<T>的用法,參考給出的MSDN鏈接即可。示例代碼使用了它的重載版本:Lazy<T>(Func<T>),它默認是線程安全的。

好了,小結到這裡也差不多了。除了第1種方案作為反面教材不推薦使用外,其餘方案怎麼取捨就見仁見智了。我給出的主要參考網址里,原文作者在文末總結中對以上方案做了排序(推薦程度從高至低):4>2>5=6>3。我建議有興趣且有一定英語閱讀能力的讀者耐心地讀一讀原文,作者比較詳細地給出了自己對各個方案的意見。我個人反倒更常用第3種方案,其次是第6種和第4種方案,當然我有我的原因:開發環境穩定,.Net版本4.6.0+。所以,方案2-6的取捨還是根據實際情況來吧。對我來說,「這些方案是怎麼想出來的?」才是真的關注點;我也希望藉此文能讓讀者對各種技術解決方案背後的原理產生興趣!

最後的最後,關於配圖多說一句:願諸位程序猿早日擺脫「單身模式」!預祝國慶快樂!

主要參考網址:Implementing the Singleton Pattern

次要參考:

  1. MSDN_Volatile
  2. StackOverflow_BeforeFieldInit

推薦閱讀:

面向新手的雜談:Flyweight(續)
設計模式之「Decorator」註疏#02

TAG:C | 設計模式 | NET |