Csharp/C#教程: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<loops;i++) { result=Compute(normalInstance); } returnresult; } [Benchmark] publicdecimalDoNormalLoopByIn() { decimalresult=0M; for(inti=0;i<loops;i++) { result=ComputeIn(innormalInstance); } returnresult; } [Benchmark] publicdecimalDoReadOnlyLoopByIn() { decimalresult=0M; for(inti=0;i<loops;i++) { result=ComputeIn(inreadOnlyInstance); } returnresult; } publicdecimalCompute(NormalStructs) { //业务逻辑 return0M; } publicdecimalComputeIn(inNormalStructs) { //业务逻辑 return0M; } publicdecimalComputeIn(inReadOnlyStructs) { //业务逻辑 return0M; } }

在没有使用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修饰符,除非该参数是结构体。 扩展方法的第一个参数,其中该参数是泛型类型(即使该类型被约束为结构体。)

上述就是C#学习教程:C#中的in参数与性能分析详解分享的全部内容,如果对大家有所用处且需要了解更多关于C#学习教程,希望大家多多关注—计算机技术网(www.ctvol.com)!

本文来自网络收集,不代表计算机技术网立场,如涉及侵权请联系管理员删除。

ctvol管理联系方式QQ:251552304

本文章地址:https://www.ctvol.com/cdevelopment/903737.html

(0)
上一篇 2021年10月21日
下一篇 2021年10月21日

精彩推荐