Task.Yield() в библиотеке необходимо ConfigureWait (false)

рекомендуется использовать ConfigureAwait(false) каждый раз, когда вы можете, особенно в библиотеках, потому что это может помочь избежать взаимоблокировок и повысить производительность.

Я написал библиотеку, которая сильно использует async (обращается к веб-службам для БД). Пользователи библиотеки зашли в тупик, и после очень мучительной отладки и манипуляции я отследил ее до единственного использования await Task.Yield(). Везде, где у меня есть ожидание, я использую .ConfigureAwait(false), однако это не поддерживается на Task.Yield().

Какое рекомендуемое решение для ситуаций, когда требуется эквивалент Task.Yield().ConfigureAwait(false)?

Я читал о том, как был удален SwitchTo метод. Я могу понять, почему это может быть опасно, но почему нет эквивалента Task.Yield().ConfigureAwait(false)?

Edit:

Чтобы предоставить дополнительный контекст для моего вопроса, вот какой-то код. Я реализую библиотеку с открытым исходным кодом для доступа к DynamoDB (распределенной базе данных как услуге AWS), которая поддерживает async. Ряд операций возвращает IAsyncEnumerable<T>, как это предусмотрено IX-Async library. Эта библиотека не обеспечивает хороший способ генерации списков async из источников данных, которые предоставляют строки в "кусках", то есть каждый запрос async возвращает много элементов. Поэтому для этого у меня есть свой общий тип. Библиотека поддерживает опцию "читать вперед", позволяющую пользователю указать, сколько данных нужно запрашивать раньше, когда это действительно необходимо вызовом MoveNext().

В основном, как это работает, я делаю запросы на куски, вызывая GetMore() и проходя через состояние между ними. Я помещал эти задачи в очередь chunks и деактивировал их и превратил их в фактические результаты, которые я ввел в отдельную очередь. Метод NextChunk() здесь. В зависимости от значения ReadAhead я буду продолжать получать следующий фрагмент, как только последний будет выполнен (все), или нет, пока не будет необходимо, но не будет доступно (None), или только получите следующий фрагмент за пределами значений, которые в настоящее время используется (некоторые). Из-за этого получение следующего фрагмента должно выполняться параллельно/не блокировать получение следующего значения. Для этого перечисляющий код:

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)
    { ... }
}

Ответ 1

Точный эквивалент Task.Yield().ConfigureAwait(false) (который не существует, поскольку ConfigureAwait - это метод на Task, а Task.Yield возвращает пользовательский запрос) просто использует Task.Factory.StartNew с CancellationToken.None, TaskCreationOptions.PreferFairness и TaskScheduler.Current. Однако в большинстве случаев Task.Run (который использует значение по умолчанию TaskScheduler) достаточно близко.

Вы можете проверить, что, посмотрев на источник YieldAwaiter и посмотрите, что он использует ThreadPool.QueueUserWorkItem/ThreadPool.UnsafeQueueUserWorkItem, когда TaskScheduler.Current является стандартным (например, пул потоков) и 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 (или Task.Factory.StartNew с определенным TaskScheduler)

Ответ 2

Я заметил, что вы отредактировали свой вопрос после того, как вы приняли существующий ответ, так что, возможно, вас интересует больше разговоров по этому вопросу. Здесь вы идете:)

Рекомендуем использовать ConfigureAwait (false), когда вы может, особенно в библиотеках, потому что это может помочь избежать взаимоблокировок и улучшить производительность.

Это рекомендуется, только если вы абсолютно уверены, что любой API, который ваш вызов в вашей реализации (включая API-интерфейсы Framework) не зависит от каких-либо свойств контекста синхронизации. Это особенно важно для библиотечного кода, и даже более того, если библиотека подходит как для клиентской, так и для серверной системы. Например, CurrentCulture является общим видом: он никогда не будет проблемой для настольного приложения, но это может быть полезно для приложения ASP.NET.

Вернуться к вашему коду:

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

Скорее всего, тупик вызван клиентом вашей библиотеки, потому что они используют Task.Result (или Task.Wait, Task.WaitAll, Task.IAsyncResult.AsyncWaitHandle и т.д., позволяют им искать) где-то во внешнем кадре вызова цепь. Хотя здесь Task.Yield() избыточно, это не ваша проблема, в первую очередь, а скорее их: они не должны блокировать асинхронные API и должны использовать "Async All the Way", что также объясняется в статье Стивена Клири статью, которую вы связали.

Удаление Task.Yield() может или не может решить эту проблему, поскольку enumerable.GetMore() также может использовать некоторый await SomeApiAsync() без ConfigureAwait(false), таким образом отправляя продолжение обратно в контекст синхронизации вызывающего абонента. Более того, "SomeApiAsync" может быть хорошо установленным API-интерфейсом Framework, который по-прежнему уязвим для тупика, например SendMailAsync, мы вернемся к нему позже.

В целом вы должны использовать Task.Yield(), если по какой-то причине вы хотите немедленно вернуться к вызывающему абоненту ( "вернуть" контроль выполнения обратно вызывающему абоненту), а затем продолжить асинхронно, по милости SynchronizationContext установлен на вызывающий поток (или ThreadPool, если SynchronizationContext.Current == null). Зона продолжения может быть выполнена в том же потоке на следующей итерации цикла сообщений приложения. Более подробную информацию можно найти здесь:

Итак, правильно было бы избежать блокировки кода. Однако, скажем, вы все еще хотите сделать свой код тупиковым, вам не нужен контекст синхронизации, и вы уверены, что то же самое верно в отношении любой системы или стороннего API, которые вы используете в своей реализации.

Затем вместо повторного использования ThreadPoolEx.SwitchTo (который был удален по уважительной причине) вы можете просто использовать Task.Run, как это предложено в комментариях:

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, это все еще хак, с тем же сетевым эффектом, хотя и более читаемым, чем с использованием изменения ThreadPoolEx.SwitchTo(). То же, что и SwitchTo, он все еще имеет связанную стоимость: резервный переключатель потоков, который может повредить производительности ASP.NET.

Существует другой (IMO better) hack, который я предложил здесь, чтобы устранить тупик с вышеупомянутым 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;
    }
}

Этот хак работает так, что он временно удаляет контекст синхронизации для синхронной области исходного метода NextChunk, поэтому он не будет записан для 1-го await продолжения внутри async лямбда, эффективно решая проблема взаимоблокировки.

Стивен предоставил немного другую реализацию, отвечая на тот же вопрос. Его IgnoreSynchronizationContext восстанавливает исходный контекст синхронизации на том, что происходит в потоке продолжения после await (который может быть совершенно другим, случайным пулом). Я бы предпочел не восстановить его после await вообще, пока меня это не волнует.

Ответ 3

Поскольку полезный и законный API, который вы ищете, отсутствует, я подал этот запрос, предлагая его дополнение к .NET.

Я также добавил его в vs-threading, чтобы следующий выпуск Microsoft.VisualStudio.Threading NuGet package будет включать этот API. Обратите внимание: эта библиотека не зависит от VS, поэтому вы можете использовать ее в своем приложении.