前言

C# 11 中即将到来一个可以让重视性能的开发者狂喜的重量级特性,这个特性主要是围绕着一个重要底层性能设施 refstruct 的一系列改进。

但是这部分的改进涉及的内容较多,不一定能在 .NET 7(C# 11)做完,因此部分内容推迟到 C# 12 也是有可能的。当然,还是很有希望能在 C# 11 的时间点就看到完全体的。

本文仅仅就这一个特性进行介绍,因为 C# 11 除了本特性之外,还有很多其他的改进,一篇文章根本说不完,其他那些我们就等到 .NET 7 快正式发布的时候再说吧。

背景

C# 自 7.0 版本引入了新的 ref struct 用来表示不可被装箱的栈上对象,但是当时局限性很大,甚至无法被用于泛型约束,也无法作为 struct 的字段。在 C# 11 中,由于特性 ref 字段的推动,需要允许类型持有其它值类型的引用,这方面的东西终于有了大幅度进展。

这些设施旨在允许开发者使用安全的代码编写高性能代码,而无需面对不安全的指针。接下来我就来对 C# 11 甚至 12 在此方面的即将到来的改进进行介绍。

ref 字段

C# 以前是不能在类型中持有对其它值类型的引用的,但是在 C# 11 中,这将变得可能。从 C# 11 开始,将允许 ref struct 定义 ref 字段。

readonly ref struct Span<T>
{
private readonly ref T _field;
private readonly int _length;
public Span(ref T value)
{
_field = ref value;
_length = 1;
}
}

直观来看,这样的特性将允许我们写出上面的代码,这段代码中构造了一个 Span<T>,它持有了对其他 T 对象的引用。

当然,ref struct 也是可以被 default 来初始化的:

Span<int> span = default;

但这样 _field 就会是个空引用,不过我们可以通过 Unsafe.IsNullRef 方法来进行检查:

if (Unsafe.IsNullRef(ref _field))
{
throw new NullReferenceException(...);
}

另外,ref字段的可修改性也是一个非常重要的事情,因此引入了:

  • readonly ref:一个对对象的只读引用,这个引用本身不能在构造方法或 init 方法之外被修改
  • ref readonly:一个对只读对象的引用,这个引用指向的对象不能在构造方法或 init 方法之外被修改
  • readonly ref readonly:一个对只读对象的只读引用,是上述两种的组合

例如:

ref struct Foo
{
ref readonly int f1;
readonly ref int f2;
readonly ref readonly int f3; void Bar(int[] array)
{
f1 = ref array[0]; // 没问题
f1 = array[0]; // 错误,因为 f1 引用的值不能被修改
f2 = ref array[0]; // 错误,因为 f2 本身不能被修改
f2 = array[0]; // 没问题
f3 = ref array[0]; // 错误:因为 f3 本身不能被修改
f3 = array[0]; // 错误:因为 f3 引用的值不能被修改
}
}

生命周期

这一切看上去都很美好,但是真的没有任何问题吗?

假设我们有下面的代码来使用上面的东西:

Span<int> Foo()
{
int v = 42;
return new Span<int>(ref v);
}

v 是一个局部变量,在函数返回之后其生命周期就会结束,那么上面这段代码就会导致 Span<int> 持有的 v 的引用变成无效的。顺带一提,上面这段代码是完全合法的,因为 C# 之前不支持 ref 字段,因此上面的代码是不可能出现逃逸问题的。但是 C# 11 加入了 ref 字段,栈上的对象就有可能通过 ref 字段而发生引用逃逸,于是代码变得不安全。

如果我们有一个 CreateSpan 方法用来创建一个引用的 Span

Span<int> CreateSpan(ref int v)
{
// ...
}

这就衍生出了一系列在以前的 C# 中没问题(因为 ref 的生命周期为当前方法),但是在 C# 11 中由于可能存在 ref 字段而导致用安全的方式写出的非安全代码:

Span<int> Foo(int v)
{
// 1
return CreateSpan(ref v); // 2
int local = 42;
return CreateSpan(ref local); // 3
Span<int> span = stackalloc int[42];
return CreateSpan(ref span[0]);
}

因此,在 C# 11 中则不得不引入破坏性更改,不允许上述代码通过编译。但这并没有完全解决问题。

为了解决逃逸问题, C# 11 制定了引用逃逸安全规则。对于一个在 e 中的字段 f

  • 如果 f 是个 ref 字段,并且 ethis,则 f 在它被包围的方法中是引用逃逸安全的
  • 否则如果 f 是个 ref 字段,则 f 的引用逃逸安全范围和 e 的逃逸安全范围相同
  • 否则如果 e 是一个引用类型,则 f 的引用逃逸安全范围是调用它的方法
  • 否则 f 的引用逃逸安全范围和 e 相同

由于 C# 中的方法是可以返回引用的,因此根据上面的规则,一个 ref struct 中的方法将不能返回一个对非 ref 字段的引用:

ref struct Foo
{
private ref int _f1;
private int f2; public ref int P1 => ref _f1; // 没问题
public ref int P2 => ref _f2; // 错误,因为违反了第四条规则
}

除了引用逃逸安全规则之外,同样还有对 ref 赋值的规则:

  • 对于 x.e1 = ref e2, 其中 x 是在调用方法中逃逸安全的,那么 e2 必须在调用方法中是引用逃逸安全的
  • 对于 e1 = ref e2,其中 e1 是个局部变量,那么 e2 的引用逃逸安全范围必须至少和 e1 的引用逃逸安全范围一样大

于是, 根据上述规则,下面的代码是没问题的:

readonly ref struct Span<T>
{
readonly ref T _field;
readonly int _length; public Span(ref T value)
{
// 没问题,因为 x 是 this,this 的逃逸安全范围和 value 的引用逃逸安全范围都是调用方法,满足规则 1
_field = ref value;
_length = 1;
}
}

于是很自然的,就需要在字段和参数上对生命周期进行标注,帮助编译器确定对象的逃逸范围。

而我们在写代码的时候,并不需要记住以上这么多的规则,因为有了生命周期标注之后一切都变得显式和直观了。

scoped

在 C# 11 中,引入了 scoped 关键字用来限制逃逸安全范围:

局部变量 s 引用逃逸安全范围 逃逸安全范围
Span<int> s 当前方法 调用方法
scoped Span<int> s 当前方法 当前方法
ref Span<int> s 调用方法 调用方法
scoped ref Span<int> s 当前方法 调用方法
ref scoped Span<int> s 当前方法 当前方法
scoped ref scoped Span<int> s 当前方法 当前方法

其中,scoped ref scoped 是多余的,因为它可以被 ref scoped 隐含。而我们只需要知道 scoped 是用来把逃逸范围限制到当前方法的即可,是不是非常简单?

如此一来,我们就可以对参数进行逃逸范围(生命周期)的标注:

Span<int> CreateSpan(scoped ref int v)
{
// ...
}

然后,之前的代码就变得没问题了,因为都是 scoped ref

Span<int> Foo(int v)
{
// 1
return CreateSpan(ref v); // 2
int local = 42;
return CreateSpan(ref local); // 3
Span<int> span = stackalloc int[42];
return CreateSpan(ref span[0]);
}

scoped 同样可以被用在局部变量上:

Span<int> Foo()
{
// 错误,因为 span 不能逃逸当前方法
scoped Span<int> span1 = default;
return span1; // 没问题,因为初始化器的逃逸安全范围是调用方法,因为 span2 可以逃逸到调用方法
Span<int> span2 = default;
return span2; // span3 和 span4 是一样的,因为初始化器的逃逸安全范围是当前方法,加不加 scoped 都没区别
Span<int> span3 = stackalloc int[42];
scoped Span<int> span4 = stackalloc int[42];
}

另外,structthis 也加上了 scoped ref 的逃逸范围,即引用逃逸安全范围为当前方法,而逃逸安全范围为调用方法。

剩下的就是和 outin 参数的配合,在 C# 11 中,out 参数将会默认为 scoped ref,而 in 参数仍然保持默认为 ref

ref int Foo(out int r)
{
r = 42;
return ref r; // 错误,因为 r 的引用逃逸安全范围是当前方法
}

这非常有用,例如比如下面这个常见的情况:

Span<byte> Read(Span<byte> buffer, out int read)
{
// ..
} Span<int> Use()
{
var buffer = new byte[256]; // 如果不修改 out 的引用逃逸安全范围,则这会报错,因为编译器需要考虑 read 是可以被作为 ref 字段返回的情况
// 如果修改 out 的引用逃逸安全范围,则就没有问题了,因为编译器不需要考虑 read 是可以被作为 ref 字段返回的情况
int read;
return Read(buffer, out read);
}

下面给出一些更多的例子:

Span<int> CreateWithoutCapture(scoped ref int value)
{
// 错误,因为 value 的引用逃逸安全范围是当前方法
return new Span<int>(ref value);
} Span<int> CreateAndCapture(ref int value)
{
// 没问题,因为 value 的逃逸安全范围被限制为 value 的引用逃逸安全范围,这个范围是调用方法
return new Span<int>(ref value)
} Span<int> ComplexScopedRefExample(scoped ref Span<int> span)
{
// 没问题,因为 span 的逃逸安全范围是调用方法
return span; // 没问题,因为 refLocal 的引用逃逸安全范围是当前方法、逃逸安全范围是调用方法
// 在 ComplexScopedRefExample 的调用中它被传递给了一个 scoped ref 参数,
// 意味着编译器在计算生命周期时不需要考虑引用逃逸安全范围,只需要考虑逃逸安全范围
// 因此它返回的值的安全逃逸范围为调用方法
Span<int> local = default;
ref Span<int> refLocal = ref local;
return ComplexScopedRefExample(ref refLocal); // 错误,因为 stackLocal 的引用逃逸安全范围、逃逸安全范围都是当前方法
// 在 ComplexScopedRefExample 的调用中它被传递给了一个 scoped ref 参数,
// 意味着编译器在计算生命周期时不需要考虑引用逃逸安全范围,只需要考虑逃逸安全范围
// 因此它返回的值的安全逃逸范围为当前方法
Span<int> stackLocal = stackalloc int[42];
return ComplexScopedRefExample(ref stackLocal);
}

unscoped

上述的设计中,仍然有个问题没有被解决:

struct S
{
int _field; // 错误,因为 this 的引用逃逸安全范围是当前方法
public ref int Prop => ref _field;
}

因此引入一个 unscoped,允许扩展逃逸范围到调用方法上,于是,上面的方法可以改写为:

struct S
{
private int _field; // 没问题,引用逃逸安全范围被扩展到了调用方法
public unscoped ref int Prop => ref _field;
}

这个 unscoped 也可以直接放到 struct 上:

unscoped struct S
{
private int _field;
public unscoped ref int Prop => ref _field;
}

同理,嵌套的 struct 也没有问题:

unscoped struct Child
{
int _value;
public ref int Value => ref _value;
} unscoped struct Container
{
Child _child;
public ref int Value => ref _child.Value;
}

此外,如果需要恢复以前的 out 逃逸范围的话,也可以在 out 参数上指定 unscoped

ref int Foo(unscoped out int r)
{
r = 42;
return ref r;
}

不过有关 unscoped 的设计还属于初步阶段,不会在 C# 11 中就提供。

ref struct 约束

从 C# 11 开始,ref struct 可以作为泛型约束了,因此可以编写如下方法了:

void Foo<T>(T v) where T : ref struct
{
// ...
}

因此,Span<T> 的功能也被扩展,可以声明 Span<Span<T>> 了,比如用在 byte 或者 char 上,就可以用来做高性能的字符串处理了。

反射

有了上面那么多东西,反射自然也是要支持的。因此,反射 API 也加入了 ref struct 相关的支持。

实际用例

有了以上基础设施之后,我们就可以使用安全代码来造一些高性能轮子了。

栈上定长列表

struct FrugalList<T>
{
private T _item0;
private T _item1;
private T _item2; public readonly int Count = 3; public unscoped ref T this[int index] => index switch
{
0 => ref _item1,
1 => ref _item2,
2 => ref _item3,
_ => throw new OutOfRangeException("Out of range.")
};
}

栈上链表

ref struct StackLinkedListNode<T>
{
private T _value;
private ref StackLinkedListNode<T> _next; public T Value => _value;
public bool HasNext => !Unsafe.IsNullRef(ref _next);
public ref StackLinkedListNode<T> Next => HasNext ? ref _next : throw new InvalidOperationException("No next node."); public StackLinkedListNode(T value)
{
this = default;
_value = value;
} public StackLinkedListNode(T value, ref StackLinkedListNode<T> next)
{
_value = value;
_next = ref next;
}
}

除了这两个例子之外,其他的比如解析器和序列化器等等,例如 Utf8JsonReaderUtf8JsonWriter 都可以用到这些东西。

未来计划

高级生命周期

上面的生命周期设计虽然能满足绝大多数使用,但是还是不够灵活,因此未来有可能在此基础上扩展,引入高级生命周期标注。例如:

void M(scoped<'a> ref MyStruct s, scoped<'b> Span<int> span) where 'b >= 'a
{
s.Span = span;
}

上面的方法给参数 sspan 分别声明了两个生命周期 'a'b,并约束 'b 的生命周期不小于 'a,因此在这个方法里,span 可以安全地被赋值给 s.Span

这个虽然不会被包含在 C# 11 中,但是如果以后开发者对相关的需求增长,是有可能被后续加入到 C# 中的。

总结

以上就是 C# 11(或之后)对 refstruct 的改进了。有了这些基础设施,开发者们将能轻松使用安全的方式来编写没有任何堆内存开销的高性能代码。尽管这些改进只能直接让小部分非常关注性能的开发者收益,但是这些改进带来的将是后续基础库代码质量和性能的整体提升。

如果你担心这会让语言的复杂度上升,那也大可不必,因为这些东西大多数人并不会用到,只会影响到小部分的开发者。因此对于大多数人而言,只需要写着原样的代码,享受其他基础库作者利用上述设施编写好的东西即可。

C# 11 对 ref 和 struct 的改进的更多相关文章

  1. C++11在时空性能方面的改进

    C++11在时空性能方面的改进 这篇我们聊聊C++11在时间和空间上的改进点: 主要包括以下方面: 新增的高效容器:array.forward_list以及unordered containers: ...

  2. C++11 std::ref使用场景

    C++本身有引用(&),为什么C++11又引入了std::ref(或者std::cref)? 主要是考虑函数式编程(如std::bind)在使用时,是对参数直接拷贝,而不是引用.如下例子: # ...

  3. C# 11 的新特性和改进前瞻

    前言 .NET 7 的开发还剩下一个多月就要进入 RC,C# 11 的新特性和改进也即将敲定.在这个时间点上,不少新特性都已经实现完毕并合并入主分支 C# 11 包含的新特性和改进非常多,类型系统相比 ...

  4. 深入理解C++11【3】

    [深入理解C++11[3]] 1.POD类型 Plain Old Data. Plain 表示 了POD是个普通的类型.C++11将POD划分为两个基本概念的合集: 1)平凡的(trivial) 2) ...

  5. C#高级编程第11版 - 第三章 索引

    [1]3.1 创建及使用类 1.构造函数:构造函数的名字与类名相同: 使用 new 表达式创建类的对象或者结构(例如int)时,会调用其构造函数.并且通常初始化新对象的数据成员. 除非类是静态的,否则 ...

  6. std::bind与std::ref, why and how

    首先解释下为什么有时候需要bind. 我们可以用bind从函数T add(T a, T b)造出个inc()来,即把b写死为1.这个例子本身比较傻,但有不傻的应用. template<typen ...

  7. .NET周报【11月第1期 2022-11-07】

    国内文章 开源·安全·赋能 - .NET Conf China 2022 https://mp.weixin.qq.com/s/_tYpfPeQgyEGsnR4vVLzHg .NET Conf Chi ...

  8. C++11 中值得关注的几大变化(网摘)

    C++11 中值得关注的几大变化(详解) 原文出处:[陈皓 coolshell] 源文章来自前C++标准委员会的 Danny Kalev 的 The Biggest Changes in C++11 ...

  9. Java 11 究竟比 8 快了多少?

    阅读本文大概需要 1.2 分钟. 作者:h4cd 来源:开源中国社区 开源规划调度引擎 OptaPlanner 官网发布了一个 Java 11 GC 性能基准测试报告. 当前使用量最大的 Java 版 ...

随机推荐

  1. 《前端运维》二、Nginx--4代理、负载均衡与其他

    一.代理服务 比较容易理解吧,简单来说.客户端访问服务器并不是直接访问的,而是通过中间代理服务器,代理服务器再去访问服务器.就像一个中转站一样,无论什么,只要从客户端到服务器,你就要通过我. 一)正向 ...

  2. leetcode刷题1--动态规划法回文串2

    题目是: Given a string s,partition s such that every substring of the partition is a palindrome Return ...

  3. 组合(n选k问题)

    #include "iostream.h" #include "string.h" int a[100]; void dfs(int n,int k) { if ...

  4. Tomcat启动时shell窗口乱码解决方法

    tomcat/conf/目录下,修改logging.properties java.util.logging.ConsoleHandler.encoding = utf-8 更改为 java.util ...

  5. BASH和DOS之间的基本区别是什么?

    BASH和DOS控制台之间的主要区别在于3个方面:1. BASH命令区分大小写,而DOS命令则不区分;2. 在BASH下,/ character是目录分隔符,\作为转义字符.在DOS下,/用作命令参数 ...

  6. sleep 和 wait 的区别?

    Sleep是休眠线程,wait是等待,sleep是thread的静态方法,wait则是object的方法. Sleep依旧持有锁,并在指定时间自动唤醒.wait则释放锁.

  7. 使用过 Redis 分布式锁么,它是什么回事?

    先拿 setnx 来争抢锁,抢到之后,再用 expire 给锁加一个过期时间防止锁忘记了 释放. 这时候对方会告诉你说你回答得不错,然后接着问如果在 setnx 之后执行 expire 之前进程意外  ...

  8. 简述 Memcached 内存管理机制原理?

    早期的 Memcached 内存管理方式是通过 malloc 的分配的内存,使用完后通过 free 来回收内存,这种方式容易产生内存碎片,并降低操作系统对内存的管理效 率.加重操作系统内存管理器的负担 ...

  9. 解释 AOP?

    面向切面的编程,或 AOP, 是一种编程技术,允许程序模块化横向切割关注点,或横切典型的责任划分,如日志和事务管理.

  10. 学习heartbeat-03t实现web服务的高可用案例及维护要点

    8.Heartbeat实现web服务的高可用案例 8.1部署准备 通过web服务高可用案例来熟悉heatbeat软件的使用,用上面的两台服务器机器名分别为heartbeat-1-130和heartbe ...