C#中的in参数与性能分析详解
前言
in修饰符也是从C#7.2开始引入的,它与我们上一篇中讨论的《C#中的只读结构体(readonlystruct)》1是紧密相关的。
in修饰符
in修饰符通过引用传递参数。它让形参成为实参的别名,即对形参执行的任何操作都是对实参执行的。它类似于ref或out关键字,不同之处在于in参数无法通过调用的方法进行修改。
- ref修饰符,指定参数由引用传递,可以由调用方法读取或写入。
- out修饰符,指定参数由引用传递,必须由调用方法写入。
- in修饰符,指定参数由引用传递,可以由调用方法读取,但不可以写入。
举个简单的例子:
structProduct { publicintProductId{get;set;} publicstringProductName{get;set;} } publicstaticvoidModify(inProductproduct) { //product=newProduct();//错误CS8331无法分配到变量'inProduct',因为它是只读变量 //product.ProductName="测试商品";//错误CS8332不能分配到变量'inProduct'的成员,因为它是只读变量 Console.WriteLine($"Id:{product.ProductId},Name:{product.ProductName}");//OK }
引入in参数的原因
我们知道,结构体实例的内存在栈(stack)上进行分配,所占用的内存随声明它的类型或方法一起回收,所以通常在内存分配上它是比引用类型占有优势的。2
但是对于有些很大(比如有很多字段或属性)的结构体,将其作为方法参数,在紧凑的循环或关键代码路径中调用方法时,复制这些结构的成本就会很高。当所调用的方法不修改该参数的状态,使用新的修饰符in声明参数以指定此参数可以按引用安全传递,可以避免(可能产生的)高昂的复制成本,从而提高代码运行的性能。
in参数对性能的提升
为了测试in修饰符对性能的提升,我定义了两个较大的结构体,一个是可变的结构体NormalStruct,一个是只读的结构体ReadOnlyStruct,都定义了30个属性,然后定义三个测试方法:
- DoNormalLoop方法,参数不加修饰符,传入一般结构体,这是以前比较常见的做法。
- DoNormalLoopByIn方法,参数加in修饰符,传入一般结构体。
- DoReadOnlyLoopByIn方法,参数加in修饰符,传入只读结构体。
代码如下所示:
publicstructNormalStruct { publicdecimalNumber1{get;set;} publicdecimalNumber2{get;set;} //... publicdecimalNumber30{get;set;} } publicreadonlystructReadOnlyStruct { publicreadonlydecimalNumber1{get;} publicreadonlydecimalNumber2{get;} //... publicreadonlydecimalNumber30{get;} } publicclassBenchmarkClass { constintloops=50000000; NormalStructnormalInstance=newNormalStruct(); ReadOnlyStructreadOnlyInstance=newReadOnlyStruct(); [Benchmark(Baseline=true)] publicdecimalDoNormalLoop() { decimalresult=0M; for(inti=0;i在没有使用in参数的方法中,意味着每次调用传入的是变量的一个新副本;而在使用in修饰符的方法中,每次不是传递变量的新副本,而是传递同一副本的只读引用。
使用BenchmarkDotNet工具测试三个方法的运行时间,结果如下:
| Method| Mean| Error| StdDev| Median|Ratio|RatioSD|
|-------------------|-----------:|---------:|----------:|-----------:|------:|--------:|
| DoNormalLoop|1,536.3ms|65.07ms|191.86ms|1,425.7ms| 1.00| 0.00|
| DoNormalLoopByIn| 480.9ms|27.05ms| 79.32ms| 446.3ms| 0.32| 0.07|
|DoReadOnlyLoopByIn| 581.9ms|35.71ms|105.30ms| 594.1ms| 0.39| 0.10|从这个结果可以看出,如果使用in参数,不管是一般的结构体还是只读结构体,相对于不用in修饰符的参数,性能都有较大的提升。这个性能差异在不同的机器上运行可能会有所不同,但是毫无疑问,使用in参数会得到更好的性能。
在Parallel.For中使用
在上面简单的for循环中,我们看到in参数有助于性能的提升,那么在并行运算中呢?我们把上面的for循环改成使用Parallel.For来实现,代码如下:
[Benchmark(Baseline=true)] publicdecimalDoNormalLoop() { decimalresult=0M; Parallel.For(0,loops,i=>Compute(normalInstance)); returnresult; } [Benchmark] publicdecimalDoNormalLoopByIn() { decimalresult=0M; Parallel.For(0,loops,i=>ComputeIn(innormalInstance)); returnresult; } [Benchmark] publicdecimalDoReadOnlyLoopByIn() { decimalresult=0M; Parallel.For(0,loops,i=>ComputeIn(inreadOnlyInstance)); returnresult; }事实上,道理是一样的,在使用in参数的方法中,每次调用传入的是变量的一个新副本;在使用in修饰符的方法中,每次传递的是同一副本的只读引用。
使用BenchmarkDotNet工具测试三个方法的运行时间,结果如下:
| Method| Mean| Error| StdDev|Ratio|
|-------------------|---------:|---------:|---------:|------:|
| DoNormalLoop|793.4ms|13.02ms|11.54ms| 1.00|
| DoNormalLoopByIn|352.4ms| 6.99ms|17.27ms| 0.42|
|DoReadOnlyLoopByIn|341.1ms| 6.69ms|10.02ms| 0.43|同样表明,使用in参数会得到更好的性能。
使用in参数需要注意的地方
我们来看一个例子,定义一个一般的结构体,包含一个属性Value和一个修改该属性的方法UpdateValue。然后在别的地方也定义一个方法UpdateMyNormalStruct来修改该结构体的属性Value。代码如下:
structMyNormalStruct { publicintValue{get;set;} publicvoidUpdateValue(intvalue) { Value=value; } } classProgram { staticvoidUpdateMyNormalStruct(MyNormalStructmyStruct) { myStruct.UpdateValue(8); } staticvoidMain(string[]args) { MyNormalStructmyStruct=newMyNormalStruct(); myStruct.UpdateValue(2); UpdateMyNormalStruct(myStruct); Console.WriteLine(myStruct.Value); } }您可以猜想一下它的运行结果是什么呢?2还是8?
我们来理一下,在Main中先调用了结构体自身的方法UpdateValue将Value修改为2,再调用Program中的方法UpdateMyNormalStruct,而该方法中又调用了MyNormalStruct结构体自身的方法UpdateValue,那么输出是不是应该是8呢?如果您这么想就错了。
它的正确输出结果是2,这是为什么呢?
这是因为,结构体和许多内置的简单类型(sbyte、byte、short、ushort、int、uint、long、ulong、char、float、double、decimal、bool和enum类型)一样,都是值类型,在传递参数的时候以值的方式传递。因此调用方法UpdateMyNormalStruct时传递的是myStruct变量的新副本,在此方法中,其实是此副本调用了UpdateValue方法,所以原变量myStruct的Value不会发生变化。
说到这里,有聪明的朋友可能会想,我们给UpdateMyNormalStruct方法的参数加上in修饰符,是不是输出结果就变为8了,in参数不就是引用传递吗?
我们可以试一下,把代码改成:
staticvoidUpdateMyNormalStruct(inMyNormalStructmyStruct) { myStruct.UpdateValue(8); } staticvoidMain(string[]args) { MyNormalStructmyStruct=newMyNormalStruct(); myStruct.UpdateValue(2); UpdateMyNormalStruct(inmyStruct); Console.WriteLine(myStruct.Value); }运行一下,您会发现,结果依然为2!这……就让人大跌眼镜了……
用工具查看一下UpdateMyNormalStruct方法的中间语言:
.methodprivatehidebysigstatic voidUpdateMyNormalStruct( [in]valuetypeConsoleApp4InTest.MyNormalStruct&myStruct )cilmanaged { .param[1] .custominstancevoid[System.Runtime]System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor()=( 01000000 ) //MethodbeginsatRVA0x2164 //Codesize18(0x12) .maxstack2 .localsinit( [0]valuetypeConsoleApp4InTest.MyNormalStruct ) IL_0000:nop IL_0001:ldarg.0 IL_0002:ldobjConsoleApp4InTest.MyNormalStruct IL_0007:stloc.0 IL_0008:ldloca.s0 IL_000a:ldc.i4.8 IL_000b:callinstancevoidConsoleApp4InTest.MyNormalStruct::UpdateValue(int32) IL_0010:nop IL_0011:ret }//endofmethodProgram::UpdateMyNormalStruct您会发现,在IL_0002、IL_0007和IL_0008这几行,仍然创建了一个MyNormalStruct结构体的防御性副本(defensivecopy)。虽然在调用方法UpdateMyNormalStruct时以引用的方式传递参数,但在方法体中调用结构体自身的UpdateValue前,却创建了一个该结构体的防御性副本,改变的是该副本的Value。这就有点奇怪了,不是吗?
Google了一些资料是这么解释的:C#无法知道当它调用一个结构体上的方法(或getter)时,是否也会修改它的值/状态。于是,它所做的就是创建所谓的“防御性副本”。当在结构体上运行方法(或getter)时,它会创建传入的结构体的副本,并在副本上运行方法。这意味着原始副本与传入时完全相同,调用者传入的值并没有被修改。
有没有办法让方法UpdateMyNormalStruct调用后输出8呢?您将参数改成ref修饰符试试:stuck_out_tongue_winking_eye::grin::joy:
综上所述,最好不要把in修饰符和一般(非只读)结构体一起使用,以免产生晦涩难懂的行为,而且可能对性能产生负面影响。
in参数的限制
不能将in、ref和out关键字用于以下几种方法:
- 异步方法,通过使用async修饰符定义。
- 迭代器方法,包括yieldreturn或yieldbreak语句。
- 扩展方法的第一个参数不能有in修饰符,除非该参数是结构体。
- 扩展方法的第一个参数,其中该参数是泛型类型(即使该类型被约束为结构体。)
总结
使用in参数,有助于明确表明此参数不可修改的意图。
当只读结构体(readonlystruct)的大小大于IntPtr.Size3时,出于性能原因,应将其作为in参数传递。
不要将一般(非只读)结构体作为in参数,因为结构体是可变的,反而有可能对性能产生负面影响,并且可能产生晦涩难懂的行为。
到此这篇关于C#中的in参数与性能分析的文章就介绍到这了,更多相关C#中in参数与性能内容请搜索毛票票以前的文章或继续浏览下面的相关文章希望大家以后多多支持毛票票!