Повторная задача Task.ConfigureAwait(continueOnCapturedContext: false)

Слишком долго для чтения. Использование Task.ConfigureAwait(continueOnCapturedContext: false) может привести к избыточному переключению потоков. Я ищу для вас последовательное решение.

Длинная версия. Основная цель проекта ConfigureAwait(false) заключается в уменьшении избыточных обратных вызовов продолжения SynchronizationContext.Post для await, где это возможно. Обычно это означает меньшее переключение потоков и меньшую работу с потоками пользовательского интерфейса. Однако это не всегда так, как это работает.

Например, существует сторонняя библиотека, реализующая API SomeAsyncApi. Обратите внимание, что ConfigureAwait(false) не используется нигде в этой библиотеке по какой-либо причине:

// some library, SomeClass class
public static async Task<int> SomeAsyncApi()
{
    TaskExt.Log("X1");

    // await Task.Delay(1000) without ConfigureAwait(false);
    // WithCompletionLog only shows the actual Task.Delay completion thread
    // and doesn't change the awaiter behavior

    await Task.Delay(1000).WithCompletionLog(step: "X1.5");

    TaskExt.Log("X2");

    return 42;
}

// logging helpers
public static partial class TaskExt
{
    public static void Log(string step)
    {
        Debug.WriteLine(new { step, thread = Environment.CurrentManagedThreadId });
    }

    public static Task WithCompletionLog(this Task anteTask, string step)
    {
        return anteTask.ContinueWith(
            _ => Log(step),
            CancellationToken.None,
            TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default);
    }
}

Теперь, скажем, есть какой-то клиентский код, запущенный в потоке UI WinForms, и используя SomeAsyncApi:

// another library, AnotherClass class
public static async Task MethodAsync()
{
    TaskExt.Log("B1");
    await SomeClass.SomeAsyncApi().ConfigureAwait(false);
    TaskExt.Log("B2");
}

// ... 
// a WinFroms app
private async void Form1_Load(object sender, EventArgs e)
{
    TaskExt.Log("A1");
    await AnotherClass.MethodAsync();
    TaskExt.Log("A2");
}

Выход:

{ step = A1, thread = 9 }
{ step = B1, thread = 9 }
{ step = X1, thread = 9 }
{ step = X1.5, thread = 11 }
{ step = X2, thread = 9 }
{ step = B2, thread = 11 }
{ step = A2, thread = 9 }

Здесь логический поток выполнения проходит через 4 потока. 2 из них являются избыточными и вызваны SomeAsyncApi().ConfigureAwait(false). Это происходит потому, что ConfigureAwait(false) переносит продолжение в ThreadPool из потока с контекстом синхронизации (в этом случае поток пользовательского интерфейса).

В этом конкретном случае MethodAsync лучше без ConfigureAwait(false). Тогда он принимает только 2 переключателя нити vs 4:

{ step = A1, thread = 9 }
{ step = B1, thread = 9 }
{ step = X1, thread = 9 }
{ step = X1.5, thread = 11 }
{ step = X2, thread = 9 }
{ step = B2, thread = 9 }
{ step = A2, thread = 9 }

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

Как это происходит с WindowsFormsSynchronizationContext (или DispatcherSynchronizationContext), где мы, возможно, не заботимся о дополнительных переключателях потоков вообще. Однако аналогичная ситуация может возникнуть в ASP.NET, где AspNetSynchronizationContext.Post по существу делает следующее:

Task newTask = _lastScheduledTask.ContinueWith(_ => SafeWrapCallback(action));
_lastScheduledTask = newTask;

Все это может выглядеть как надуманная проблема, но я видел много такого производственного кода, как на стороне клиента, так и на стороне сервера. Еще один сомнительный шаблон, с которым я столкнулся: await TaskCompletionSource.Task.ConfigureAwait(false) с SetResult вызывается в том же контексте синхронизации, что и для первого await. Опять же, продолжение было избыточно перенесено на ThreadPool. Причиной этого шаблона было то, что "он помогает избежать взаимоблокировок".

Вопрос. В свете описанного поведения ConfigureAwait(false) я ищу элегантный способ использования async/await, при этом минимизируя избыточное переключение потоков/контекстов. В идеале, что-то, что будет работать с существующими сторонними библиотеками.

На что я смотрел, пока:

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

    await Task.Run(() => SomeAsyncApi()).ConfigureAwait(false);
    
  • Еще одно хакерское решение может заключаться в том, чтобы временно удалить контекст синхронизации из текущего потока, поэтому он не будет захвачен никакими последующими ожиданиями во внутренней цепочке вызовов (ранее я упоминал его здесь):

    async Task MethodAsync()
    {
        TaskExt.Log("B1");
        await TaskExt.WithNoContext(() => SomeAsyncApi()).ConfigureAwait(false);
        TaskExt.Log("B2");
    }
    
    { step = A1, thread = 8 }
    { step = B1, thread = 8 }
    { step = X1, thread = 8 }
    { step = X1.5, thread = 10 }
    { step = X2, thread = 10 }
    { step = B2, thread = 10 }
    { step = A2, thread = 8 }
    
    public static Task<TResult> WithNoContext<TResult>(Func<Task<TResult>> func)
    {
        Task<TResult> task;
        var sc = SynchronizationContext.Current;
        try
        {
            SynchronizationContext.SetSynchronizationContext(null);
            // do not await the task here, so the SC is restored right after
            // the execution point hits the first await inside func
            task = func();
        }
        finally
        {
            SynchronizationContext.SetSynchronizationContext(sc);
        }
        return task;
    }
    

    Это работает, но мне не нравится тот факт, что он меняет текущий контекст синхронизации потока, хотя и очень короткий. Более того, здесь существует еще одна импликация: при отсутствии SynchronizationContext в текущем потоке для await продолжения будет использоваться окружающая среда TaskScheduler.Current. Чтобы объяснить это, WithNoContext можно было бы изменить, как показано ниже, что сделало бы этот взлом еще более экзотическим:

    // task = func();
    var task2 = new Task<Task<TResult>>(() => func());
    task2.RunSynchronously(TaskScheduler.Default); 
    task = task2.Unwrap();
    

Буду признателен за любые другие идеи.

Обновлено, чтобы отправить @комментарий i3arnon:

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

Как ваш ответ был отредактирован, вот ваше выражение Я не согласился, для ясности:

ConfigureAwait (false) Цель состоит в том, чтобы уменьшить, насколько это возможно, работу "специальные" (например, UI) потоки должны обрабатываться, несмотря на поток для переключения требуется.

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

Избегайте ненужного маршалинга

Если это вообще возможно, убедитесь, что вы выполняете асинхронную реализацию не нуждается в заблокированном потоке, чтобы завершить операцию (таким образом, вы можете просто использовать обычные блокирующие механизмы для ожидания синхронно для выполнения асинхронной работы в другом месте). в случае async/await, это обычно означает, что любой ожидает внутри асинхронной реализации, вы используете ConfigureAwait (false) для всех ожидающих точек; это предотвратит ожидание от попытки вернуться к текущему SynchronizationContext. В виде разработчик библиотеки, его наилучшая практика всегда использовать ConfigureAwait (false) на всех ваших ожиданиях, если у вас нет конкретная причина не; это хорошо не только для того, чтобы виды проблем с тупиками, , но также и для производительности, поскольку это позволяет избежать ненужные затраты на маршалинг.

В нем говорится, что цель состоит в том, чтобы избежать ненужных затрат на маршалинг, для производительности. Переключатель потока (который, в частности, течет ExecutionContext), является большой стоимостью маршалинга.

Теперь он нигде не говорит о том, что цель состоит в том, чтобы уменьшить объем работы, который выполняется на "специальных" потоках или контекстах.

Хотя это может иметь определенный смысл для потоков пользовательского интерфейса, я все еще не думаю, что это главная цель, стоящая за ConfigureAwait. Существуют и другие - более структурированные способы минимизации работы с потоками пользовательского интерфейса, например, с использованием фрагментов await Task.Run(work).

Кроме того, совершенно не имеет смысла сводить к минимуму работу над AspNetSynchronizationContext - которая сама течет из потока в поток, в отличие от потока пользовательского интерфейса. Совсем наоборот, как только вы на AspNetSynchronizationContext, вы хотите сделать как можно больше работы, чтобы избежать ненужного переключения в середине обработки HTTP-запроса. Тем не менее, все еще имеет смысл использовать ConfigureAwait(false) в ASP.NET: если он используется правильно, он снова уменьшает переключение потоков на стороне сервера.

Ответ 1

Когда вы имеете дело с асинхронными операциями, накладные расходы на коммутатор потоков слишком малы, чтобы заботиться (вообще говоря). Цель ConfigureAwait(false) заключается не в том, чтобы вызвать переключатель потока (если необходимо), а скорее для предотвращения слишком большого количества кода, выполняемого в определенном специальном контексте.

Обоснованием этого шаблона было то, что "он помогает избежать взаимоблокировок".

И погружения в стек.

Но я думаю, что это не проблема в общем случае. Когда я сталкиваюсь с кодом, который неправильно использует ConfigureAwait, я просто переношу его в Task.Run и двигаюсь дальше. Накладные расходы на коммутаторы потоков не стоит беспокоиться.

Ответ 2

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

Я не согласен с вашей предпосылкой. Цель ConfigureAwait(false) состоит в том, чтобы сократить, насколько это возможно, работу, которая должна быть перенаправлена ​​на "специальные" (например, пользовательский интерфейс) контексты несмотря на потоков, которые могут потребоваться от этого контекста,

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

Чтобы достичь этого, вы должны использовать ConfigureAwait везде, вам не нужен поток, выполняющий продолжение. Если вы возьмете свой пример и используете ConfigureAwait соответственно, вы получите только один переключатель (вместо 2 без него):

private async void Button_Click(object sender, RoutedEventArgs e)
{
    TaskExt.Log("A1");
    await AnotherClass.MethodAsync().ConfigureAwait(false);
    TaskExt.Log("A2");
}

public class AnotherClass
{
    public static async Task MethodAsync()
    {
        TaskExt.Log("B1");
        await SomeClass.SomeAsyncApi().ConfigureAwait(false);
        TaskExt.Log("B2");
    }
}

public class SomeClass
{
    public static async Task<int> SomeAsyncApi()
    {
        TaskExt.Log("X1");
        await Task.Delay(1000).WithCompletionLog(step: "X1.5").ConfigureAwait(false);
        TaskExt.Log("X2");
        return 42;
    }
}

Вывод:

{ step = A1, thread = 9 }
{ step = B1, thread = 9 }
{ step = X1, thread = 9 }
{ step = X1.5, thread = 11 }
{ step = X2, thread = 11 }
{ step = B2, thread = 11 }
{ step = A2, thread = 11 }

Теперь, когда вы заботитесь о продолжении потока (например, когда вы используете элементы управления пользовательским интерфейсом), вы "платите", переключаясь на этот поток, размещая соответствующую работу в этом потоке. Вы все еще получили от всей работы, которая не требовала этого потока.

Если вы хотите сделать это еще глубже и удалить синхронную работу этих методов async из потока пользовательского интерфейса, вам нужно только один раз использовать Task.Run и добавить еще один переключатель:

private async void Button_Click(object sender, RoutedEventArgs e)
{
    TaskExt.Log("A1");
    await Task.Run(() => AnotherClass.MethodAsync()).ConfigureAwait(false);
    TaskExt.Log("A2");
}

Вывод:

{ step = A1, thread = 9 }
{ step = B1, thread = 10 }
{ step = X1, thread = 10 }
{ step = X1.5, thread = 11 }
{ step = X2, thread = 11 }
{ step = B2, thread = 11 }
{ step = A2, thread = 11 }

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


Использование WithNoContext имеет точно такой же результат, как и при использовании ConfigureAwait(false). Однако недостатки связаны с потоком SynchronizationContext и что вы не знаете об этом в методе async. ConfigureAwait напрямую влияет на текущий await, так что у вас есть причина и следствие вместе.

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


Теперь, если вы имеете дело с багги-библиотекой, когда ConfigureAwait(false) не используется должным образом, вы можете взломать ее, удалив SynchronizationContext, но используя Thread.Run, намного проще и понятнее и разгрузите работу ThreadPool имеет очень незначительные накладные расходы.