C# 中的 in 引數和效能分析

技術譯民發表於2020-11-03

in 修飾符也是從 C# 7.2 開始引入的,它與我們上一篇中討論的 《C# 中的只讀結構體(readonly struct)[1] 是緊密相關的。

in 修飾符

in 修飾符通過引用傳遞引數。 它讓形參成為實參的別名,即對形參執行的任何操作都是對實參執行的。 它類似於 refout 關鍵字,不同之處在於 in 引數無法通過呼叫的方法進行修改。

  • ref 修飾符,指定引數由引用傳遞,可以由呼叫方法讀取或寫入。
  • out 修飾符,指定引數由引用傳遞,必須由呼叫方法寫入。
  • in 修飾符,指定引數由引用傳遞,可以由呼叫方法讀取,但不可以寫入。

舉個簡單的例子:

struct Product
{
    public int ProductId { get; set; }
    public string ProductName { get; set; }
}

public static void Modify(in Product product)
{
    //product = new Product();          // 錯誤 CS8331 無法分配到 變數 'in Product',因為它是隻讀變數
    //product.ProductName = "測試商品";  // 錯誤 CS8332 不能分配到 變數 'in Product' 的成員,因為它是隻讀變數
    Console.WriteLine($"Id: {product.ProductId}, Name: {product.ProductName}"); // OK
}

引入 in 引數的原因

我們知道,結構體例項的記憶體在棧(stack)上進行分配,所佔用的記憶體隨宣告它的型別或方法一起回收,所以通常在記憶體分配上它是比引用型別佔有優勢的。[2]

但是對於有些很大(比如有很多欄位或屬性)的結構體,將其作為方法引數,在緊湊的迴圈或關鍵程式碼路徑中呼叫方法時,複製這些結構的成本就會很高。當所呼叫的方法不修改該引數的狀態,使用新的修飾符 in 宣告引數以指定此引數可以按引用安全傳遞,可以避免(可能產生的)高昂的複製成本,從而提高程式碼執行的效能。

in 引數對效能的提升

為了測試 in 修飾符對效能的提升,我定義了兩個較大的結構體,一個是可變的結構體 NormalStruct,一個是隻讀的結構體 ReadOnlyStruct,都定義了 30 個屬性,然後定義三個測試方法:

  • DoNormalLoop 方法,引數不加修飾符,傳入一般結構體,這是以前比較常見的做法。
  • DoNormalLoopByIn 方法,引數加 in 修飾符,傳入一般結構體。
  • DoReadOnlyLoopByIn 方法,引數加 in 修飾符,傳入只讀結構體。

程式碼如下所示:

public struct NormalStruct
{
    public decimal Number1 { get; set; }
    public decimal Number2 { get; set; }
    //...
    public decimal Number30 { get; set; }
}

public readonly struct ReadOnlyStruct
{
    // 自動屬性上的 readonly 關鍵字是可以省略的,這裡加上是為了便於理解
    public readonly decimal Number1 { get; }
    public readonly decimal Number2 { get; }
    //...
    public readonly decimal Number30 { get; }
}

public class BenchmarkClass
{
    const int loops = 50000000;
    NormalStruct normalInstance = new NormalStruct();
    ReadOnlyStruct readOnlyInstance = new ReadOnlyStruct();

    [Benchmark(Baseline = true)]
    public decimal DoNormalLoop()
    {
        decimal result = 0M;
        for (int i = 0; i < loops; i++)
        {
            result = Compute(normalInstance);
        }
        return result;
    }

    [Benchmark]
    public decimal DoNormalLoopByIn()
    {
        decimal result = 0M;
        for (int i = 0; i < loops; i++)
        {
            result = ComputeIn(in normalInstance);
        }
        return result;
    }

    [Benchmark]
    public decimal DoReadOnlyLoopByIn()
    {
        decimal result = 0M;
        for (int i = 0; i < loops; i++)
        {
            result = ComputeIn(in readOnlyInstance);
        }
        return result;
    }

    public decimal Compute(NormalStruct s)
    {
        //業務邏輯...
        return 0M;
    }

    public decimal ComputeIn(in NormalStruct s)
    {
        //業務邏輯...
        return 0M;
    }

    public decimal ComputeIn(in ReadOnlyStruct s)
    {
        //業務邏輯...
        return 0M;
    }
}

在沒有使用 in 引數的方法中,意味著每次呼叫傳入的是變數的一個新副本; 而在使用 in 修飾符的方法中,每次不是傳遞變數的新副本,而是傳遞同一副本的只讀引用。

使用 BenchmarkDotNet 工具測試三個方法的執行時間,結果如下:

Method Mean Error StdDev Median Ratio RatioSD
DoNormalLoop 1,536.3 ms 65.07 ms 191.86 ms 1,425.7 ms 1.00 0.00
DoNormalLoopByIn 480.9 ms 27.05 ms 79.32 ms 446.3 ms 0.32 0.07
DoReadOnlyLoopByIn 581.9 ms 35.71 ms 105.30 ms 594.1 ms 0.39 0.10

從這個結果可以看出,如果使用 in 引數,不管是一般的結構體還是隻讀結構體,相對於不用 in 修飾符的引數,效能都有較大的提升。這個效能差異在不同的機器上執行可能會有所不同,但是毫無疑問,使用 in 引數會得到更好的效能。

在 Parallel.For 中使用

在上面簡單的 for 迴圈中,我們看到 in 引數有助於效能的提升,那麼在並行運算中呢?我們把上面的 for 迴圈改成使用 Parallel.For 來實現,程式碼如下:

[Benchmark(Baseline = true)]
public decimal DoNormalLoop()
{
    decimal result = 0M;
    Parallel.For(0, loops, i => Compute(normalInstance));
    return result;
}

[Benchmark]
public decimal DoNormalLoopByIn()
{
    decimal result = 0M;
    Parallel.For(0, loops, i => ComputeIn(in normalInstance));
    return result;
}

[Benchmark]
public decimal DoReadOnlyLoopByIn()
{
    decimal result = 0M;
    Parallel.For(0, loops, i => ComputeIn(in readOnlyInstance));
    return result;
}

事實上,道理是一樣的,在沒有使用 in 引數的方法中,每次呼叫傳入的是變數的一個新副本; 在使用 in 修飾符的方法中,每次傳遞的是同一副本的只讀引用。

使用 BenchmarkDotNet 工具測試三個方法的執行時間,結果如下:

Method Mean Error StdDev Ratio
DoNormalLoop 793.4 ms 13.02 ms 11.54 ms 1.00
DoNormalLoopByIn 352.4 ms 6.99 ms 17.27 ms 0.42
DoReadOnlyLoopByIn 341.1 ms 6.69 ms 10.02 ms 0.43

同樣表明,使用 in 引數會得到更好的效能。

使用 in 引數需要注意的地方

我們來看一個例子,定義一個一般的結構體,包含一個屬性 Value 和 一個修改該屬性的方法 UpdateValue。 然後在別的地方也定義一個方法 UpdateMyNormalStruct 來修改該結構體的屬性 Value
程式碼如下:

struct MyNormalStruct
{
    public int Value { get; set; }

    public void UpdateValue(int value)
    {
        Value = value;
    }
}

class Program
{
    static void UpdateMyNormalStruct(MyNormalStruct myStruct)
    {
        myStruct.UpdateValue(8);
    }

    static void Main(string[] args)
    {
        MyNormalStruct myStruct = new MyNormalStruct();
        myStruct.UpdateValue(2);
        UpdateMyNormalStruct(myStruct);
        Console.WriteLine(myStruct.Value);
    }
}

您可以猜想一下它的執行結果是什麼呢? 2 還是 8?

我們來理一下,在 Main 中先呼叫了結構體自身的方法 UpdateValueValue 修改為 2, 再呼叫 Program 中的方法 UpdateMyNormalStruct, 而該方法中又呼叫了 MyNormalStruct 結構體自身的方法 UpdateValue,那麼輸出是不是應該是 8 呢? 如果您這麼想就錯了。
它的正確輸出結果是 2,這是為什麼呢?

這是因為,結構體和許多內建的簡單型別(sbyte、byte、short、ushort、int、uint、long、ulong、char、float、double、decimal、bool 和 enum 型別)一樣,都是值型別,在傳遞引數的時候以值的方式傳遞。因此呼叫方法 UpdateMyNormalStruct 時傳遞的是 myStruct 變數的新副本,在此方法中,其實是此副本呼叫了 UpdateValue 方法,所以原變數 myStructValue 不會發生變化。

說到這裡,有聰明的朋友可能會想,我們給 UpdateMyNormalStruct 方法的引數加上 in 修飾符,是不是輸出結果就變為 8 了,in 引數不就是引用傳遞嗎?
我們可以試一下,把程式碼改成:

static void UpdateMyNormalStruct(in MyNormalStruct myStruct)
{
    myStruct.UpdateValue(8);
}

static void Main(string[] args)
{
    MyNormalStruct myStruct = new MyNormalStruct();
    myStruct.UpdateValue(2);
    UpdateMyNormalStruct(in myStruct);
    Console.WriteLine(myStruct.Value);
}

執行一下,您會發現,結果依然為 2 !這……就讓人大跌眼鏡了……
用工具檢視一下 UpdateMyNormalStruct 方法的中間語言:

.method private hidebysig static 
	void UpdateMyNormalStruct (
		[in] valuetype ConsoleApp4InTest.MyNormalStruct& myStruct
	) cil managed 
{
	.param [1]
		.custom instance void [System.Runtime]System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = (
			01 00 00 00
		)
	// Method begins at RVA 0x2164
	// Code size 18 (0x12)
	.maxstack 2
	.locals init (
		[0] valuetype ConsoleApp4InTest.MyNormalStruct
	)

	IL_0000: nop
	IL_0001: ldarg.0
	IL_0002: ldobj ConsoleApp4InTest.MyNormalStruct 
	IL_0007: stloc.0
	IL_0008: ldloca.s 0
	IL_000a: ldc.i4.8
	IL_000b: call instance void ConsoleApp4InTest.MyNormalStruct::UpdateValue(int32)
	IL_0010: nop
	IL_0011: ret
} // end of method Program::UpdateMyNormalStruct

您會發現,在 IL_0002IL_0007IL_0008 這幾行,仍然建立了一個 MyNormalStruct 結構體的防禦性副本(defensive copy)。雖然在呼叫方法 UpdateMyNormalStruct 時以引用的方式傳遞引數,但在方法體中呼叫結構體自身的 UpdateValue 前,卻建立了一個該結構體的防禦性副本,改變的是該副本的 Value。這就有點奇怪了,不是嗎?

我們使用 in 引數的目的就是想減少結構體的複製從而提升效能,但這裡並沒有起到作用。甚至,假如 UpdateMyNormalStruct 方法中多次呼叫該結構體的非只讀方法,編譯器也會多次建立該結構體的防禦性副本,這就對效能產生了負面影響。

Google 了一些資料是這麼解釋的:C# 無法知道當它呼叫一個結構體上的方法(或getter)時,是否也會修改它的值/狀態。於是,它所做的就是建立所謂的“防禦性副本”。當在結構體上執行方法(或getter)時,它會建立傳入的結構體的副本,並在副本上執行方法。這意味著原始副本與傳入時完全相同,呼叫者傳入的值並沒有被修改。

有沒有辦法讓方法 UpdateMyNormalStruct 呼叫後輸出 8 呢?您將引數改成 ref 修飾符試試看 ? ? ?

綜上所述,最好不要把 in 修飾符和一般(非只讀)結構體一起使用,以免產生晦澀難懂的行為,而且可能對效能產生負面影響。

in 引數的限制

不能將 inrefout 關鍵字用於以下幾種方法:

  • 非同步方法,通過使用 async 修飾符定義。
  • 迭代器方法,包括 yield returnyield break 語句。
  • 擴充套件方法的第一個引數不能有 in 修飾符,除非該引數是結構體。
  • 擴充套件方法的第一個引數,其中該引數是泛型型別(即使該型別被約束為結構體。)

總結

  • 使用 in 引數,有助於明確表明此引數不可修改的意圖。
  • 只讀結構體(readonly struct的大小大於 IntPtr.Size [3] 時,出於效能原因,應將其作為 in 引數傳遞。
  • 不要將一般(非只讀)結構體作為 in 引數,因為結構體是可變的,反而有可能對效能產生負面影響,並且可能產生晦澀難懂的行為。

作者 : 技術譯民
出品 : 技術譯站


  1. https://www.cnblogs.com/ittranslator/p/13876180.html C# 中的只讀結構體 ↩︎

  2. https://www.cnblogs.com/ittranslator/p/13664383.html C# 中 Struct 和 Class 的區別總結 ↩︎

  3. https://docs.microsoft.com/zh-cn/dotnet/api/system.intptr.size#System_IntPtr_Size IntPtr.Size ↩︎

相關文章