如何检查 AsyncLocal<T> 在同一个 "async context" 中被访问

How to check that AsyncLocal<T> is accessed within same "async context"

如果 Thread.CurrentThread 保持不变,

TL;DR ThreadLocal<T>.Value 指向相同的位置。 AsyncLocal<T>.Value 是否有类似的东西(例如 SychronizationContext.CurrentExecutionContext.Capture() 是否足以满足所有情况)?


假设我们已经创建了一些保存在线程本地存储(例如 ThreadLocal<T> 实例)中的数据结构快照,并将其传递给 axillary class 供以后使用。这个腋下class就是用来把这个数据结构恢复到快照状态的。我们不想将此快照恢复到不同的线程,因此我们可以检查创建了哪个线程 axillary class。例如:

class Storage<T>
{
    private ThreadLocal<ImmutableStack<T>> stackHolder;

    public IDisposable Push(T item)
    {
        var bookmark = new StorageBookmark<T>(this);
        stackHolder.Value = stackHolder.Value.Push(item);
        return bookmark;
    }

    private class StorageBookmark<TInner> :IDisposable
    {
        private Storage<TInner> owner;
        private ImmutableStack<TInner> snapshot;
        private Thread boundThread;

        public StorageBookmark(Storage<TInner> owner)
        { 
             this.owner = owner;
             this.snapshot = owner.stackHolder.Value;
             this.boundThread  = Thread.CurrentThread;
        }

        public void Dispose()
        {
             if(Thread.CurrentThread != boundThread) 
                 throw new InvalidOperationException ("Bookmark crossed thread boundary");
             owner.stackHolder.Value = snapshot;
        }
    }
}

有了这个,我们基本上将 StorageBookmark 绑定到特定线程,因此绑定到 ThreadLocal 存储中数据结构的特定版本。我们通过确保我们不会在 Thread.CurrentThread

的帮助下越过 "thread context" 来做到这一点 现在,手头的问题。我们如何使用 AsyncLocal<T> 而不是 ThreadLocal<T> 实现相同的行为?准确的说,有没有类似于Thread.CurrentThread的东西,在构造和使用的时候可以检查一下,控制"async context"没有被越过(也就是说AsyncLocal<T>.Value会指向同一个对象)书签创建时)。
似乎 SynchronizationContext.CurrentExecutionContext.Capture() 可能就足够了,但我不确定哪个更好并且没有问题(或者甚至可以在所有可能的情况下工作)

逻辑调用上下文与执行上下文具有相同的流语义,因此与 AsyncLocal 相同。知道这一点后,您可以在逻辑上下文中存储一个值以检测您何时跨越 "async context" 边界:

class Storage<T>
{
    private AsyncLocal<ImmutableStack<T>> stackHolder = new AsyncLocal<ImmutableStack<T>>();

    public IDisposable Push(T item)
    {
        var bookmark = new StorageBookmark<T>(this);

        stackHolder.Value = (stackHolder.Value ?? ImmutableStack<T>.Empty).Push(item);
        return bookmark;
    }

    private class StorageBookmark<TInner> : IDisposable
    {
        private Storage<TInner> owner;
        private ImmutableStack<TInner> snapshot;
        private Thread boundThread;
        private readonly object id;

        public StorageBookmark(Storage<TInner> owner)
        {
            id = new object();
            this.owner = owner;
            this.snapshot = owner.stackHolder.Value;
            CallContext.LogicalSetData("AsyncStorage", id);
        }

        public void Dispose()
        {
            if (CallContext.LogicalGetData("AsyncStorage") != id)
                throw new InvalidOperationException("Bookmark crossed async context boundary");
            owner.stackHolder.Value = snapshot;
        }
    }
}

public class Program
{
    static void Main()
    {
        DoesNotThrow().Wait();
        Throws().Wait();
    }

    static async Task DoesNotThrow()
    {
        var storage = new Storage<string>();

        using (storage.Push("hello"))
        {
            await Task.Yield();
        }
    }

    static async Task Throws()
    {
        var storage = new Storage<string>();

        var disposable = storage.Push("hello");

        using (ExecutionContext.SuppressFlow())
        {
            Task.Run(() => { disposable.Dispose(); }).Wait();
        }
    }
}

您希望做的事情从根本上违背了异步执行上下文的本质;您不需要(因此不能保证)在您的异步上下文中创建的所有任务都会立即等待,按照它们创建的相同顺序,或者根本不会等待,但它们的创建是在调用上下文的范围内使它们成为同一异步上下文的一部分,period。

将异步执行上下文视为不同于线程上下文可能具有挑战性,但异步并不等同于并行性,而并行性正是逻辑线程所支持的。存储在线程本地存储中的对象不打算跨线程 shared/copied 通常可以是可变的,因为在逻辑线程内执行将始终能够保证相对受限的顺序逻辑(如果可能需要一些特殊处理来确保编译时优化不会打扰你,尽管这种情况很少见,而且只在非常特定的情况下才有必要)。出于这个原因,您示例中的 ThreadLocal 实际上不需要是 ImmutableStack,它可以只是 Stack(性能更好),因为您不需要担心写时复制或并发访问。如果堆栈是可公开访问的,那么有人可以将堆栈传递给可能 push/pop 项目的其他线程会更令人担忧,但由于它是此处的私有实现细节,因此 ImmutableStack 实际上可以被视为不必要的复杂度。

无论如何,执行上下文,这不是 .NET 独有的概念(其他平台上的实现可能在某些方面有所不同,但根据我的经验,差别不大)非常类似于(并直接相关)调用堆栈,但在某种程度上将新的异步任务视为堆栈上的新调用,这可能需要共享调用者在执行操作时的状态,以及由于调用者可能会继续创建更多而发散tasks 和 create/update 的状态在阅读一组顺序指令时没有逻辑意义。通常建议放置在 ExecutionContext 中的任何内容都是不可变的,但在某些情况下,仍指向同一实例引用的上下文的所有副本都应该共享可变数据。例如,IHttpContext 使用 AsyncLocal 存储在 IHttpContextAccessor 的默认实现中,因此在单个请求范围内创建的所有任务都可以访问相同的响应状态,例如.允许多个并发上下文对同一个引用实例进行突变必然会引入问题的可能性,包括并发性和逻辑执行顺序。例如,尝试在 HTTP 响应上设置不同结果的多个任务将导致异常或意外行为。您可以在某种程度上尝试在这里帮助消费者,但归根结底,消费者有责任了解他们所依赖的细微实现细节的复杂性(这通常是一种代码味道,但有时现实世界中必不可少的罪恶)。

如前所述,除了这种情况,为了确保所有嵌套上下文都可预测且安全地运行,通常建议仅存储不可变类型并始终将上下文恢复到其先前的值(就像您正在做的那样)一次性堆栈机制)。考虑写时复制行为的最简单方法就是好像每个新任务、新线程池工作项和新线程都有自己的上下文克隆,但如果它们指向相同的引用类型(即所有有相同的副本引用指针)它们都有相同的实例;写时复制只是一种优化,可以在不必要时防止复制,但基本上可以完全忽略并认为每个逻辑任务都有自己的副本上下文(这非常很像 ImmutableStack 或字符串)。如果更新不可变集合项指向 的当前值 的唯一方法是将其重新分配给新的修改实例,那么您永远不必担心跨上下文污染(只是就像 ImmutableStack 你正在使用的那样)。

您的示例没有显示任何关于如何访问数据或为 T 传递什么类型的信息,因此无法查看您可能面临的问题,但如果您担心什么是嵌套任务处理 "Current" 上下文或 IDisposable 值被分配给某处的字段并从不同的线程访问,有几件事你可以尝试,有些要点值得考虑:

  • 最接近当前支票的是:
if(stackHolder.Value != this) 
    throw new InvalidOperationException ("Bookmark disposed out of order or in wrong context");
  • 一个简单的 ObjectDisposedException 如果两个上下文试图处理它,则至少会从一个上下文中抛出异常。
  • 虽然通常不推荐这样做,但如果您想绝对确定该对象至少被释放一次,您可以在 IDisposable 实现的终结器中抛出异常(确保调用 GC.SuppressFinalize(this) 在 Dispose 方法中)。
  • 通过组合前两者,虽然它不能保证它被释放到创建它的确切 task/method 块中,但您至少可以保证一个对象被释放一次且仅一次。
  • 由于 ExecutionContext 应该被流动和控制的方式的根本重要性,它是执行引擎(通常是运行时、任务调度程序等)的责任,但在第三方正在以新颖的方式使用 Tasks/Threads)以确保 ExecutionContext 流量在适当的时候被捕获和抑制。如果线程、调度程序或同步迁移发生在根上下文中,则 ExecutionContext 不应流入下一个逻辑任务的 ExecutionContext 和任务先前执行的上下文中的 thread/scheduler 进程.例如,如果任务继续在 ThreadPool 线程上开始,然后等待继续导致下一个逻辑操作在不同于最初启动的 ThreadPool 线程或其他一些 I/O 资源完成线程,当返回原始线程时 ThreadPool 它不应继续 reference/flow 不再逻辑上在其中执行的任务的 ExecutionContext。假设没有额外的任务被并行创建并且被误入歧途,一旦在根等待者中恢复执行,它将是唯一继续引用上下文的执行上下文。当任务完成时,它的执行上下文(或者更确切地说,它的副本)也会完成。
  • 即使未观察到的后台任务已启动且从未等待,如果 AsyncLocal 中存储的数据是不可变的,写时复制行为与不可变堆栈相结合将确保执行上下文的并行克隆永远不能互相污染
  • 通过第一次检查并使用不可变类型,您真的不需要担心克隆的并行执行上下文,除非您担心它们会从以前的上下文中获取敏感数据;当他们 Dispose 当前项目时,只有当前执行上下文(例如嵌套并行上下文)的堆栈恢复到之前的状态;所有克隆的上下文(包括父上下文)都没有被修改。
  • 如果您 担心嵌套上下文通过处理它们不应该访问父数据的内容,您可以使用相对简单的模式将 IDisposable 与环境值,以及 TransactionScope 中使用的抑制模式,例如,暂时将当前值设置为 null,等等

只是以实际的方式重申,例如,假设您在其中一个书签中存储了一个 ImmutableList。如果存储在 ImmutableList 中的项目是可变的,那么上下文污染是可能的。


var someImmutableListOfMutableItems = unsafeAsyncLocal.Value;
// sets Name for all contexts pointing to the reference type.
someImmutableListOfMutableItems[0].Name = "Jon"; // assigns property setter on shared reference of Person
// notice how unsafeAsyncLocal.Value never had to be reassigned?

鉴于不可变项目的不可变集合永远不会污染另一个上下文,除非关于执行上下文的流动方式存在超级根本错误(联系供应商、提交错误报告、发出警报等)

var someImmutableListOfImmutableItems = safeAsyncLocal.Value;

someImmutableListOfImmutableItems = someImmutableListOfImmutableItems.SetItem(0, 
    someImmutableListOfImmutableItems[0].SetName("Jon") // SetName returns a new immutable Person instance
); // SetItem returns new immutable list instance

// notice both the item and the collection need to be reassigned. No other context will be polluted here
safeAsyncLocal.Value = someImmutableListOfImmutableItems;

编辑:一些文章是为那些想要阅读比我在这里的胡言乱语更连贯的东西的人准备的:)

https://devblogs.microsoft.com/pfxteam/executioncontext-vs-synchronizationcontext/ https://weblogs.asp.net/dixin/understanding-c-sharp-async-await-3-runtime-context

为了进行一些比较,这里有一篇关于如何在 JavaScript 中管理上下文的文章,它是单线程但支持异步编程模型(我认为这可能有助于说明它们如何 relate/differ ):

https://blog.bitsrc.io/understanding-execution-context-and-execution-stack-in-javascript-1c9ea8642dd0