C# 中的 in 參數和性能分析


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 ↩︎


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM