执行上下文无法从异步方法顺着调用栈向上流动。

7

考虑以下代码:

private static async Task Main(string[] args)
{
    await SetValueInAsyncMethod();
    PrintValue();

    await SetValueInNonAsyncMethod();
    PrintValue();
}

private static readonly AsyncLocal<int> asyncLocal = new AsyncLocal<int>();

private static void PrintValue([CallerMemberName] string callingMemberName = "")
{
    Console.WriteLine($"{callingMemberName}: {asyncLocal.Value}");
}

private static async Task SetValueInAsyncMethod()
{
    asyncLocal.Value = 1;
    PrintValue();

    await Task.CompletedTask;
}

private static Task SetValueInNonAsyncMethod()
{
    asyncLocal.Value = 2;
    PrintValue();

    return Task.CompletedTask;
}

如果您在.NET 4.7.2控制台应用程序中运行此代码,您将获得以下输出:
SetValueInAsyncMethod: 1
Main: 0
SetValueInNonAsyncMethod: 2
Main: 2

我理解造成输出差异的原因是SetValueInAsyncMethod不是一个真正的方法,而是由AsyncTaskMethodBuilder执行的状态机,它在内部捕获了ExecutionContext,而SetValueInNonAsyncMethod只是一个常规方法。

但即使有这种理解,我仍然有一些问题:

  1. 这是一个bug/缺失的功能还是有意的设计决策?
  2. 在编写依赖于AsyncLocal的代码时,我需要担心这种行为吗?比如说,我想编写自己的TransactionScope,通过await点传递一些环境数据。这里是否足够使用AsyncLocal
  3. 在.NET中,当涉及到在“逻辑代码流”中保留值时,除了AsyncLocalCallContext.LogicalGetData/CallContext.LogicalSetData外,还有其他替代方案吗?
2个回答

7
这是一个故意的设计决策。具体地,async状态机为其逻辑上下文设置了“写时复制”标志。
与此相关的是,所有同步方法都属于它们最接近的祖先async方法。
大多数这样的系统使用AsyncLocal结合可清除AsyncLocal值的IDisposable模式。组合这些模式确保它将在同步或异步代码中工作。如果消费代码是async方法,则AsyncLocal本身就可以很好地工作;使用IDisposable使它能够与异步和同步方法一起使用。
没有其他替代方案在.NET中用于保留沿着“逻辑代码流”中的值。

如果您不介意的话,有人能否详细解释一下这句话的含义:“使用IDisposable可以确保它能够与异步和同步方法一起正常工作”。 - Ostati
@Ostati:从同步方法设置AsyncLocal<T>.Value实际上会更改向上遍历调用堆栈的第一个异步方法的值。因此,如果AAsync()调用设置asyncLocal1.ValueB(),那么AAsync()将看到该更新的值。使用IDisposable可以为您提供显式范围,在该范围内存在AsyncLocal<T>.Value更新,并且在可处置对象被处理时重置为其先前的值。 - Stephen Cleary
我仍然无法完全理解这种情况。您能否请看一下这个问题?https://dev59.com/r7_qa4cB1Zd3GeqPOsO1 - Ostati

7

在我看来,这似乎是一个有意的决定。

正如你所知,SetValueInAsyncMethod被编译成了一个状态机,隐式地捕获当前的ExecutionContext。当你改变AsyncLocal变量时,这个改变不会"流回"到调用函数中。相比之下,SetValueInNonAsyncMethod不是异步的,因此它不会被编译成状态机。因此,ExecutionContext没有被捕获,对AsyncLocal变量的任何更改对调用者都是可见的。

如果你需要,也可以自己捕获ExecutionContext:

private static Task SetValueInNonAsyncMethodWithEC()
{
    var ec = ExecutionContext.Capture(); // Capture current context into ec
    ExecutionContext.Run(ec, _ => // Use ec to run the lambda
    {
        asyncLocal.Value = 3;
        PrintValue();
    });
    return Task.CompletedTask;
}

这将输出一个值为3,而主函数将输出2。

当然,最简单的方法是将SetValueInNonAsyncMethod转换为异步方法,让编译器为您完成这项工作。

关于使用AsyncLocal(或者CallContext.LogicalGetData)的代码,重要的是要知道,在被调用的异步方法中更改值(或任何已捕获的ExecutionContext)将不会“回流”。但是,只要不重新分配它,仍然可以访问和修改AsyncLocal


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接