Task.Yield() 在库中需要 ConfigureWait(false)

Task.Yield() in library needs ConfigureWait(false)

它是 recommended that one use ConfigureAwait(false) whenever when you can,尤其是在库中,因为它可以帮助避免死锁并提高性能。

我编写了一个大量使用异步(访问数据库的 Web 服务)的库。图书馆的用户遇到了僵局,经过大量痛苦的调试和修补后,我追踪到 await Task.Yield() 的单次使用。我在等待的其他任何地方都使用 .ConfigureAwait(false),但是 Task.Yield().

不支持

对于需要相当于 Task.Yield().ConfigureAwait(false) 的情况,推荐的解决方案是什么?

我读到过有一个 SwitchTo method that was removed。我明白为什么这可能很危险,但为什么没有 Task.Yield().ConfigureAwait(false)?

的等价物

编辑:

为了为我的问题提供更多背景信息,这里有一些代码。我正在实施 open source library for accessing DynamoDB (a distributed database as a service from AWS) that supports async. A number of operations return IAsyncEnumerable<T> as provided by the IX-Async library。该库没有提供从提供 "chunks" 行的数据源生成异步枚举的好方法,即每个异步请求 returns 许多项目。所以我有我自己的通用类型。该库支持预读选项,允许用户指定在调用 MoveNext().

实际需要数据之前应该请求多少数据

基本上,这是如何工作的,我通过调用 GetMore() 并在它们之间传递状态来请求块。我将这些任务放入 chunks 队列中,然后将它们出队,然后将它们转换为我放入单独队列中的实际结果。 NextChunk() 方法是这里的问题。根据 ReadAhead 的值,我将在最后一个完成后立即获取下一个块(全部)或直到需要一个值但不可用(None)或仅获取下一个块超出了当前正在使用的值(一些)。因此,获取下一个块应该 运行 in parallel/not 阻止获取下一个值。枚举器代码为:

private class ChunkedAsyncEnumerator<TState, TResult> : IAsyncEnumerator<TResult>
{
    private readonly ChunkedAsyncEnumerable<TState, TResult> enumerable;
    private readonly ConcurrentQueue<Task<TState>> chunks = new ConcurrentQueue<Task<TState>>();
    private readonly Queue<TResult> results = new Queue<TResult>();
    private CancellationTokenSource cts = new CancellationTokenSource();
    private TState lastState;
    private TResult current;
    private bool complete; // whether we have reached the end

    public ChunkedAsyncEnumerator(ChunkedAsyncEnumerable<TState, TResult> enumerable, TState initialState)
    {
        this.enumerable = enumerable;
        lastState = initialState;
        if(enumerable.ReadAhead != ReadAhead.None)
            chunks.Enqueue(NextChunk(initialState));
    }

    private async Task<TState> NextChunk(TState state, CancellationToken? cancellationToken = null)
    {
        await Task.Yield(); // ** causes deadlock
        var nextState = await enumerable.GetMore(state, cancellationToken ?? cts.Token).ConfigureAwait(false);
        if(enumerable.ReadAhead == ReadAhead.All && !enumerable.IsComplete(nextState))
            chunks.Enqueue(NextChunk(nextState)); // This is a read ahead, so it shouldn't be tied to our token

        return nextState;
    }

    public Task<bool> MoveNext(CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        if(results.Count > 0)
        {
            current = results.Dequeue();
            return TaskConstants.True;
        }
        return complete ? TaskConstants.False : MoveNextAsync(cancellationToken);
    }

    private async Task<bool> MoveNextAsync(CancellationToken cancellationToken)
    {
        Task<TState> nextStateTask;
        if(chunks.TryDequeue(out nextStateTask))
            lastState = await nextStateTask.WithCancellation(cancellationToken).ConfigureAwait(false);
        else
            lastState = await NextChunk(lastState, cancellationToken).ConfigureAwait(false);

        complete = enumerable.IsComplete(lastState);
        foreach(var result in enumerable.GetResults(lastState))
            results.Enqueue(result);

        if(!complete && enumerable.ReadAhead == ReadAhead.Some)
            chunks.Enqueue(NextChunk(lastState)); // This is a read ahead, so it shouldn't be tied to our token

        return await MoveNext(cancellationToken).ConfigureAwait(false);
    }

    public TResult Current { get { return current; } }

    // Dispose() implementation omitted
}

我不认为这段代码是完美的。抱歉,它太长了,不确定如何简化。重要的部分是 NextChunk 方法和对 Task.Yield() 的调用。此功能通过静态构造方法使用:

internal static class AsyncEnumerableEx
{
    public static IAsyncEnumerable<TResult> GenerateChunked<TState, TResult>(
        TState initialState,
        Func<TState, CancellationToken, Task<TState>> getMore,
        Func<TState, IEnumerable<TResult>> getResults,
        Func<TState, bool> isComplete,
        ReadAhead readAhead = ReadAhead.None)
    { ... }
}

完全等同于 Task.Yield().ConfigureAwait(false)(它不存在,因为 ConfigureAwaitTask 上的方法,而 Task.Yield returns 是自定义可等待的方法)只是将 Task.Factory.StartNewCancellationToken.NoneTaskCreationOptions.PreferFairnessTaskScheduler.Current 一起使用。 然而,在大多数情况下,Task.Run(使用默认值 TaskScheduler)已经足够接近了

您可以通过查看 YieldAwaiter 的源代码来验证,当 TaskScheduler.Current 是默认值(即线程池)时,可以看到它使用 ThreadPool.QueueUserWorkItem/ThreadPool.UnsafeQueueUserWorkItem ) 和 Task.Factory.StartNew 当它不是时。

但是您可以创建自己的可等待对象 (),它模仿 YieldAwaitable 但忽略 SynchronizationContext:

async Task Run(int input)
{
    await new NoContextYieldAwaitable();
    // executed on a ThreadPool thread
}

public struct NoContextYieldAwaitable
{
    public NoContextYieldAwaiter GetAwaiter() { return new NoContextYieldAwaiter(); }
    public struct NoContextYieldAwaiter : INotifyCompletion
    {
        public bool IsCompleted { get { return false; } }
        public void OnCompleted(Action continuation)
        {
            var scheduler = TaskScheduler.Current;
            if (scheduler == TaskScheduler.Default)
            {
                ThreadPool.QueueUserWorkItem(RunAction, continuation);
            }
            else
            {
                Task.Factory.StartNew(continuation, CancellationToken.None, TaskCreationOptions.PreferFairness, scheduler);
            }
        }

        public void GetResult() { }
        private static void RunAction(object state) { ((Action)state)(); }
    }
}

注意:我不建议实际使用NoContextYieldAwaitable,这只是对你问题的回答。您应该使用 Task.Run(或带有特定 TaskSchedulerTask.Factory.StartNew

我注意到您在接受现有答案后编辑了问题,所以您可能对有关该主题的更多评论感兴趣。给你:)

It's recommended that one use ConfigureAwait(false) whenever when you can, especially in libraries because it can help avoid deadlocks and improve performance.

建议这样做,前提是您完全确定您在实现中调用的任何 API(包括框架 APIs)不依赖于同步上下文的任何属性.这对于库代码尤其重要,如果库同时适用于客户端和服务器端使用则更是如此。例如,CurrentCulture:它永远不会成为桌面应用程序的问题,但它很可能是 ASP.NET 应用程序。

返回您的代码:

private async Task<TState> NextChunk(...)
{
    await Task.Yield(); // ** causes deadlock
    var nextState = await enumerable.GetMore(...);
    // ...
    return nextState;
}

很可能,死锁是由你图书馆的客户造成的,因为他们使用Task.Result(或Task.WaitTask.WaitAllTask.IAsyncResult.AsyncWaitHandle等,让他们搜索)在调用链外部框架的某处。尽管 Task.Yield() 在这里是多余的,但这首先不是你的问题,而是他们的问题:他们不应该阻塞异步 APIs 而应该使用 "Async一路走来,正如您链接的 Stephen Cleary 的文章中所解释的那样。

删除Task.Yield()可能可能不会解决这个问题,因为enumerable.GetMore()也可以使用一些await SomeApiAsync() 没有 ConfigureAwait(false),因此将继续发送回调用者的同步上下文。此外,“SomeApiAsync”可能恰好是一个完善的框架API,它仍然容易出现死锁,例如SendMailAsync,我们稍后再谈。

总的来说,如果出于某种原因您想立即 return 给调用者(“让出”执行控制权返回给调用者),您应该只使用 Task.Yield(),然后异步继续, 受安装在调用线程上的 SynchronizationContext 的支配(或 ThreadPool,如果 SynchronizationContext.Current == null)。在应用程序的核心消息循环的下一次迭代时,可以在 相同的 线程上执行延续井。可以在此处找到更多详细信息:

  • Task.Yield - real usages?

所以,正确的做法是避免一直阻塞代码。但是,假设您仍然想让您的代码防死锁,您不关心同步上下文,并且您确定您在实现中使用的任何系统或第 3 方 API 也是如此。

然后,您可以使用 Task.Run,而不是重新发明 ThreadPoolEx.SwitchTo(有充分理由将其删除),如评论中所建议的:

private Task<TState> NextChunk(...)
{
    // jump to a pool thread without SC to avoid deadlocks
    return Task.Run(async() => 
    {
        var nextState = await enumerable.GetMore(...);
        // ...
        return nextState;
    });
}

IMO,这仍然是一个 hack,具有相同的净效果,尽管比使用 ThreadPoolEx.SwitchTo() 的变体更具可读性。与 SwitchTo 相同,它仍然有相关成本:冗余线程切换可能会损害 ASP.NET 性能。

还有 另一个(IMO 更好)hack,我建议 here 解决上述 SendMailAsync 的僵局。它不会导致额外的线程切换:

private Task<TState> NextChunk(...)
{
    return TaskExt.WithNoContext(async() => 
    {
        var nextState = await enumerable.GetMore(...);
        // ...
        return nextState;
    });
}

public static class TaskExt
{
    public static Task<TResult> WithNoContext<TResult>(Func<Task<TResult>> func)
    {
        Task<TResult> task;
        var sc = SynchronizationContext.Current;
        try
        {
            SynchronizationContext.SetSynchronizationContext(null);
            task = func(); // do not await here
        }
        finally
        {
            SynchronizationContext.SetSynchronizationContext(sc);
        }
        return task;
    }
}

这个 hack 的工作方式是临时删除原始 NextChunk 方法的同步范围的同步上下文,因此不会在 await 中的第一个 await 延续中捕获它=36=] lambda,有效解决死锁问题。

Stephen 提供了一点 different implementation while answering the same question。他的 IgnoreSynchronizationContextawait 之后的延续线程(可能是完全不同的随机池线程)上恢复原始同步上下文。我宁愿在 await 之后根本不恢复它,只要我不关心它。

由于缺少您正在寻找的有用且合法的 API,我提交 this request 建议将其添加到 .NET。

我也 added it to vs-threading so that the next release of the Microsoft.VisualStudio.Threading NuGet package 将包括这个 API。请注意,此库不是特定于 VS 的,因此您可以在您的应用中使用它。