[料理佳餚] 開發分散式運算的應用程式時,加入不可變性(Immutability)的設計來加強意圖。

由於 CPU 時脈的發展受限,因此 CPU 的發展不再往更高時脈前進,而是往更多核心數前進,隨之而來的,便是軟體設計師需要調整軟體的設計,讓原本的演算法能夠分散執行,充分地利用多核心的資源,使程式執行起來更有效率,而在開發分散式運算應用程式時,我個人是認為應該加入不可變性(Immutability)的設計,來強調其分散式運算的意圖。

可變性與不可變性

我們先來了解一下什麼是可變性?什麼是不可變性?

什麼是可變性?

在 C# 中,我們自行建立的類別,預設都是可變的,底下有一個 Mutable 類別,當我 new 了一個 Mutable 的實例之後,我可以隨意地變更 Id 及 Name 的屬性值,這我們就可以說 Mutable 類別具有可變性。

public class Mutable
{
    public int Id { get; set; }

    public string Name { get; set; }
}

什麼是不可變性?

在 C# 中典型具有不可變性的類別就是 string,字串天生是不可變的,在底下的程式碼當中,我建立了一個 "Johnny" 的字串物件 str,然後我在將 str 變更為 "Mary",實際上 str 從 "Johnny" 變 "Mary" 的過程中,是另外分配一塊獨立的記憶體空間來存放 "Mary",而不是覆寫同一塊記憶體空間。

var str = "Johnny";
str = "Mary";

那麼,我們自訂的類別如何具有不可變性? 基本上只要將屬性設成唯讀,賦值都透過建構式,我們就可以說在定義上,我們的類別具有不可變性。

public sealed class Immutable
{
    public Immutable(int id, string name)
    {
        this.Id = id;
        this.Name = name;
    }

    public int Id { get; }

    public string Name { get; }
}

要注意的是,透過 Reflection 或是直接撰寫 IL Code,即使是唯讀的屬性,還是能修改屬性值,因此不可變性只是在定義上具有不可變性,僅開發時期的一種約束,用來強調意圖。

資源共享在分散式運算中要特別小心

資源所描述的範圍很廣,資料庫、磁碟機、...等,甚至是 new 出來的物件,都可以算是一種資源,在分散式運算的環境之下,往往會伴隨著高併發與非同步的情況,這個時候如果資源是稀少的,甚至是只有一份,卻需要在多個運算單元中同時進行讀寫,為了追求資源內容的正確性,共享的資源就很容易成為系統的瓶頸。

我們把眼界拉回聚焦在物件上,我們的物件可不可變跟分散式運算有什麼關係? 底下我舉個簡單的例子,我有一個 Number 物件,丟進 5 個 Job 當中,Job 裡面會對 Number 的 Value 加上指定數值之後印出來,指定的數值分別是 1 ~ 5,使用者還要求在加上 3 之前,Number 的 Value 要先乘以 2 倍,由於 Job 與 Job 之間為獨立的作業,為了提高效率,我使用 Task.Run() 來分散執行這 5 個 Job。

private static void Main(string[] args)
{
    var number = new Number { Value = 1 };

    var job1 = new Job(1);
    var job2 = new Job(2);
    var job3 = new Job(3);
    var job4 = new Job(4);
    var job5 = new Job(5);

    Task.Run(() => job1.Execute(number));
    Task.Run(() => job2.Execute(number));
    Task.Run(() => job3.Execute(number));
    Task.Run(() => job4.Execute(number));
    Task.Run(() => job5.Execute(number));

    Console.ReadKey();
}

internal class Job
{
    private readonly int addition;

    public Job(int addition)
    {
        this.addition = addition;
    }

    public void Execute(Number number)
    {
        if (this.addition == 3)
        {
            number.Value *= 2;
        }

        number.Value += this.addition;

        Console.WriteLine("Job{0}: Value={1}", this.addition, number.Value);
    }
}

internal class Number
{
    public int Value { get; set; }
}

執行結果預期會是 2、3、5、5、6 等數字隨機出現,但是卻出現了非預期的結果。

我想各位朋友應該都知道問題出在 Job.Execute() 這個方法裡面,Number 這個類別是具有可變性的,當我們在開發 Job.Execute() 這個方法的時候,應該要避免對參數 Number 的 Value 進行修改,以免其他同時在執行的 Job 取得非預期的參數值。

就如同開頭提到的,最好是將 Number 類別設計成具有不可變性,來讓我們自己也好,或是其他開發人員也好,感受到目前正在開發的這個方法有可能會是分散執行的意圖。

改過之後,執行結果就跟預期的一樣。

此外,雖然無法完全避免,但將物件設計成不可變,可以避掉大部分 False Sharing 的情況,False Sharing 發生的主要原因是在於變數被其他執行緒修改,而不可變物件已被限制了寫操作,除非使用更進階的手法,不然不可變物件一旦被建立起來後,直到被 GC 回收前應該是不會再被修改,關於 False Sharing 更詳細的說明可以參考黑大的文章 - False Sharing 原理動態展示

某些物件不應該可變

不可變的設計也能夠展現在我們對某些類別其不可變的定義上,舉個例子,我有一個 Money 類別,包含了 CurrencyAmount

internal class Money
{
    public Currency Currency { get; set; }

    public decimal Amount { get; set; }
}

當我需要新台幣 100 元時,就 new 一個 Money 物件:

var money = new Money { Currency = Currency.NTD, Amount = 100 }

可是當我們需要將新台幣換算成美元的時候,我們經常直覺會這樣寫:

money.Currency = Currency.USD;
money.Amount /= 31.168;

這樣其實有點怪,好像把一張新台幣 100 元鈔票的顏色洗掉,印成美元 3.2 元的樣子,可是這在程式設計的世界挺常見的,像 Money 這樣子的類別,我們其實可以把它宣告為不可變的,更明確地展現我們的意圖。

小結

再強調一次,不可變性的設計只是一種約束,用來加強我們的定義及意圖,加入不可變性的設計的會使得記憶體的耗用量變大,以及增加 GC 的工作量,但是我們換來的是讓可獨立工作的程式擁有自己的資源,不必去跟其他程式競爭,以及明確的設計意圖,分散式運算不是免費的,大多數時候不用分散式的算法反而更有效率,但是當我們一旦決定採用分散式運算的時候,程式設計的思維就要跟著轉換。

C# 指南 ASP.NET 教學 ASP.NET MVC 指引
Azure SQL Database 教學 SQL Server 教學 Xamarin.Forms 教學