Andrey on .NET | Еще один аргумент за ConfigureAwait(false) в библиотеках

Еще один аргумент за ConfigureAwait(false) в библиотеках

Как получить блокировку ASP.NET приложения одним вызовом .NET метода? Очень даже просто. Причем, данный способ справедлив также для WPF и WinForms.

Причина кроется в неудачной попытке объединения двух миров: синхронного и асинхронного. Рассмотрим проблему на примере.

Демонстрация проблемы

Создадим ASP.NET MVC приложение. Добавим в него Контроллер Home c Действием Index и соответствующим Представлением. В данный момент все работает отлично.

Теперь предположим, что необходимо обратиться к сервису. Но вот проблема - он асинхронный. Чтобы не переписывать код Действия воспользуемся методом Wait(). Он превратит вызов в синхронный:

public class HomeController : Controller
{
    public ActionResult Index()
    {
        var service = new SomeService();
        service.DoSomeJobAsync().Wait();

        return this.View();
    }
}

public class SomeService
{
    public async Task DoSomeJobAsync()
    {
        await Task.Delay(200);
        Thread.Sleep(100);
    }
}

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

  • Вызов Wait() блокирует основной поток до завершения вызова метода DoSomeJobAsync().
  • Поскольку async/await не используются для вызова DoSomeJobAsync(), то его код начинает выполняется в основном потоке.
  • В сервисе расположен вызов асинхронного метода. Теперь вспомним, что async/await это конечный автомат, который использует очередь сообщений. Он сохранит текущий контекст (основного потока) и ему же будет отправлено сообщение о завершении Task.Delay(). Но основной поток находится в режиме ожидания из-за вызова Wait(). Итог – блокировка сразу после завершения Task.Delay().

С аналогичной ситуацией можно столкнуться в WPF и WinForms приложениях при попытке вызова из UI-потока асинхронных методов как синхронные.

Способы решения

Вариант 1: Исправляем Контроллер

Первый вариант очень простой. Если возможно, просто сделать метод Контроллера асинхронным:

public async Task<ActionResult> Index()
{
    var service = new SomeService();
    await service.DoSomeJob();

    return this.View();
}

Вариант 2: Исправляем библиотеку (сервис)

Второй способ универсальный. Как упоминалось уже в заметке "Оптимизируем async/await в библиотеках", внутри кода библиотек нет смысла сохранять контекст синхронизации. Изменим сервис:

public async Task DoSomeJobAsync()
{
        await Task.Delay(200).ConfigureAwait(false);
        Thread.Sleep(100);
}

Теперь вызов ConfigureAwait(false) отменяет сохранение контекста синхронизации. Это означает, что выполнение кода DoSomeJobAsync() после await Task.Delay() продолжится в контексте по умолчанию. Соответственно, нет необходимости в очереди сообщений. Блокировки не будет и выполнение кода продолжится.

Это еще одна причина "превентивного" использования ConfigureAwait(false) в библиотеках.

Комментарии (13) -

Матвей 24.07.2014 17:57:03

Спасибо за краткое описание, очень пригодилось.

Проблема не воспроизводится. Даже если специально явно указать ConfigureAwait(true);
public static async Task DoSomeJobAsync()
{
  Console.WriteLine("DoSomeJobAsync start {0}", Task.CurrentId);
  await Task.Delay(100);
  Thread.Sleep(1000);
  Console.WriteLine("DoSomeJobAsync end {0}", Task.CurrentId);
}

public static void Main(string[] args)
{
  Console.WriteLine("main start");
  DoSomeJobAsync().Wait();
  Console.WriteLine("main end");
}

Дело в том, что по умолчанию tread имеет Default (ThreadPool) SynchronizationContext, которые не склонен выполнять делегаты в каких-то специфичных потоках, а берёт первый попавшийся поток из пула.
Если же в моём примере в функцию Main добавить первой строкой например
var f = new Form();
Это создаст WindowsFormsSynchronizationContext связанный с текущим потоком. И тогда далее всё будет происходить точно как описано в статье, где пример относится к ASP.NET и потому имеет дело с AspNetSynchronizationContext.
Вообще тема SynchronizationContext - дело тёмное.

Я вообще постепенно прихожу к решению, что во подавляющем большинстве async Task-методов лучше использовать ConfigureAwait(false). А со значением по умолчанию true использовать только async void-методы, которые чаще всего будут в UI-коде (контроллере, presenter'е, view model).

А в консольном приложении Wait() можно спокойно лепить к async-методу, ибо у него нет контекста синхронизации, как в GUI-приложениях.

Евгений 10.10.2015 21:23:04

С ConfigureAwait(false) нужно быть очень аккуратным, иначе рискуешь потерять HttpContext.

Ну HttpContext по идее не должен уходить дальше контроллера. А в нем как раз ConfigureAwait(false) не нужен.

Евгений 10.10.2015 22:43:51

Верно.

У меня назрел другой вопрос. Совершенно точно, AspNetSynchronizationContext не постит сообщение в изначальный поток. Для этого достаточно сравнить ManageThreadID до вызова асинхронной операции и после. Также, я специально выкачал исходный код этого контекста, чтобы в этом убедиться. Однако если дернуть Result происходит Deadlock. Есть идеи почему так происходит?

Евгений 11.10.2015 11:57:20

Тут видимо deadlock связан не с тем, что вызывающий поток ожидает завершения асинхронной операции, а с тем, что доступ к HttpContext (а равно и к сессии) должен быть сериализован, потому что контекст не thread-safe.

Александр 25.12.2016 6:04:07

Я тоже столкнулся с этой неприятной особенностью Task'а и нашёл вашу публикацию в поиске среди многих других в том числе и этой blog.stephencleary.com/.../...k-on-async-code.html
Мне кажется, что вы допустили некоторую неточность в выводах.
«…выполнение кода DoSomeJobAsync() после await Task.Delay() продолжится в том же потоке, что и сам Task.Delay()»
Точнее, выполнение продолжится в контексте синхронизации по умолчанию, то есть в ThreadPool SynchronizationContext, который для выполнения кода берёт потоки из Thread Pool и продолжение метода будет исполнено в потоке отличном от того, в котором был произведён вызов метода (необязательно поток метода Delay()), поэтому блокировка не произойдёт.

Александр Согласен. Спасибо за комментарий.

Алексей 21.03.2020 1:23:27

Не совсем понятно описание. В таком ли порядке все происходит:
1) Сначала вызов Wait() блокирует UI поток.
2) Неудачная попытка (невозможность) вызова Task.Delay() на UI потоке из-за того, что он уже заблокирован и непригоден для использования.

Или:
1) Удачный вызов Task.Delay() на UI потоке.
2) Блокировка через Wait() метод.
3) Невозможность вернуть результат от Task.Delay(), так как UI поток заблокирован.

Алексей 21.03.2020 2:39:23

На момент вызова .Result (или Wait()) результат из DoSomeJobAsync() ещё не успел быть получен, метод еще не отработал. Результат не готов. Нечего дать UI потоку. А он ждет и надеется, и из-за этого заблокирован. Задача завершается  и хочет дать ответ UI потоку, но не может, так как UI поток заблокирован. Вроде все ясно, но только, если UI поток заблокирован, как он тогда успешно выполняет работу, например Task.Delay? В голове не укладывается.

Алексей
1) Выполнение DoSomeJobAsync будет происходить синхронно в UI потоке. Основной поток встал и ждет завершения метода.
2) Управаление прейдет к DoSomeJobAsync, внутри него запустится асинхронный Delay (новый поток).
3) По завершению Delay,  DoSomeJobAsync посылает сообщение в UI поток, чтобы продолжить свое выполнение в нем. Т.е. вызов "Thread.Sleep(100);" ждет когда UI поток окликнится на это сообщение.
4) А UI поток заблокирован.

Если мы отключили контекст синхронизации, то мы можем в пункте 3 мы можем продолжить на текущем потоке. В нем выолняем "Thread.Sleep(100);" и завершаем DoSomeJobAsync. Метод закончил работу, UI поток продолжает работу как надо.



Другими словами - работа основного потока необходима для обработки сообщеий о завершении асинхронной операции и активации остановленного потока.

Добавить комментарий