C# 單例模式的實現和效能對比

Lemuria發表於2018-10-08

簡介

單例指的是只能存在一個例項的類(在C#中,更準確的說法是在每個AppDomain之中只能存在一個例項的類,它是軟體工程中使用最多的幾種模式之一。在第一個使用者建立了這個類的例項之後,其後需要使用這個類的就只能使用之前建立的例項,無法再建立一個新的例項。通常情況下,單例會在第一次被使用時建立。本文會對C#中幾種單例的實現方式進行介紹,並分析它們之間的執行緒安全性和效能差異。

單例的實現方式有很多種,但從最簡單的實現(非延遲載入,非執行緒安全,效率低下),到可延遲載入,執行緒安全,且高效的實現,它們都有一些基本的共同點:

. 單例類都只有一個private的無參建構函式
. 類宣告為sealed(不是必須的)
. 類中有一個靜態變數儲存著所建立的例項的引用
. 單例類會提供一個靜態方法或屬性來返回建立的例項的引用(eg.GetInstance)
複製程式碼

幾種實現

一. 非執行緒安全

//Bad code! Do not use!
public sealed class Singleton
{
    private static Singleton instance = null;

    private Singleton()
    {

    }

    public static Singleton instance
    {
        get
        {
            if (instance == null)
            {
                instance = new Singleton();
            }
            return instance;
        }
    }
}
複製程式碼

這種方法不是執行緒安全的,會存在兩個執行緒同時執行if (instance == null)並且建立兩個不同的instance,後建立的會替換掉新建立的,導致之前拿到的reference為空。

二. 簡單的執行緒安全實現

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();
                }
                return instance;
            }
        }
    }
}
複製程式碼

相比較於實現一,這個版本加上了一個對instance的鎖,在呼叫instance之前要先對padlock上鎖,這樣就避免了實現一中的執行緒衝突,該實現自始至終只會建立一個instance了。但是,由於每次呼叫Instance都會使用到鎖,而呼叫鎖的開銷較大,這個實現會有一定的效能損失。

注意這裡我們使用的是新建一個private的object例項padlock來實現鎖操作,而不是直接對Singleton進行上鎖。直接對型別上鎖會出現潛在的風險,因為這個型別是public的,所以理論上它會在任何code裡呼叫,直接對它上鎖會導致效能問題,甚至會出現死鎖情況。

Note: C#中,同一個執行緒是可以對一個object進行多次上鎖的,但是不同執行緒之間如果同時上鎖,就可能會出現執行緒等待,或者嚴重的會出現死鎖情況。因此,我們在使用lock時,儘量選擇類中的私有變數上鎖,這樣可以避免上述情況發生。

三. 雙重驗證的執行緒安全實現

public sealed calss 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();
                    }
                }
            }
            return instance;
        }
    } 
}
複製程式碼

在保證執行緒安全的同時,這個實現還避免了每次呼叫Instance都進行lock操作,這會節約一定的時間。

但是,這種實現也有它的缺點:

1. 無法在Java中工作。(具體原因可以見原文,這邊沒怎麼理解)
2. 程式設計師在自己實現時很容易出錯。如果對這個模式的程式碼進行自己的修改,要倍加小心,因為double check的邏輯較為複雜,很容易出現思考不周而出錯的情況。
複製程式碼

四. 不用鎖的執行緒安全實現

public sealed class Singleton
{
    //在Singleton第一次被呼叫時會執行instance的初始化
    private static readonly Singleton instance = new Singleton();

    //Explicit static consturctor to tell C# compiler 
    //not to mark type as beforefieldinit
    static Singleton()
    {
    }

    private Singleton()
    {
    }

    public static Singleton Instance
    {
        get
        {
            return instance;
        }
    }
}
複製程式碼

這個實現很簡單,並沒有用到鎖,但是它仍然是執行緒安全的。這裡使用了一個static,readonly的Singleton例項,它會在Singleton第一次被呼叫的時候新建一個instance,這裡新建時候的執行緒安全保障是由.NET直接控制的,我們可以認為它是一個原子操作,並且在一個AppDomaing中它只會被建立一次。

這種實現也有一些缺點:

1. instance被建立的時機不明,任何對Singleton的呼叫都會提前建立instance
2. static建構函式的迴圈呼叫。如有A,B兩個類,A的靜態建構函式中呼叫了B,而B的靜態建構函式中又呼叫了A,這兩個就會形成一個迴圈呼叫,嚴重的會導致程式崩潰。
3. 我們需要手動新增Singleton的靜態建構函式來確保Singleton型別不會被自動加上beforefieldinit這個Attribute,以此來確保instance會在第一次呼叫Singleton時才被建立。
4. readonly的屬性無法在執行時改變,如果我們需要在程式執行時dispose這個instance再重新建立一個新的instance,這種實現方法就無法滿足。
複製程式碼

五. 完全延遲載入實現(fully lazy instantiation)

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();
    }
}
複製程式碼

實現五是實現四的包裝。它確保了instance只會在Instance的get方法裡面呼叫,且只會在第一次呼叫前初始化。它是實現四的確保延遲載入的版本。

六 使用.NET4的Lazy型別

public sealed class Singleton
{
    private static readonly Lazy<Singleton> lazy = new Lazy<Singleton>(() => new Singleton());

    public static Singleton Instance 
    {
        get 
        {
            return lazy.Value;
        }
    }

    private Singleton()
    {
    }
}
複製程式碼

.NET4或以上的版本支援Lazy來實現延遲載入,它用最簡潔的程式碼保證了單例的執行緒安全和延遲載入特性。

效能差異

之前的實現中,我們都在強調程式碼的執行緒安全性和延遲載入。然而在實際使用中,如果你的單例類的初始化不是一個很耗時的操作或者初始化順序不會導致bug,延遲初始化是一個可有可無的特性,因為初始化所佔用的時間是可以忽略不計的。

在實際使用場景中,如果你的單例例項會被頻繁得呼叫(如在一個迴圈中),那麼為了保證執行緒安全而帶來的效能消耗是更值得關注的地方。

為了比較這幾種實現的效能,我做了一個小測試,迴圈拿這些實現中的單例9億次,每次呼叫instance的方法執行一個count++操作,每隔一百萬輸出一次,執行環境是MBP上的Visual Studio for Mac。結果如下:

執行緒安全性 延遲載入 測試執行時間(ms)
實現一 15532
實現二 45803
實現三 15953
實現四 不完全 14572
實現五 14295
實現六 22875

測試方法並不嚴謹,但是仍然可以看出,方法二由於每次都需要呼叫lock,是最耗時的,幾乎是其他幾個的三倍。排第二的則是使用.NET Lazy型別的實現,比其他多了二分之一左右。其餘的四個,則沒有明顯區別。

總結

總體來說,上面說的多種單例實現方式在現今的計算機效能下差距都不大,除非你需要特別大併發量的呼叫instance,才會需要去考慮鎖的效能問題。

對於一般的開發者來說,使用方法二或者方法六來實現單例已經是足夠好的了,方法四和五則需要對C#執行流程有一個較好的認識,並且實現時需要掌握一定技巧,並且他們節省的時間仍然是有限的。

引用

本文大部分是翻譯自Implementing the Singleton Pattern in C#,加上了一部分自己的理解。這是我搜尋static readonly field initializer vs static constructor initialization時看到的,在這裡對兩位作者表示感謝。

相關文章