作者:张富春(ahfuzhang),转载时请注明作者和引用链接,谢谢!


先看代码:

    public async ValueTask<Error> RunAsync()
    {
        ref readonly var req = ref this.Request;
        ref var rsp = ref this.Response;
        // ref var 不能跨越 async 方法中的 await 边界使用。
        //
        (DbConnection? db, Error err) = await DbPool.Pool!.GetAsync();
        if (err.Err())
        {
            rsp.Code = 1;
            rsp.Message = err.Message;
            return default;
        }
        using var _ = db;
        var (obj, err1) = await db!.ExecuteScalarAsync(
            sql:"select count(1) from menus where menu_id>0",
            parameters: null,
            bindFunc: (MySqlCommand cmd) =>
            {
                return default;
            }
        );
        if (err1.Err())
        {
            Response.Code = 2;
            Response.Message = err1.Message;
            return default;
        }
        Response.Message = obj?.ToString() ?? string.Empty;

         // 如果按照下面这行代码的写法,就会抛异常
        // rsp.Message = obj?.ToString() ?? string.Empty;
        // 抛出异常:Object reference not set to an instance of an object.
        return default;
    }

我认为这个问题的根本还是编译器不够强大。
编译器完全有能力做到:ref var rsp 只是一个编译前的语法糖,而编译后,真正产生作用的是 Response.
跨 await 调用后, 居然就自动把 ref var 设置为 null,这实在太离谱了!如果某个分支我没有注意到,可能就会导致线上问题。

没办法了,只能以后自己小心。这个特性,成功把 C# 提升到 C++ 一样恶心的高度。
使用者必须自己了解这些缺陷,否则不小心就会出问题。


以下是来自 ChatGPT 的详细的规则解释:

提示词:请解释一下 C# 中 "ref var 不能跨越 async 方法中的 await 边界使用。" 这条原则

这条原则的核心是:

ref var / ref local / ref struct 这类“指向某块现有内存”的变量,不能在 await 之后继续使用,因为 await 会把方法拆成状态机,而这些“引用到栈上或对象内部位置”的东西不能安全地保存进状态机。

  1. 先看一个不允许的例子
async Task Foo()
{
    int x = 10;
    ref int r = ref x;
    await Task.Delay(1000);
    r = 20; // 不允许
}

编译器会报类似错误:

Cannot use ref local 'r' inside an anonymous method, lambda expression, query expression, or async method

或者类似“不能在 async 方法中跨 await 使用 ref local”的错误。

原因不是 r = 20 本身有问题,而是:

ref int r = ref x;

这里的 r 本质上不是一个普通变量,它是一个别名,直接指向 x 的存储位置。

  1. await 会把 async 方法拆成状态机

这个 async 方法:

async Task Foo()
{
    int x = 10;
    await Task.Delay(1000);
    Console.WriteLine(x);
}

编译器大致会改写成一个状态机对象,类似:

class FooStateMachine
{
    public int state;
    public AsyncTaskMethodBuilder builder;
    public int x;
    public void MoveNext()
    {
        // await 前后的代码会被拆开执行
    }
}

也就是说,await 前后的局部变量,如果后面还要用,通常会被“提升”到状态机字段里。

普通变量可以这么做:

int x;

可以变成:

public int x;

但是 ref int r 就麻烦了。

  1. 为什么 ref var 不能保存进状态机?

ref int r 表示:

r 是某个 int 存储位置的引用

它可能引用:

ref int r = ref array[0];
ref int r = ref span[0];
ref int r = ref someStruct.Field;
ref int r = ref localVariable;

这些引用有几个问题:

问题一:它可能指向栈上的变量

例如:

int x = 10;
ref int r = ref x;

如果 await 之后方法挂起,当前栈帧可能已经没了。r 如果还存在,就可能指向已经失效的栈内存。

C# 不允许这种悬空引用。

问题二:状态机是堆对象,不能安全保存 managed reference

async 状态机通常会被放到堆上,因为它要在 await 之后恢复执行。

但 ref int 这种东西不是普通对象引用,它是一个 managed pointer / byref。CLR 对这类引用有严格限制,不能随便作为对象字段长期保存。

否则 GC 移动对象、栈帧消失、对象生命周期变化时,都可能导致引用不安全。

问题三:ref 的生命周期必须非常短

ref 的设计目标是高性能、低开销地直接访问某块内存,但它的安全前提是:

编译器能清楚证明这个引用不会活得比被引用的东西更久。

await 会打断这个证明。

因为执行流程变成了:

执行 await 前代码
挂起方法
稍后恢复方法
继续执行 await 后代码

这中间变量的生命周期、栈帧、状态机位置都发生了变化。

  1. 正确理解:“不能跨 await”,不是“async 方法里完全不能用 ref var”

重点是不能跨越 await 边界使用。

比如这种通常是可以理解为安全模式的:

async Task Foo()
{
    int[] arr = { 1, 2, 3 };
    ref int r = ref arr[0];
    r = 100;
    await Task.Delay(1000);
    Console.WriteLine(arr[0]);
}

这里 r 只在 await 之前使用,await 之后不用 r,逻辑上是安全的。

但是这种不行:

async Task Foo()
{
    int[] arr = { 1, 2, 3 };
    ref int r = ref arr[0];
    await Task.Delay(1000);
    r = 100; // 不允许
}

因为 r 活到了 await 之后。

  1. Span 也是类似问题

Span 是 ref struct,也不能跨 await:

async Task Foo()
{
    Span<int> span = stackalloc int[10];
    await Task.Delay(1000);
    span[0] = 1; // 不允许
}

原因类似:Span 里面也包含对内存的 byref 引用,它不能被提升到 async 状态机里。

  1. 怎么改?

方案一:在 await 之前完成 ref 操作

async Task Foo()
{
    int[] arr = { 1, 2, 3 };
    ref int r = ref arr[0];
    r = 100;
    await Task.Delay(1000);
    Console.WriteLine(arr[0]);
}

方案二:await 之后重新获取 ref

async Task Foo()
{
    int[] arr = { 1, 2, 3 };
    await Task.Delay(1000);
    ref int r = ref arr[0];
    r = 100;
}

也就是说,不要让 ref 变量本身跨过 await,但可以在 await 之后重新创建一个新的 ref。

方案三:保存索引,不保存 ref

错误思路:

ref int r = ref arr[index];
await SomethingAsync();
r = 100;

正确做法:

int savedIndex = index;
await SomethingAsync();
arr[savedIndex] = 100;

保存普通值,如 index、key、offset,而不是保存 ref。

方案四:把同步的 ref 操作拆出去

static void Modify(ref int value)
{
    value = 100;
}
async Task Foo()
{
    int[] arr = { 1, 2, 3 };
    await Task.Delay(1000);
    Modify(ref arr[0]);
}

这里 ref 只存在于同步方法调用期间,没有跨越 await。

  1. 本质总结

可以把这条规则理解成:

await 会把方法拆开,并把需要保留的局部变量搬到状态机里;
但 ref var 是“指向某个内存位置的临时引用”,不能被安全地搬到状态机里;
所以 ref var 不能活过 await。

一句话版:

ref var 是短生命周期的内存别名;await 会延长局部变量生命周期并改变执行上下文;两者不能混用跨边界。