C#中的in参数与性能分析详解

前言

in 修饰符也是从 C# 7.2 开始引入的,它与我们上一篇中讨论的 《C# 中的只读结构体(readonly struct)》1 是紧密相关的。

in 修饰符

in 修饰符通过引用传递参数。 它让形参成为实参的别名,即对形参执行的任何操作都是对实参执行的。 它类似于 ref 或 out 关键字,不同之处在于 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
{
 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 中先调用了结构体自身的方法 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 参数不就是引用传递吗?

我们可以试一下,把代码改成:

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_0002、IL_0007 和 IL_0008 这几行,仍然创建了一个 MyNormalStruct 结构体的防御性副本(defensive copy)。虽然在调用方法 UpdateMyNormalStruct 时以引用的方式传递参数,但在方法体中调用结构体自身的 UpdateValue 前,却创建了一个该结构体的防御性副本,改变的是该副本的 Value。这就有点奇怪了,不是吗?

Google 了一些资料是这么解释的:C# 无法知道当它调用一个结构体上的方法(或getter)时,是否也会修改它的值/状态。于是,它所做的就是创建所谓的“防御性副本”。当在结构体上运行方法(或getter)时,它会创建传入的结构体的副本,并在副本上运行方法。这意味着原始副本与传入时完全相同,调用者传入的值并没有被修改。

有没有办法让方法 UpdateMyNormalStruct 调用后输出 8 呢?您将参数改成 ref 修饰符试试 :stuck_out_tongue_winking_eye: :grin: :joy:

综上所述,最好不要把 in 修饰符和一般(非只读)结构体一起使用,以免产生晦涩难懂的行为,而且可能对性能产生负面影响。

in 参数的限制

不能将 in、ref 和 out 关键字用于以下几种方法:

  • 异步方法,通过使用 async 修饰符定义。
  • 迭代器方法,包括 yield return 或 yield break 语句。
  • 扩展方法的第一个参数不能有 in 修饰符,除非该参数是结构体。
  • 扩展方法的第一个参数,其中该参数是泛型类型(即使该类型被约束为结构体。)

总结

使用 in 参数,有助于明确表明此参数不可修改的意图。

当只读结构体(readonly struct)的大小大于 IntPtr.Size 3 时,出于性能原因,应将其作为 in 参数传递。

不要将一般(非只读)结构体作为 in 参数,因为结构体是可变的,反而有可能对性能产生负面影响,并且可能产生晦涩难懂的行为。

到此这篇关于C#中的in参数与性能分析的文章就介绍到这了,更多相关C#中in参数与性能内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • C#中的in参数与性能分析详解

    前言 in 修饰符也是从 C# 7.2 开始引入的,它与我们上一篇中讨论的 <C# 中的只读结构体(readonly struct)>1 是紧密相关的. in 修饰符 in 修饰符通过引用传递参数. 它让形参成为实参的别名,即对形参执行的任何操作都是对实参执行的. 它类似于 ref 或 out 关键字,不同之处在于 in 参数无法通过调用的方法进行修改. ref 修饰符,指定参数由引用传递,可以由调用方法读取或写入. out 修饰符,指定参数由引用传递,必须由调用方法写入. in 修饰符,指定

  • C++中构造函数的参数缺省的详解

    C++中构造函数的参数缺省的详解 前言: 构造函数中参数的值既可以通过实参传递,也可以指定为某些默认值,即如果用户不指定实参值,编译系统就使形参取默认值.在构造函数中也可以采用这样的方法来实现初始化. #include <iostream> using namespace std; class A { public : A(int aa=0,int bb=00); //在声明构造函数时指定默认参数 int volume( ); int a; int b; }; int main( ) { A

  • 对python3 中方法各种参数和返回值详解

    如下所示: # -*- coding:utf-8 -*- # Author: Evan Mi # 函数 def func1(): print('in the func1') return 0 # 过程 def func2(): print('in the func2') """ 多个值用逗号分割后返回,会分装到一个tuple中返回, 接收的时候,如果使用一个变量接收,那么这个接收变量就是一个tuple类型的 如果接收的时候也用逗号分割多个值来接收,那么可以分别对应返回tupl

  • 三种Golang数组拷贝方式及性能分析详解

    目录 测试 测试代码 测试结果 原理分析 copy append 总结 在Go语言中,我们可以使用for.append()和copy()进行数组拷贝,对于某些对性能比较敏感且数组拷贝比较多的场景,我们可以会对拷贝性能比较关注,这篇文件主要是对比一下这三种方式的性能. 测试 测试条件是把一个64KB的字节数组分为64个块进行复制. 测试代码 package test import ( "testing" ) const ( blocks = 64 blockSize = 1024 ) v

  • golang json性能分析详解

    前言 众所周知Json 作为一种重要的数据格式,具有良好的可读性以及自描述性,广泛地应用在各种数据传输场景中.Go 语言里面原生支持了这种数据格式的序列化以及反序列化,内部使用反射机制实现,性能有点差,在高度依赖 json 解析的应用里,往往会成为性能瓶颈,好在已有很多第三方库帮我们解决了这个问题,但是这么多库,对于像我这种有选择困难症的人来说,到底要怎么选择呢,下面就给大家来一一分析一下 ffjson go get -u github.com/pquerna/ffjson 原生的库性能比较差的

  • Vue中之nextTick函数源码分析详解

    1. 什么是Vue.nextTick()? 官方文档解释如下: 在下次DOM更新循环结束之后执行的延迟回调.在修改数据之后立即使用这个方法,获取更新后的DOM. 2. 为什么要使用nextTick? <!DOCTYPE html> <html> <head> <title>演示Vue</title> <script src="https://tugenhua0707.github.io/vue/vue1/vue.js"&

  • ES6中的rest参数与扩展运算符详解

    前言 本文主要给大家介绍了关于ES6中rest参数与扩展运算符的相关内容,rest参数和扩展运算符都是ES6新增的特性.rest参数的形式为:...变量名:扩展运算符是三个点(...).下面话不多说了,来一起看看详细的介绍: rest参数 rest参数用于获取函数的多余参数,这样就不需要使用arguments对象了.rest参数搭配的变量是一个数组,该变量将多余的参数放入数组中. function add(...values) { let sum = 0; for (var val of val

  • java字符串拼接与性能分析详解

    假设有一个字符串,我们将对这个字符串做大量循环拼接操作,使用"+"的话将得到最低的性能.但是究竟这个性能有多差?如果我们同时也把StringBuffer,StringBuilder或String.concat()放入性能测试中,结果又会如何呢?本文将会就这些问题给出一个答案! 我们将使用Per4j来计算性能,因为这个工具可以给我们一个完整的性能指标集合,比如最小,最大耗时,统计时间段的标准偏差等.在测试代码中,为了得到一个准确的标准偏差值,我们将执行20个拼接"*"

  • jQuery中通过ajax调用webservice传递数组参数的问题实例详解

    下面通过实例给大家说明比较直观些,更方便大家了解. 本人的项目中通过jquery.ajax调用webservice. 客户端代码如下: $.ajax({ url: "test/xxx.asmx", type: 'POST', dataType: 'xml', timeout: , data: { name: "zhangsan", tags: ["aa", "bb", "cc"] }, error: fun

  • Java中注解与原理分析详解

    目录 一.注解基础 二.注解原理 三.常用注解 1.JDK注解 2.Lombok注解 四.自定义注解 1.同步控制 2.类型引擎 一.注解基础 注解即标注与解析,在Java的代码工程中,注解的使用几乎是无处不在,甚至多到被忽视: 无论是在JDK源码或者框架组件,都在使用注解能力完成各种识别和解析动作:在对系统功能封装时,也会依赖注解能力简化各种逻辑的重复实现: 基础接口 在Annotation的源码注释中有说明:所有的注解类型都需要继承该公共接口,本质上看注解是接口,但是代码并没有显式声明继承关

随机推荐