Кто из нас не косячит? Я регулярно встречаюсь с ошибками в асинхронном коде и делаю их сам. Чтобы прекратить это колесо Сансары делюсь с вами самыми типичными косяками из тех, которые иногда довольно сложно отловить и починить.



Этот текст вдохновлен блогом Стивена Клэри, человека который знает всё про конкурентность, асинхронность, многопоточность и другие страшные слова. Он автор книги Concurrency in C# Cookbook, собравшей в себе огромное количество паттернов для работы с конкурентностью.

Классический асинхронный deadlock


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


Сначала метод будет углубляться в цепочку вызовов async-методов пока не встретит источник асинхронности. Как именно реализуется источник асинхронности — тема, выходящая за рамки данной статьи. Сейчас для простоты примем, что это операция, которая не требует рабочего потока во время ожидания её результата, например запрос к базе данных или HTTP-запрос. Синхронный запуск такой операции означает то, что во время ожидания её результата в системе будет как минимум один заснувший поток, который потребляет ресурсы, но не выполняет никакой полезной работы.


При асинхронном вызове, мы как бы разрываем поток выполнения команд на «до» и «после» асинхронной операции и в .NET нет никаких гарантий, что код, лежащий после await будет выполняться в том же потоке, что и код до await. В большинстве случаев это и не нужно, но что делать, когда такое поведение жизненно необходимо для работы программы? Нужно использовать SynchronizationContext. Это механизм, позволяющий наложить определенные ограничения на потоки, в которых выполняется код. Далее мы будем иметь дело с двумя контекстами синхронизации (WindowsFormsSynchronizationContext и AspNetSynchronizationContext), но Алекс Дэвис в своей книге пишет, что в .NET их около десятка. Про SynchronizationContext хорошо написано здесь, здесь, а здесь автор реализовал свой собственный, за что ему большой респект.


Итак, как только код приходит к источнику асинхронности, он сохраняет контекст синхронизации, который был в thread-static свойстве SynchronizationContext.Current, потом стартует асинхронную операцию и освобождает текущий поток. Иными словами, пока мы ждем окончания выполнения асинхронной операции, мы не блокируем ни один поток и это главный профит от асинхронной операции по сравнению с синхронной. После окончания выполнения асинхронной операции мы должны выполнить инструкции, которые находятся после источника асинхронности и тут, для того чтобы решить в каком потоке нам выполнять код после асинхронной операции, нам нужно проконсультироваться с сохраненным ранее контекстом синхронизации. Как он скажет, так и будем делать. Скажет выполнять в том же потоке, что и код до await — выполним в том же, не скажет — возьмем первый попавшийся поток из пула.


А что делать, если в данном конкретном случае нам важно, чтобы код после await выполнялся в любом свободном потоке из пула потоков? Нужно использовать мантру ConfigureAwait(false). Значение false, переданное в параметр continueOnCapturedContext как раз и сообщает системе, что можно использовать любой поток из пула. А что произойдет, если в момент выполнения метода с await контекста синхронизации вообще не было (SynchronizationContext.Current == null), как например в консольном приложении. В этом случае у нас нет никаких ограничений на поток, в котором должен быть выполнен код после await и система возьмет первый попавшийся поток из пула, как и в случае с ConfigureAwait(false).


Итак, что же такое асинхронный дедлок?


Дедлок в WPF и WinForms


Отличием WPF и WinForms-приложений является наличие того самого контекста синхронизации. У контекста синхронизации WPF и WinForms есть специальный поток — поток пользовательского интерфейса. UI-поток один на SynchronizationContext и только из этого потока можно взаимодействовать с элементами пользовательского интерфейса. По умолчанию, код, начавший работать в UI-потоке, возобновляет работу после асинхронной операции в нём же.


Теперь посмотрим на пример:

private void Button_Click(object sender, System.Windows.RoutedEventArgs e)
{
    StartWork().Wait();
}
private async Task StartWork()
{
    await Task.Delay(100);
    var s = "Just to illustrate the instruction following await";
}

Что произойдет при вызове StartWork().Wait():

  1. Вызывающий поток (а это поток пользовательского интерфейса) войдёт в метод StartWork и дойдет до инструкции await Task.Delay(100).
  2. UI-поток запустит асинхронную операцию Task.Delay(100), а сам вернёт управление в метод Button_Click, а там его ждёт метод Wait() класса Task. При вызове метода Wait() произойдёт блокировка UI-потока до момента окончания асинхронной операции, и мы ожидаем, что как только она завершится, UI-поток сразу же подхватит выполнение и пойдёт дальше по коду, однако, всё будет не так.
  3. Как только Task.Delay(100) завершится, UI-поток должен будет сначала продолжить выполнение метода StartWork() и для этого ему нужен строго тот поток, в котором выполнение стартовало. Но UI-поток сейчас занят ожиданием результата выполнения операции.
  4. Дедлок: StartWork() не может продолжить выполнение и вернуть результат, а Button_Click ждёт того самого результата, а из-за того, что выполнение стартовало в потоке пользовательского интерфейса, приложение просто напросто повиснет без шансов на продолжение работы.

Такую ситуацию можно довольно просто вылечить изменив вызов Task.Delay(100) на Task.Delay(100).ConfigureAwait(false):

private void Button_Click(object sender, System.Windows.RoutedEventArgs e)
{
    StartWork().Wait();
}
private async Task StartWork()
{
    await Task.Delay(100).ConfigureAwait(false);
    var s = "Just to illustrate the instruction following await";
}

Этот код отработает без дедлоков, так как теперь для завершения метода StartWork() может быть использован поток из пула, а не заблокированный UI-поток. Стивен Клэри в своём блоге рекомендует использовать ConfigureAwait(false) во всех «библиотечных методах», но специально подчеркивает, что использовать ConfigureAwait(false) для лечения дедлоков — неправильная практика. Вместо этого он советует НЕ использовать блокирующие методы типа Wait(), Result, GetAwaiter().GetResult() и переводить все методы на использование async/await, если это возможно (так называемый принцип Async all the way).


Дедлок в ASP.NET


В ASP.NET также есть контекст синхронизации, но у него немного другие ограничения. Он разрешает использовать только один поток на запрос в одно и то же время и так же требует, чтобы код после await выполнялся в том же потоке, что и код до await.


Пример:

public class HomeController : Controller
{
    public ActionResult Deadlock()
    {
        StartWork().Wait();
        return View();
    }
    private async Task StartWork()
    {
        await Task.Delay(100);
        var s = "Just to illustrate the code following await";
    }
}

Этот код так же вызовет дедлок, так как в момент вызова StartWork().Wait() единственный разрешённый поток будет заблокирован и будет ожидать окончания операции StartWork(), а она никогда не закончится, так как поток, в котором выполнение должно продолжиться, занят ожиданием.


Исправляется это всё тем же ConfigureAwait(false).


Дедлок в ASP.NET Core (на самом деле нет)


Теперь попробуем запустить код из примера для ASP.NET в проекте для ASP.NET Core. Если мы это сделаем, то увидим, что дедлока не будет. Это связано с тем, что в ASP.NET Core нет контекста синхронизации. Отлично! И что, теперь можно обмазывать код блокирующими вызовами и не бояться дедлоков? Строго говоря, да, но помните, что это заставляет поток засыпать во время ожидания, то есть поток потребляет ресурсы, но не выполняет никакой полезной работы.




Запомните, что использование блокирующих вызовов нивелирует все преимущества асинхронного программирования превращая его в синхронное. Да, иногда без использования Wait() не получится написать программу, но причина должна быть серьёзной.

Ошибочное использование Task.Run()


Метод Task.Run() был создан для запуска операций в новом потоке. Как и положено методу, написанному по TAP-паттерну, он возвращает Task или Task<T> и у людей, которые впервые сталкиваются с async/await появляется большое желание завернуть синхронный код в Task.Run() и эвейтить результат этого метода. Код как будто бы стал асинхронным, но на самом деле ничего не поменялось. Давайте разберёмся что получается при таком использовании Task.Run().


Пример:

private static async Task ExecuteOperation()
{
    Console.WriteLine($"Before: {Thread.CurrentThread.ManagedThreadId}");
    await Task.Run(() => 
    {
        Console.WriteLine($"Inside before sleep: {Thread.CurrentThread.ManagedThreadId}");
        Thread.Sleep(1000);
        Console.WriteLine($"Inside after sleep: {Thread.CurrentThread.ManagedThreadId}");
    });
    Console.WriteLine($"After: {Thread.CurrentThread.ManagedThreadId}");
}

Результатом работы этого кода будет:

Before: 1
Inside before sleep: 3
Inside after sleep: 3
After: 3

Здесь Thread.Sleep(1000) — это какая-либо синхронная операция, которая требует потока для выполнения. Допустим, мы хотим сделать наше решение асинхронным и для того, чтобы эту операцию можно было эвейтить, мы завернули её в Task.Run().


Как только код доходит до метода Task.Run(), достаётся другой поток из пула потоков и в нём исполняется код, который мы передали в Task.Run(). Старый поток, как и положено приличному потоку, возвращается в пул и ждёт, когда его снова позовут делать работу. Новый поток выполняет переданный код, доходит до синхронной операции, синхронно выполняет её (ждёт пока операция не будет выполнена) и идёт дальше по коду. Иными словами, операция так и осталась синхронной: мы, как и раньше, используем поток во время выполнения синхронной операции. Единственное отличие — мы потратили время на переключение контекста при вызове Task.Run() и при возврате в ExecuteOperation(). Всё стало немножечко хуже.


Надо понимать, что несмотря на то, что в строках Inside after sleep: 3 и After: 3 мы видим один и тот же Id потока, в этих местах совершенно разный контекст выполнения. Просто ASP.NET умнее нас и старается сэкономить ресурсы при переключении контекста из кода внутри Task.Run() во внешний код. Здесь он решил не менять хотя бы поток выполнения.


В таких случаях нет никакого смысла использовать Task.Run(). Вместо этого Клэри советует делать все операции асинхронными, то есть в нашем случае заменять Thread.Sleep(1000) на Task.Delay(1000), но это, конечно, не всегда возможно. Что делать в случаях, когда мы используем сторонние библиотеки, которые не можем или не хотим переписывать и делать до конца асинхронными, но нам по тем или иным причинам нужен именно async-метод? Лучше использовать Task.FromResult() для оборачивания результата работы вендорных методов в Task. Это, конечно, не сделает код асинхронным, но мы хотя бы сэкономим на переключении контекста.


Для чего же тогда использовать Task.Run()? Ответ прост: для CPU-bound операций, когда нужно сохранить отзывчивость UI или распараллелить вычисления. Здесь нужно сказать, что CPU-bound операции по натуре синхронны. Именно для запуска синхронных операций в асинхронном стиле и был придуман Task.Run().

Использование async void не по назначению


Возможность писать асинхронные методы, возвращающие void была добавлена для того, чтобы писать асинхронные хендлеры событий. Давайте разберёмся почему они могут внести смуту, если их использовать не по назначению:

  1. Нельзя дождаться результата.
  2. Не поддерживается обработка исключений через try-catch.
  3. Нельзя комбинировать вызовы через Task.WhenAll(), Task.WhenAny() и прочие подобные методы.

Из всех перечисленных причин самым интересным моментом является обработка исключений. Дело в том, что в async-методах, возвращающих Task или Task<T>, исключения перехватываются и оборачиваются в объект Task, который потом будет передан вызывающему методу. В своей статье для MSDN Клэри пишет, что так как в async-void методах нет возвращаемого значения, то и оборачивать исключения не во что и они возбуждаются напрямую в контексте синхронизации. Итогом является необработанное исключение из-за которого процесс крашится, успевая, разве что написать ошибку в консоль. Получить и залогировать такие исключения можно подписавшись на событие AppDomain.UnhandledException, но остановить краш процесса даже в обработчике этого события уже не удастся. Такое поведение характерно как раз для хендлера события, но не для обычного метода, от которого мы ожидаем возможности стандартной обработки исключения через try-catch.


Например, если в ASP.NET Core приложении написать так, процесс гарантированно упадёт:

public IActionResult ThrowInAsyncVoid()
{
    ThrowAsynchronously();
    return View();
}
private async void ThrowAsynchronously()
{
    throw new Exception("Obviously, something happened");
}

Но стоит поменять тип возвращаемого значения метода ThrowAsynchronously на Task (даже не добавляя ключевое слово await) и исключение будет перехвачено стандартным хендлером ошибок ASP.NET Core, а процесс будет продолжать жить несмотря на эксепшн.


Будьте аккуратнее с методами async-void — они могут положить вам процесс.

await в однострочном методе


Последний антипаттерн не такой страшный как предыдущие. Суть в том, что нет смысла использовать async/await в методах, которые, например просто пробрасывают результат другого async-метода дальше, за исключением, пожалуй, использования await в using.


Вместо такого кода:

public async Task MyMethodAsync()
{
    await Task.Delay(1000);
}

вполне можно (и предпочтительно) было бы написать:
public Task MyMethodAsync()
{
    return Task.Delay(1000);
}

Почему это работает? Потому, что ключевое слово await может применяться к Task-like объектам, а не к методам, помеченным ключевым словом async. В свою очередь ключевое слово async как раз говорит компилятору о том, что данный метод нужно развернуть в конечный автомат, а все возвращаемые значения обернуть в Task (или в другой Task-like объект).


Иными словами, результат первой версии метода — Task, который станет Completed как только закончится ожидание Task.Delay(1000), а результат второй версии метода — Task, возвращаемый самим Task.Delay(1000), который станет Completed, как только пройдёт 1000 милисекунд.


Как видно, обе версии эквивалентны, но в то же время первая требует гораздо больше ресурсов для создания асинхронного «обвеса».


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


UPD:
Как справедливо замечают в комментариях, выпиливание async/await из однострочных методов ведет к негативным побочным эффектам. Например, при выбросе исключения, метод, пробрасывающий Task наверх, не будет видно в стэке. Поэтому, убирать эвейты по-умолчанию не рекоммендуется. Пост Клэри с разбором.

Комментарии (53)


  1. leotsarev
    10.01.2019 21:48
    +4

    Ваш друг — пакет AsyncFixer


    1. ypastushenko Автор
      10.01.2019 23:43

      Да, статические анализаторы — хорошая штука. Но инструментами нужно пользоваться с умом, понимая, почему они считают код ошибочным.


  1. SLAVONchick
    10.01.2019 23:42

    Лучше использовать Task.FromResult() для оборачивания результата работы вендорных методов в Task. Это, конечно, не сделает код асинхронным, но мы хотя бы сэкономим на переключении контекста.

    А в чём смысл оборачивать синхронный код в Task, если он не будет выполняться асинхронно? Не проще ли выполнять его синхронно?


    1. ypastushenko Автор
      10.01.2019 23:46

      Не просто лучше, а нужно выполнять синхронно. Но что если, например, нужно реализовать метод интерфейса, который возвращает Task?


  1. Marwin
    10.01.2019 23:43

    оказывается, я всё делал не так ((


  1. onyxmaster
    11.01.2019 00:12

    Я бы всё-таки не стал делать task elision по умолчанию, в основном из-за того что семантика выброса исключений меняется в зависимости от того, где именно в методе расположен выброс исключения; впрочем есть и другие причины.
    У меня есть простое правило — использовать elision по умолчанию только для реализаций декораторов без валидации. В остальных случаях — смотреть на результаты регулярного аудита при помощи PerfView.
    Я понимаю что в статье и не советуют использовать его всегда, но я неоднократно сталкивался с ситуацией, когда человек знакомится с концепцией task elision и начинает усиленно «изводить» await-ы =)


    1. ypastushenko Автор
      11.01.2019 09:30

      Да, тут больше речь о методах, которые пробрасывают таски дальше.

      Отдельное спасибо за ссылку на Клэри :) Этот пост я как-то пропустил(


      1. Bronx
        11.01.2019 10:30

        Ещё небольшая поправка: Stephen Cleary == Стивен Клири, а не Клэри.


        1. ypastushenko Автор
          11.01.2019 13:10

          Тоже думал над локализацией, но вот в этом видео он сам себя называет Стивен Клэри.


          1. Bronx
            11.01.2019 20:50

            А в этом вебкасте — Клири. Возможно, оба произношения верны тогда.


    1. kefirr
      11.01.2019 10:56

      Очень хорошее замечание. На мой взгляд, лучше лишний раз не думать (и не заставлять коллег думать) и всегда писать async/await. Далеко не все даже опытные разработчики держат в голове все эти тонкости.


    1. ypastushenko Автор
      12.01.2019 10:03

      Добавил апдейт


  1. 0x1000000
    11.01.2019 11:42

    У меня вот вопрос знающим людям. Теоретически, добавление
    <add key="asp:UseTaskFriendlySynchronizationContext" value="true" />
    должно пофиксить дедлок в ASP.Net, поскольку эта опция разрешает выполнение кода поле асинхронной операции в любом потоке из пула (и это реально так), но по факту, дедлок остается :(


    1. mayorovp
      11.01.2019 11:47

      Проблема не в номере потока, а в их количестве, поскольку ASP.NET запрещает выполнение одного запроса в двух потоках одновременно.


      1. 0x1000000
        11.01.2019 12:46

        Звучит логично, спасибо за ответ! Но еще в статье сказанно, что

        Исправляется это всё тем же ConfigureAwait(false).

        Исходя из этого ограничения, ConfigureAwait не поправит ситуацию в общем случае.

        Так если, например, заменить Task.Delay на

        async Task MyDelay(int millisecondsDelay) => await Task.Delay(millisecondsDelay

        то дедлок останется.


        1. ypastushenko Автор
          11.01.2019 13:06

          ConfigureAwait(false) вообще отвязывает операцию от контекста синхронизации и отправляет ее в пул потоков. SynchronizationContext.Current становится равным null.
          После ConfigureAwait(false) снимаются все ограничения наложенные контекстом ранее.


          1. 0x1000000
            11.01.2019 13:59

            Вот пример кода который все еще вызывает deadlock (WEB API 2):

            [HttpGet]
            public int Deadlock()
            {
                StartWork().Wait();
                return 0;
            }
            
            private async Task StartWork()
            {
                await MyDelay(100).ConfigureAwait(false);
                var s = "Just to illustrate the code following await";
            }
            
            private async Task MyDelay(int ms) => await Task.Delay(ms);
            

            На месте MyDelay может быть любая другая библиотечная функция


            1. ypastushenko Автор
              11.01.2019 14:09

              Всё правильно. Здесь отвязывается от контекста Task, который создается в StartWork, но Task, возвращаемый Task.Delay() всё еще привязан к контексту. Если перенести ConfigureAwait(false) в MyDelay, то дедлока не будет.


              1. 0x1000000
                11.01.2019 14:27

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


                1. ypastushenko Автор
                  11.01.2019 17:09

                  Справедливо. Поэтому Клэри и советует использовать ConfigureAwait (false) в библиотеках.


        1. mayorovp
          11.01.2019 13:17

          Почему же? После ConfigureAwait(false) ASP.NET больше не относит продолжение метода к старому запросу, это уже просто случайный поток из пула, про который ASP.NET вообще ничего не знает.


          1. 0x1000000
            12.01.2019 14:13
            -1

            Это будет так только при определенных условиях (см. пример выше). Корме того, подобный хак может привести к ошибкам в рантайме, поскольку httpContext будет потерян и код "продолжения" не сможет получить доступ к данным запроса. Копирование http контекста из потока в поток это основная обязанность контекстов синхронизации применяемых в Asp.net, поэтому не стоит их выкидывать — лучше избавиться от этого антипаттерна .


    1. 0x1000000
      11.01.2019 20:53

      Разобрался с aspnet:UseTaskFriendlySynchronizationContext. Эта опция включена по умолчанию. C помощью этой опции можно выбирать тип контекста синхронизации между AspNetSynchronizationContext и LegacyAspNetSynchronizationContext. В “обычном” (TaskFriendly) используется неблокирующая очередь для выполнения “продолжений”, а в “старом” “продолжения” вызывались “как есть” (из потока в котором завершилась асинхронная операция).

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


  1. interset
    11.01.2019 13:02

    (1) Вверху фото не видно. Там только рекламный котик видно. Исправьте ошибку.
    (2) Проблемы с Асинхронную были ещё модемах в период СССР вот пример habr.com/post/202508


    1. ypastushenko Автор
      11.01.2019 13:07

      (1) У меня фото видно. Оно на habrastorage.
      (2) Спасибо, интересно. Почитаю.


      1. interset
        11.01.2019 13:12
        -1

        Сделайте что не будь. Не могу убрать этот котика. image


        1. ypastushenko Автор
          11.01.2019 13:19

          Этот котик часть статьи) Картинка какбы говорит нам, что не нужно использовать Wait() без причины, иначе котику будет не хорошо)


          1. interset
            11.01.2019 13:21
            -1

            Спасибо за информацию. Не знал. Теперь знаю.


  1. jakobz
    11.01.2019 17:24
    -1

    Не назван самый главный момент с async-ами: их вообще не надо использовать в 99% веб-приложений. Там нет таких задач, где они принесут заметную выгоду по производительности. А вот гемора, дедлоков, кучи лишних букв в коде, и головной боли — они приносят достаточно.

    Взять хотя бы то, что у всех Entity Framework, его DbContext — не thread-safe, и в .net core нет SynchronizationContext-а. Т.е. EF вместе с async-ами — это полный капец, работать оно может только по счастливому стечению обстоятельств.

    Я вообще считаю что добавление async-ов в C# — было ошибкой. Эдакой данью моде, заданной node.js — где костыль преподнесли за божью росу, дарующую перформанс. Тот 1% случаев, когда асинхронщина действительно что-то даст кроме гемора — можно было писать и обычными ContinueWith — и даже лучше было бы: меньше магии в таких сложных штуках — благо.


    1. mayorovp
      11.01.2019 17:36

      При чём тут счастливое стечение обстоятельств? Тот факт, что DbContext — не thread-safe, всего лишь означает что его нельзя использовать из разных потоков одновременно. Но это не означает что его нельзя использовать из разных потоков последовательно.

      А await дает именно это: последовательное исполнение в разных потоках.

      Что же до ошибок, костылей и дани моде — вы хоть раз пробовали выносить операции в фон в тех же вин-формах или WPF? Там без async никак.


      1. jakobz
        12.01.2019 02:12
        -1

        Там последовательное исполнение заканчивается на любом коде джуна, которому надо Task вернуть имея 0, или идиота, начитавшегося статей автора, и втыкающего везде configureAwait(false) как мантру.

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


        1. mayorovp
          12.01.2019 11:34

          И каким же образом этот ConfigureAwait(false) помешает потокобезопасности?


    1. mayorovp
      11.01.2019 17:46

      Кстати, у вас что-то не так с хронологией. Async/await пришел в Javascript из C#, а не наоборот.


      1. onyxmaster
        11.01.2019 18:50

        Я предположу что товарищ имел в виду популяризованный node.js подход с (однопоточным) неблокирующим сервером и, как следствие, модой на end-to-end (от открытия сокета для приёма соединений до обращения к БД) асинхронный пайплайн.


    1. onyxmaster
      11.01.2019 18:39

      Я хоть и вижу что это непопулярное мнение, но соглашусь с автором комментария как минимум частично.
      Если запросы к БД работают быстро (<15мс) — оверхед от асинхронного API (и задержки из-за условно говоря IOCP и нагрузка на GC из-за временных объектов async state machine плюс самих Task<>) побъёт любой выигрыш (это не теория, это ежемесячный PerfView относительно нагруженного веб-проекта). Если же они работают медленно (>100мс) — на сервер никакой серьёзной нагрузки всё равно не создать, потому что обычно запросов требуется несколько, а сервисы, которые возвращают ответ на действие пользователя больше чем за 300мс должны гореть в огне.
      Есть некоторый плюс в виде увеличения количества свободных потоков, но если это не микросервисное API, а что-нибудь потолще, то выгоды от «неблокирования» потока не так много (потому что основные расходы на существование потока это размер стека, а rooted object graph асинхронной операции может занимать сопоставимый со стеком объём памяти). В эпоху 32-битных процессов имело смысл экономить адресное пространство (и количество потоков), в современных 64-битных процессах смысла делать это существенно меньше.

      Есть средства уменьшения оверхеда (и сама статья собственно про один из важных моментов), кроме того до нас рано или поздно доберутся ValueTask (правда он не так полезен как кажется), но сейчас ситуация обстоит примерно так как я описал выше — для динамической работы с БД без агрегирующих запросов и «широких» JOIN (а ни тем ни тем не место в условном хайлоаде в обработке запросов) асинхронные вызовы по умолчанию применять не стоит.
      Конечно же можно пытаться параллелизовать и откладывать какие-то долгие операции внутри запроса так, чтобы скрыть, допустим, задержку внешних сервисов или какие-то операции, которые можно вынести «в фон», при этом их результаты не страшно потерять (если страшно то надо всё-таки message queue делать), но это всё-таки частные специфические случаи.

      Отмечу что автор говорит про веб-приложения, про бэкенд и доступ к БД, так что аргументация про то что «в WPF без них никак» не кажется мне подходящей.


      1. onyxmaster
        12.01.2019 10:17

        Написал три экрана текста на мобильном — 0 ответов, 0 аргументов, 2 минуса :)
        Хоть описали бы с какой именно частью не согласны, что ли? Я правда хочу знать, что я не так думаю.


        1. ypastushenko Автор
          12.01.2019 10:46
          +1

          Вообще вы, конечно правы в том, что async — это не панацея, и переводить решение на async просто потому что так сейчас модно, скорее всего не стоит. Однако, если ASP.NET решение упирается в пул потоков стоит наряду с увеличением пула рассмотреть вариант, при котором на hot path все вызовы будут полностью асинхронными.

          Кроме того, набирают популярность managed решения, в которых у юзеров нет доступа к размеру пула потоков.

          Я бы смотрел на async/await в ASP.NET так: микрософт предложил нам легкий способ работы с асинхронным кодом, который позволяет малой кровью подготовиться к хайлоаду «из коробки». По началу, он был кривой и косой, но уже в ASP.NET Core с ним стало вполне приятно работать и почти все известные проблемы убрали.

          Кстати, поделитесь опытом: как вы с помощью PerfView пришли к выводу, что при использовании async/await сильный оверхед?

          И еще вопрос:

          Если же они работают медленно (>100мс) — на сервер никакой серьёзной нагрузки всё равно не создать

          Можете пояснить: почему не создать?


          1. onyxmaster
            12.01.2019 16:22
            +1

            Про то что асинхронный код это лёгкий способ сделать «нормально по умолчанию» я совершенно согласен. Удобство налицо (я как вспомню «классический» асинк с APM и костыли чтобы это как-то склеить и надёжно обрабатывать ошибки, так зубы скрипят).
            Более того, в .NET Core действительно всё стало ещё лучше (и я не столько про ASP.NET, сколько про стандартную библиотеку и рантайм).

            В PerfView хорошо видно возрастающее количество выделений памяти. Кроме того, видно время CPU, проведённое в async state machine (Folding надо поставить в 0%), плюс переключения контекстов (я не про контексты потоков, а про то, что поток, на котором выполняется continuation, должен восстановить ExecutionContext CLR).
            При профилировании PerfView рекомендую использовать свежую версию, и выключать галочку «TPL», с ней и большим количеством асинхронных операций, часто ETW начинает сильно тормозить (и даже увеличение in-memory буфера не помогает), и искажает результаты.

            Про 100мс и нагрузку — я плохо выразился. Создать-то конечно можно любую нагрузку, вопрос в том, что происходит в эти 100мс. Это время проводится в каком-то другом сервисе, который скорее всего тоже имеет свои ограничения по масштабируемости, и узкое место скорее всего получится не в бэкенде, а в этом сервисе.


            1. alhimik45
              12.01.2019 18:57

              А как быть, если есть внешняя библиотека, имеющая только асинхронный интерфейс (и допустим там внутри запрос к микросервису на 10мс)? Получается если распространить async дальше по стеку вызовов, получим оверхед на ровном месте. Тогда лучше сразу делать GetAwaiter().GetResult() и жить дальше синхронно?


              1. onyxmaster
                12.01.2019 19:39

                В общем случае, библиотеки, которые предоставляют _только_ асинхронный интерфейс вероятно не лучший вариант.
                А так — я бы смотрел на профилирование.


                1. ypastushenko Автор
                  12.01.2019 20:06

                  Очень зависит от библиотеки. Например, коннектору к базе данных вполне стоит быть полностью асинхронным.


              1. ypastushenko Автор
                12.01.2019 20:05
                +2

                10 милисекунд — это где-то на три-четыре порядка больше, чем время, уходящее на создание асинхронщины. В этом случае можно вообще не волноваться.


                1. onyxmaster
                  12.01.2019 23:26

                  Я собрал «на коленке» примитивный бенчмарк, который не согласен с этим утверждением. Вполне вероятно что в нём есть ошибка, но всё же думаю что на него стоит посмотреть.
                  Создание async state machine это не единственный источник оверхеда.


                  1. ypastushenko Автор
                    13.01.2019 11:33

                    Да, что-то я погорячился. Вспомнил про примеры с наносекундами и решил, что оверхед не растет с ростом времени запроса.


            1. ypastushenko Автор
              12.01.2019 20:08

              Спасибо за советы. У нас сейчас как раз висит задачка на профилирование одно из сервисов. Попробуем сделать с PerfView.


    1. kasthack_phoenix
      11.01.2019 18:55

      Там нет таких задач, где они принесут заметную выгоду по производительности.

      Там большая часть как раз проходит в IO — запросах к базе / редису / записи ответа. Отсутствие отдельных потоков ощутимо ускоряет код.


      Эдакой данью моде, заданной node.js

      Await в JS появился на пять лет позже.


      Тот 1% случаев, когда асинхронщина действительно что-то даст кроме гемора — можно было писать и обычными ContinueWith

      Напишите без await хотя бы простой цикл, который асинхронно что-то делает, а потом сравните сложность.


      Уровня:


      foreach(var itemKey in itemKeys)
      {
           var fetched = await fetch(itemKey);
           var dbKey = await saveToDb(fetched);
           await updateCache(dbKey, itemKey, fetched);
      }


  1. jakobz
    12.01.2019 02:30

    Вообще, если совсем коротко, то если вы понимаете async в C# — вам пора переставать писать C#. Вам надо оглядеться — вокруг неизведанные дали. От высоко оплачиваемого руководства джунами — которым с мудрости своей вы запретите использовать async, как и я. До прекрасных монад и аппликативных функторов на React и TypeScript. И горящих глазами мальчиков и девочек, понимающих спинным мозгом монады и аппликативные функторы, и делающих прекрасные аппы.

    Советую совмещать.


    1. 0x1000000
      12.01.2019 11:26

      Task это тоже вроде как монада, а async/await это просто синтаксический сахар. Вообще, работать с
      монадами без специальных конструкций языка не особо удобно, так, например, в хаскеле почти всегда используется do нотация.

      Кстати, ничто вам не мешает написать пару хелперов и работать с тасками через «from… in...» прямо как в Хаскеле :)


  1. darkms5
    12.01.2019 09:46
    +1

    Хотелось бы добавить на тему последнего пункта про «await в однострочном методе», что при возврате task'а напрямую (без использования async-await), вызов такого метода не будет виден в callstack'е при возниковении exception'а где-то в процессе работы «проброшенного» таким образом task'а. В некоторых случаях это может привести к серьёзным проблемам при расследовании подобных ошибок, особенно если в коде довольно много ветвлений, которые в конечном итоге заканчиваются вызовом одного и того же метода (например, вызова API).


    1. ypastushenko Автор
      12.01.2019 10:03

      Спасибо, добавил апдейт.


  1. Ordos
    12.01.2019 15:04
    +1

    Кстати, для ASP.NET Core есть неплохой NuGet пакет Ben.BlockingDetector, который позволяет найти блокировки в асинхронном коде в приложении. Бывает полезно при отладке и поиске проблемных мест.


    1. ypastushenko Автор
      12.01.2019 20:09

      В соседней команде как раз недавно его впилили. Пока наблюдают)


  1. a-tk
    12.01.2019 18:41

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

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