readonly 修饰符在值类型和引用类型之间的表现有点不同。对于引用类型的 readonly 字段,编译器只保证它在构造方法之外不能重新指定,即不能再通过 a = xxx 来重新设定引用,不会管它内部进行了什么改变。而对于值类型的 readonly 字段,则意味着在实例的整个生命周期中,所有它的内部值都不会变化。为了避免潜在的变化,对于 readonly 的值类型字段,编译器每次调用方法或属性之前都会进行防御性复制。防御性复制带来可观的性能开销。
private FairlyLargeStruct _nonReadOnlyStruct = new FairlyLargeStruct(42);
private readonly FairlyLargeStruct _readOnlyStruct = new FairlyLargeStruct(42);
private readonly int[] _data = Enumerable.Range(1, 100_000).ToArray();
[Benchmark]
public int AggregateForNonReadOnlyField()
{
int result = 0;
foreach (int n in _data)
result += n + _nonReadOnlyStruct.N;
return result;
}
[Benchmark]
public int AggregateForReadOnlyField()
{
int result = 0;
foreach (int n in _data)
result += n + _readOnlyStruct.N;
return result;
}
Method | Mean | Error | StdDev |
----------------------------- |----------:|---------:|----------:|
AggregateForNonReadOnlyField | 87.92 us | 1.800 us | 3.677 us |
AggregateForReadOnlyField | 148.29 us | 4.226 us | 12.460 us |
仅仅多了一个 readonly 修饰符就造成了大量的性能损失。
解决这个问题至少有三个办法:
readonly 修饰值类型。readonly structpublic readonly struct FairlyLargeStruct
{
private readonly long l1, l2, l3, l4;
public int N { get; }
public FairlyLargeStruct(int n) : this() => N = n;
}
readonly structC# 7.2 允许通过 readonly struct 表明值类型的 immutable,不但对性能有好处,而且能够更明确表示一种不可变的观点:值是 immutable 的。(不过可以通过某些肮脏的反射操作来破坏。)
readonly struct 强制如下行为:
public int Foo {get; private set;} 这种就不是只读的)readonly 值类型字段之类的情况。对于 readonly struct FairlyLargeStruct 的基准结果如下:
Method | Mean | Error | StdDev |
----------------------------- |---------:|---------:|---------:|
AggregateForNonReadOnlyField | 91.19 us | 1.811 us | 2.597 us |
AggregateForReadOnlyField | 89.25 us | 1.775 us | 3.705 us |
之前 C# 有三种传参方式:值传递,传引用(ref),输出参数(out),实际上在内部 ref 和 out 是一样的。
C# 7.2 带来了新的传参方式:in 修饰符。in 的语义是只读的引用,在底下,参数被当作用System.Runtime.CompilerServices.IsReadOnlyAttribute修饰的引用传递。编译器确保在方法中不会修改这个参数,而且对于 in 修饰的 struct,编译器还保证不能向它的字段赋值,也就是说 in 的只读修饰对于 struct 的影响是深度的。
public void Foo(in string s)
{
// Cannot assign to variable 'in string' because it is a readonly variable
s = string.Empty;
}
不能用重载区分 in、ref 和 out,它们本质上是一样的
不能将这三个用于迭代器和 async 方法
可以将 using 块的变量通过 in 传递,即使不能通过 ref 和 out 传递。因为通过 in 传递是安全的,编译器去除了这个限制。
struct Disposable : IDisposable
{
public void Dispose() { }
}
public void DisposableSample()
{
using (var d = new Disposable())
{
// Ok
ByIn(d);
// Cannot use 'd' as a ref or out value because it is a 'using variable'
//ByRef(ref d);
}
void ByRef(ref Disposable disposable) { }
void ByIn(in Disposable disposable) { }
in 参数可以有默认值,ref 和 out 不行。public int ByIn(in string s = "") => s.Length;
in 修饰符的不同的重载。public int Foo(in string s) => s.Length;
public int Foo(string s) => s.Length;
在有这样重载实现的时候,在 C# 7.2 中 Foo(s) 调用的是 Foo(in string s),而在 C# 7.3 之后,调用的是 Foo(string s),看起来是 C# 7.2 的实现存在语义上的问题。
但对于不是重载的情况,只有 in 实现的情况下,对于调用方,in 参数的 in 修饰符是可选的,因为它对于调用方的语义保证是不变的。这样对于 API 的提供方很方便,可以将自己的实现改成通过 in 参数传入大结构,而无需自己的所有调用方都修改自己的调用代码,就可以获得性能的提高。不过不能通过 in 字面量的方式进行调用。
public int ByIn(in string s) => s.Length;
string s = string.Empty;
ByIn(in s); // Works fine
ByIn(s); // Works fine as well!
// Fail?!?! An expression cannot be used in this context because it may not be passed or returned by reference
ByIn(in "some string");
ByIn("some string"); // Works fine!
in 修饰符的性能特性in 参数跟 readonly 字段相当类似,为了避免破坏 struct 的 readonly/in 语义,在调用 struct 的属性和方法之前,编译器会做一次防御性复制,从而导致性能降低。因此,绝不应该通过 in 来传递非 readonly struct 结构体!非 readonly struct 通过 in 传递常常导致频繁的防御性复制,让性能变得更糟。
public struct FairlyLargeStruct
{
private readonly long l1, l2, l3, l4;
public int N { get; }
public FairlyLargeStruct(int n) : this() => N = n;
}
private readonly int[] _data = Enumerable.Range(1, 100_000).ToArray();
[Benchmark]
public int AggregatePassedByValue()
{
return DoAggregate(new FairlyLargeStruct(42));
int DoAggregate(FairlyLargeStruct largeStruct)
{
int result = 0;
foreach (int n in _data)
result += n + largeStruct.N;
return result;
}
}
[Benchmark]
public int AggregatePassedByIn()
{
return DoAggregate(new FairlyLargeStruct(42));
int DoAggregate(in FairlyLargeStruct largeStruct)
{
int result = 0;
foreach (int n in _data)
result += n + largeStruct.N;
return result;
}
}
结果
Method | Mean | Error | StdDev |
----------------------- |----------:|----------:|----------:|
AggregatePassedByValue | 71.24 us | 0.3150 us | 0.2278 us |
AggregatePassedByIn | 124.02 us | 3.2885 us | 9.6963 us |
readonly struct 对于设计和性能角度都很有用。IntPtr.Size 大,应该通过 in 来传递获得性能提高。in 来传递引用类型,让自己的设计意图更清晰。(其实也无所谓)in 来传递非 readonly struct,因为对性能会造成负面的影响,而且常常是不容易发觉的。in 对引用类型和基本的数字类型也可以应用,但基本上没什么特别的作用。ref readonly 方法返回的 struct 引用,上面会导致防御性复制的内容仍然成立。因此最好也结合 readonly struct 使用。可以使用 ErrorProne.NET 来检查和避免 struct 和 readonly/in 相关的性能问题。
参考:
The ‘in’-modifier and the readonly structs in C#
Reference semantics with value types
Performance traps of ref locals and ref returns in C#
C# 8.0 中引入了更加细致的 Readonly Member 修饰符,可以更加细粒度地控制被修饰的方法和属性不会导致 struct 状态的改变,譬如可以将它用在一般不会导致状态改变的 ToString() 上等等。这个特性的引入,可以很大程度上减轻我们一开始说的由于防御性复制对 readonly 的 struct 性能的影响。编译器会保证声明为 readonly 的 member 的只读性,从而在调用这些 member 时,不再需要防御性复制。