Avoid Defensive Copy on Struct

大多數人一定都知道.NET的型別系統有兩大分支:一個是參考型別(reference types),另一個則是實值型別(value types)。

C# 從 7.0 之後引入了幾個跟實值型別效能相關的特性:

  1. ref 區域變數和傳回
  2. 以唯讀參考傳遞引數
  3. ref readonly 傳回
  4. readonly struct 類型
  5. ref struct 類型
  6. readonly ref struct 類型

這一切都是為了降低GC回收成本以提升效能。ref struct(Span<T>)是 dotnetcore 2.1 最大的亮點,在此就先不討論了。


以實值型別作為參數或是回結果的時候,預設都是使用副本傳遞(pass by value)。雖然在呼叫堆疊(call stack)上宣告的實值型別變數不會使用堆積(Heap)配置,因此不需要 GC 回收,但如果實值型別體積過大,記憶體複製所帶來的效能影響還是很可觀,因此我們不能一廂情願地為了效能而使用實值型別

要避免建立副本的開銷必須使用另一種參數傳遞方式就是 pass by reference。refout參數對CLR來說都是一樣的,差別只在於 C# 編譯器針對out參數必須在方法主體內顯式賦值具有強制性**。

void Foo(ref int x, out int y)

int a = 0, y;
Foo(ref a, out var y);

out 參數這種的呼叫形式相信很多人都很反感,既使 C# 7.0開始支援了 out var語法簡化了使用方式,但對於使用參數當作方法回傳結果還是相當不直覺,所以在 C# 7.0 就新增了 ref return 的機制(*有條件限制)。

進入正題

readonly 是 C# 一開始就有的關鍵字,但很多人可能不知道的是readonly套用在實值型別變數上有Defensive Copy的副作用。

struct Value {
    int _x;
    public int X => _x;

    public Value(int x) => _x = x;

    public int Increment() => ++_x;
}

class Program {
    static readonly Value value = new Value(1);

    void Main(){
        Console.WriteLine($"Increment result: {value.Increment()}");
        Console.WriteLine($"X = {value.X}");
    }
}

希望你沒有被輸出結果嚇到

Increment result: 2
X = 1

readonly關鍵字其實是語意修飾詞而不是 .net 平台的特性。你可以這樣想:把變數宣告成唯讀的目的不就是為了讓變數的內容維持不變嗎? 以參考型別來說,它的內容是一個參考指標,要保證唯讀語意只要不讓他被重新指派就可以了。 而實質型別是以整個物件內所有的內容值來判定內容是否一致,因此所謂的讓實質型別變數的內容維持不變最單純的方式就是產生一個副本以避免可能有副作有的程式碼改變其內容。這個行為就叫做Defensive Copy。以上面的例子來說,不論有沒有呼叫Increment方法 Defensive Copy 一定會發生,因為編譯器無從得知哪一段程式碼具有修狀態的意圖


前面提到產生副本對效能是有傷害的。要具有唯讀語意又能避免 Defensive Copy 勢必需要有一個機制明確地告訴編譯器:這個實質型別的內容完全無法修改。 你可能會想:把所有的成員變數宣告成 readonly,然後移除所有會修改狀態的邏輯總行了吧? 答案是:編譯器也許可以這樣設計,但複雜度會比較高。

所以 C# 7.2 開始 readonly 可以當作宣告 struct 的修飾詞,這樣一來編譯器就可以明確的知道 struct 是否不可變,而且一旦宣告為 readonly struct,所有的成員變數也必須宣告為 readonly。


同樣是 7.2 加入的in 參數又是做什麼用的呢?前面有提到為了避免大型實質物件參數傳遞的開銷,我們可以將參數宣告成ref(copy by reference);可是 ref 沒有唯讀的語意。 因此使用了之前就存在的 in 關鍵字(老實說跟原本在泛型約束的用途差的有點多),C# 團隊之所以不用readonly ref的原因,其實是為了讓既有的程式碼可以更容易移植:refout參數在呼叫端也必須使用對應的關鍵字,而in參數呼叫形式與一般參數無異

void Foo(in int a);

Foo(10); // it's ok without 'in'

也可以跟 readonly ref return有所區別(解讀為具有唯讀語意的 ref return)

實質型別的設計原則

  • Mutable value types are evil
  • Use readonly modifier on structs whenever possible

補充說明

Defensive Copy 只能透過編譯器產生的 IL 代碼才能觀察出來,以上面的例子來看

Defensive Copy

IL_0000:  ldstr       "X = {0}"
IL_0005:  ldsfld      UserQuery.value  /// 產生另一個副本
IL_000A:  stloc.0                      /// 
IL_000B:  ldloca.s    00               ///  
IL_000D:  call        UserQuery+Value.get_X
IL_0012:  box         System.Int32
IL_0017:  call        System.String.Format
IL_001C:  call        System.Console.WriteLine
IL_0021:  ret         

優化後

IL_0000:  ldstr       "X = {0}"
IL_0005:  ldsflda     UserQuery.value  /// 直接使用變數的位置
IL_000A:  call        UserQuery+Value.get_X
IL_000F:  box         System.Int32
IL_0014:  call        System.String.Format
IL_0019:  call        System.Console.WriteLine
IL_001E:  ret     

參考:

Benchmark ValueTask<T> vs Task<T>

ValueTask<T> 伴隨著 C# 7.0 推出一陣時間了,但因為抽換掉 Task<T> 不一定有幫助,有時甚至會降低效能。因此大多數人的建議都是: Benchmark 比較再說。

the default choice for any asynchronous method should be to return a Task or Task<TResult>. Only if performance analysis proves it worthwhile should a ValueTask<TResult> be used instead of Task<TResult>.

照著這篇文章的思路,藉此驗證一下實際案例中替換成 ValueTask<T> 能否帶來成效。


這是一個快取的例子

public sealed class CacheManager
{
	// one day into the future
	private static TimeSpan DefaultTimeSpan = new TimeSpan((long)24 * 60 * 60 * TimeSpan.TicksPerSecond);

	private static readonly ConcurrentDictionary<string, Tuple<DateTime, object>> _objectByString =
		new ConcurrentDictionary<string, Tuple<DateTime, object>>(StringComparer.OrdinalIgnoreCase);

	public void Clear()
	{
		_objectByString.Clear();
	}

	public Task<T> GetOrCreateObjectAsync<T>(object cacheKey, TimeSpan? expiresIn, Func<Task<T>> getFunc)
	{
		var key = cacheKey.ToString();

		if (_objectByString.TryGetValue(key, out var tuple) && tuple.Item1 >= DateTime.UtcNow)
		{
      // cache hit
			return Task.FromResult((T)tuple.Item2);
		}

    // cache miss
		return CreateAndCacheAsync(key, expiresIn, getFunc);

		async Task<T> CreateAndCacheAsync(string stringKey, TimeSpan? expires, Func<Task<T>> getter)
		{

			var val = await getter();
			var expirationTime = expires == TimeSpan.MaxValue
				? DateTime.MaxValue
				: DateTime.UtcNow.Add(expires ?? DefaultTimeSpan);
			var tuple2 = Tuple.Create(expirationTime, (object)val);
			_objectByString.AddOrUpdate(stringKey, tuple2, (k, v) => tuple2);
			return val;
		}
	}
}

用戶端使用方式

int cacheKey = 0;
var cachedValue = _cacheManager.GetOrCreateObjectAsync(cacheKey, TimeSpan.MaxValue, 
  () => FetchDataAsync(cacheKey));

從用戶端代碼我們可以看到幾個問題:

  1. stringKey 參數型別是 object,如果用戶端想要用單純的 value type(例如: int)當作 key,就會產生額外的裝箱(boxing)操作。
  2. 匿名委派要使用的區域變數 cacheKey 會被當作捕獲變量(captured variable)而產生匿名物件。

為了消除這些 heap allocate 我們改成

// WithoutCapturedVariable
public Task<T> GetOrCreateObjectV2Async<TKey, T>(TKey cacheKey, TimeSpan? expiresIn, Func<TKey, Task<T>> getFunc)
{
	var key = cacheKey.ToString();

	//...

	async Task<T> CreateAndCacheAsync(string stringKey, TKey rawkey, TimeSpan? expires, Func<TKey, Task<T>> getter)
	{
		var val = await getter(rawkey);
		//...
		return val;
	}
}

Benchmark結果如下:

Method Mean Error StdDev Gen 0 Allocated
GetOrCreateObjectAsync 175.0 ns 0.8471 ns 0.7073 ns 0.0710 224 B
WithoutCapturedVariable 163.4 ns 0.3946 ns 0.3691 ns 0.0558 176 B

用戶端可以採用更積極的方式快取getFunc委派參數,以減少記憶體配置

Method Mean Error StdDev Gen 0 Allocated
WithoutCapturedVariable 163.4 ns 0.3946 ns 0.3691 ns 0.0558 176 B
WithoutCapturedVariable_CacheDelegate 156.0 ns 0.2541 ns 0.1984 ns 0.0355 112 B

接下來我們來試看看用 ValueTask<T> 取代 Task<T>,完整的benchmark結果如下:

Cache Miss

Method Mean Error StdDev Gen 0 Allocated
GetOrCreateObjectAsync 173.4 ns 0.4277 ns 0.3791 ns 0.0710 224 B
WithoutCapturedVariable 164.8 ns 0.3074 ns 0.2725 ns 0.0558 176 B
WithoutCapturedVariable_CacheDelegate 154.2 ns 0.5140 ns 0.4292 ns 0.0355 112 B
ValueTask_GetOrCreateObjectAsync 170.5 ns 0.4437 ns 0.4151 ns 0.0381 120 B
ValueTask_WithoutCapturedVariable 168.0 ns 0.4448 ns 0.4161 ns 0.0305 96 B
ValueTask_WithoutCapturedVariable_CacheDelegate 158.7 ns 0.3101 ns 0.2749 ns 0.0100 32 B

Cache Hit

Method Mean Error StdDev Gen 0 Allocated
GetOrCreateObjectAsync 173.4 ns 0.3568 ns 0.3338 ns 0.0710 224 B
WithoutCapturedVariable 162.4 ns 0.4580 ns 0.4060 ns 0.0558 176 B
WithoutCapturedVariable_CacheDelegate 154.5 ns 0.2841 ns 0.2658 ns 0.0355 112 B
ValueTask_GetOrCreateObjectAsync 169.8 ns 0.3438 ns 0.3048 ns 0.0379 120 B
ValueTask_WithoutCapturedVariable 166.6 ns 0.2539 ns 0.2251 ns 0.0303 96 B
ValueTask_WithoutCapturedVariable_CacheDelegate 156.8 ns 0.2152 ns 0.1797 ns 0.0100 32 B

結果顯示:雖然 ValueTask 沒有比較快,但差距幾乎可以忽略,就看 Memory 與 GC 成本的優化值不值得了。


完整程式碼

FiraCode for Programming

自從用了這個之後就變成工作機必備的字形

FiraCode

FiraCode

Markdown code block當然也要用看看

console.log(a === 1); 
console.log(a !== 1);   
console.log(a == 'a');       
console.log(a != true);
console.log(a >= 0);  
/* description */
var fn = a => a++;

Google: ‘Monospaced font programming ligatures’ 可以看到其他有類似效果的字體