В своей практике я часто встречаю, в различном окружении, код вроде того, что приведен ниже:


[1] var x = FooWithResultAsync(/*...*/).Result;

//или
[2] FooAsync(/*...*/).Wait();

//или
[3] FooAsync(/*...*/).GetAwaiter().GetResult();

//или
[4] FooAsync(/*...*/)
    .ConfigureAwait(false)
    .GetAwaiter()
    .GetResult();

//или
[5] await FooAsync(/*...*/).ConfigureAwait(false)

//или просто
[6] await FooAsync(/*...*/)

Из общения с авторами таких строк, стало ясно, что все они делятся на три группы:


  • Первая группа, это те, кому ничего не известно о возможных проблемах с вызовом Result/Wait/GetResult. Примеры (1-3) и, иногда, (6), типичны для программистов из этой группы;
  • Ко второй группе относятся программисты, которым известно о возможных проблемах, но они не знают причин их возникновения. Разработчики из этой группы, с одной стороны, стараются избегать строк вроде (1-3 и 6), но, с другой, злоупотребляют кодом вроде (4-5);
  • Третья группа, по моему опыту самая малочисленная, это те программисты, которые знают о том, как код (1-6) работает, и, поэтому, могут сделать осознанный выбор.

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



Риски и их причины


Примеры (1-6) делиться на две группы. Первая группа — код с блокировкой вызывающего потока. К этой группе относятся (1-4).
Блокировка потока, чаще всего, плохая идея. Почему? Для простоты будем считать, что все потоки выделяются из некоторого пула потоков. Если в программе присутствует блокировка, то это может привести к выборке всех потоков из пула. В лучшем случае, это замедлит работу программы и приведет к неэффективному использованию ресурсов. В худшем же случае, это может привести к взаимоблокировке(deadlock), когда для завершения некоторой задачи, нужен будет дополнительный поток, но пул его не сможет выделить.
Таким образом, когда разработчик пишет код вроде (1-4), он должен задуматься, на сколько вероятна описанная выше ситуация.


Но все становится гораздо хуже, когда мы работаем в окружении, в котором существует контекст синхронизации, отличный от стандартного. При наличии особого контекста синхронизации блокировка вызывающего потока повышает вероятность возникновения взаимоблокировки многократно. Так, код из примеров (1-3), если он выполняется в UI-потоке WinForms, практически гарантированно создает deadlock. Я пишу "практически", т.к. есть вариант, когда это не так, но об этом чуть позже. Добавление ConfigureAwait(false), как в (4), не даст 100% гарантии защиты от deadlock'a. Ниже приведен пример, подтверждающий это:


[7]
//Некоторая метод библиотечного / стороннего класса.
async Task FooAsync()
{
    // Delay взять для простоты. Может быть любой асинхронный вызов.
    await Task.Delay(5000);

    //Остальную часть кода метода объединим в метод
    RestPartOfMethodCode();
}

//Код в "конечной" точке использования, в данном случае, это WinForms приложение.
private void button1_Click(object sender, EventArgs e)
{
    FooAsync()
        .ConfigureAwait(false)
        .GetAwaiter()
        .GetResult();

    button1.Text = "new text";
}

В статье "Parallel Computing — It's All About the SynchronizationContext" дается информация о различных контекстах синхронизации.


Для того, чтобы понять причину возникновения взаимоблокировки, нужно проанализировать код конечного автомата, в который преобразуется вызов async метода, и, далее, код классов MS. В статье "Async Await and the Generated StateMachine" приводится пример такого конечного автомата.
Не буду приводить полный исходный код, генерируемого для примера (7), автомата, покажу лишь важные для дальнейшего разбора строки:


//Внутри метода MoveNext.
//...
// переменная taskAwaiter определена выше по коду.

taskAwaiter = Task.Delay(5000).GetAwaiter();
if(tasAwaiter.IsCompleted != true)
{
    _awaiter = taskAwaiter;
    _nextState = ...;

    _builder.AwaitUnsafeOnCompleted<TaskAwaiter, ThisStateMachine>(ref taskAwaiter, ref this);
    return;
}

Ветка if выполняется, если асинхронный вызов (Delay) еще не был завершен и, следовательно, текущий поток можно освободить.
Обратите внимание на то, что в AwaitUnsafeOnCompleted передается taskAwaiter полученный от внутреннего (относительно FooAsync) асинхронного вызова (Delay).


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


Код этих, и связанных с ними, классов довольно запутан, поэтому, для облегчения восприятия, я позволю себе написать сильно упрощенный "аналог" того, во что превращается пример (7), но без конечного автомата, и в терминах TPL:


[8]
Task FooAsync()
{
    // Переменная methodCompleted вводится только для того, чтобы подчеркнуть, 
    // что метод завершается тогда, когда будет выполнен некоторый "маркирующий код".
    // В конечном автомате функцию, аналогичную строчке methodCompleted.WaitOne() данного кода,
    // выполняет метод SetResult класса AsyncTaskMethodBuilder,
    // объект которого храниться в поле конечного автомата.
    var methodCompleted = new AutoResetEvent(false);

    SynchronizationContext current = SynchronizationContext.Current;
    return Task.Delay(5000).ContinueWith(
        t=>
            {
                if(current == null)
                {
                    RestPartOfMethodCode(methodCompleted);
                }
                else
                {
                    current.Post(state=>RestPartOfMethodCode(methodCompleted), null);
                    methodCompleted.WaitOne();
                }
            },
            TaskScheduler.Current);
}

//
// void RestPartOfMethodCode(AutoResetEvent methodCompleted)
// {
//     Тут оставшаяся часть кода метода FooAsync.
//   methodCompleted.Set();
// }

В примере (8) важно обратить внимание на то, что при наличии контекста синхронизации, весь код асинхронного метода, который идет после завершения внутреннего асинхронного вызова, выполняется через этот контекст (вызов current.Post(...)). Этот факт и является причиной возникновения взаимоблокировок. Например, если речь идет о WinForms-приложении, то контекст синхронизации в нем связан с UI-потоком. Если UI-поток заблокирован, в примере (7) это происходит через вызов .GetResult(), то оставшаяся часть кода асинхронного метода выполниться не может, а значит, асинхронный метод не может завершиться, и не может освободить UI-поток, что и есть deadlock.


В примере (7) вызов FooAsync был сконфигурирован через ConfigureAwait(false), но это не помогло. Дело в том, что конфигурировать надо именно тот объект ожидания, который будет передан в AwaitUnsafeOnCompleted, в нашем примере это объект ожидания от вызова Delay. Другими словами, в данном случае, вызов ConfigureAwait(false) в клиентском коде не имеет смысла. Решить проблему можно если разработчик метода FooAsync изменит его следующим образом:


[9]
async Task FooAsync()
{
    await Task.Delay(5000).ConfigureAwait(false);
    //Остальную часть кода метода объединим в метод
    RestPartOfMethodCode();
}

private void button1_Click(object sender, EventArgs e)
{
    FooAsync().GetAwaiter().GetResult();

    button1.Text = "new text";
}

Выше мы рассмотрели риски возникающие с кодом первой группы — код с блокировкой (примеры 1-4). Теперь о второй группе (примеры 5 и 6) — код без блокировок. В этом случае возникает вопрос, когда вызов ConfigureAwait(false) оправдан? При разборе примера (7), мы уже выяснили, что конфигурировать надо тот объект ожидания, на основе которого будет построено продолжение выполнения. Т.е. конфигурация требуется (если вы приняли такое решение) только для внутренних асинхронных вызовов.


Кто виноват?


Как всегда, правильным ответом будет "все". Начнем с программистов из MS. С одной стороны, разработчики Microsoft приняли решение, что, при наличии контекста синхронизации, работа должна вестись через него. И это логично, иначе зачем он еще нужен. И, как я полагаю, они ожидали, что разработчики "клиентского" кода не будут блокировать основной поток, тем более в том случае, когда контекст синхронизации на него завязан. С другой стороны, они дали очень простой инструмент чтобы "выстрелить себе в ногу" — слишком просто и удобно получать результат через блокирующие .Result/.GetResult, или блокировать поток, в ожидании завершения вызова, через .Wait. Т.е. разработчики MS сделали так, что "неправильное" (или опасное) использование их библиотек не вызывает каких-либо затруднений.


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


Что делать?


Ниже я привожу мои рекомендации.


Для разработчиков клиентского кода


  1. Всеми силами избегайте блокировок. Другими словами, не смешивайте синхронный и асинхронный код без особой необходимости.
  2. Если приходится делать блокировку, то определите, в каком окружении выполняется код:
    • Есть ли контекст синхронизации? Если да, то какой? Какие особенности в работе он создает?
    • Если контекста синхронизации "нет", то: Какова будет нагрузка? Какова вероятность что ваша блокировка приведет к "утечки" потоков из пула? Хватит ли того числа потоков, что создается на старте, по умолчанию, или надо выделить больше?
  3. Если код асинхронный, то нужен ли вам конфигурировать асинхронный вызов через ConfigureAwait?

Принимайте решение на основе всей полученной информации. Возможно, вам надо пересмотреть подход к реализации. Возможно, вам поможет ConfigureAwait, а может он вам не нужен.


Для разработчиков библиотек


  1. Если вы полагаете, что ваш код может быть вызван из "синхронного", то обязательно реализуйте синхронный API. Он должен быть по-настоящему синхронным, т.е. вы должны пользоваться синхронным API сторонних библиотек.
  2. ConfigureAwait(true / false).

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


Мой вариант заключается в том, чтобы, при реализации асинхронного API, в каждый метод API добавлять два дополнительных входных параметра: CancellationToken token и bool continueOnCapturedContext. И реализовывать код в следующем виде:


public async Task<string> FooAsync(
    /*другие аргументы функции*/,
    CancellationToken token, 
    bool continueOnCapturedContext)
{
    // ...
    await Task.Delay(30, token).ConfigureAwait(continueOnCapturedContext);
    // ...
    return result;
}

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


При этом, если асинхронный метод API будет сам частью другого асинхронного метода, то "клиентский" код сможет определить, как он должен взаимодействовать с контекстом синхронизации:


// Пример вызова в асинхронном коде:
async Task ClientFoo()
{
    // "Внутренний" код ClientFoo учитывает контекст синхронизации, в то время как 
    // внутренний код FooAsync игнорирует контекст синхронизации.
    await FooAsync(
        /*другие аргументы функции*/,
        ancellationToken.None,
        false);

    // Код всех уровней игнорирует контекст.
    await FooAsync(
        /*другие аргументы функции*/,
        ancellationToken.None, 
        false).ConfigureAwait(false);
    //...
}

//В синхронном, с блокировкой.
private void button1_Click(object sender, EventArgs e)
{
    FooAsync(
        /*другие аргументы функции*/,
        _source.Token,
        false).GetAwaiter().GetResult();

    button1.Text = "new text";
}

В качестве заключения


Главный вывод из всего вышеизложенного заключается в следующих трех мыслях:


  1. Блокировки, чаще всего, корень всех зол. Именно наличие блокировок может привести, в лучшем случае, к деградации производительности и неэффективному использованию ресурсов, в худшем — к deadlock-у. Прежде чем использовать блокировки подумайте, нужно ли это? Возможно, есть другой, приемлемый в вашем случае, способ синхронизации;
  2. Изучайте инструмент, с которым работаете;
  3. Если проектируете библиотеки, то старайтесь сделать так, чтобы их правильное использование было легким, почти интуитивным, а неправильное было сопряжено со сложностями.

Я постарался максимально просто объяснить риски связанные с async/await, и причины их возникновения. А также, представил мое видение решения этих проблем. Надеюсь, что это мне удалось, и материал будет полезен читателю. Для того чтобы лучше понять, как все работает на самом деле, надо, конечно, обратиться к исходникам. Это можно сделать через репозитории MS на GitHub или, что даже удобнее, через сайт самого MS.


P.S. Буду благодарен за конструктивную критику.

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


  1. mayorovp
    13.08.2019 22:44
    +1

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

    А зачем этот самый выбор нужен? Варианта-то всего два:


    Вариант 1. Библиотеке нужен контекст синхронизации. Тут и думать нечего — передача false будет ошибкой, допустимо только true.


    Вариант 2. Библиотеке не нужен контекст синхронизации. Но в таком случае у ConfigureAwait(false) нет ни одного недостатка кроме многословности! И раз уж вы решили "украсить" ваш код вызовами ConfigureAwait — то нет смысла передавать туда что-то кроме false.


    Собственно, далее вы приводите примеры клиентских вызовов — и ни в одном из них не передаёте true для параметра continueOnCapturedContext. Случайно ли это? Вы можете привести хоть один пример, когда в качестве этого параметра случает передавать true?


    1. goncharov_a_v Автор
      13.08.2019 22:52
      +1

      Выбор должен быть за клиентским кодом. Клиентский код не имеет доступа к исходникам библиотеки. Если клиентский код, в свою очередь, это тоже код библиотеки, то как ему указать что на всех уровнях надо (или же не надо) работать с контекстом?


      async Task ClientFoo()
      {
          //На всех уровнях учитываем контекст синхронизации.
          await FooAsync(
              /*другие аргументы функции*/,
              ancellationToken.None,
              true);
      }
      

      Пример (7) показывает зачем нужен параметр, если бы разработчик метода FooAsync предусмотрел параметр, то клиент не встал бы на взаимоблокировке. А без параметра, можно лишь надеяться что внутри все верно, либо же писать дополнительный код, для обхода проблемы.


      1. MonkAlex
        13.08.2019 23:23
        +1

        Главный вопрос — какой проблемы? Контекст синхронизации так навскидку обычно завязан на UI.
        Библиотека, которой нужен этот самый контекст, будет использовать везде true (без всяких параметров), которой не нужен — false.
        Учитывая, что асинхронное апи обычно связано с IO операциями, к UI они отношения не имеют и в 99% случаев передача false себя оправдывает.
        Исключения с true обычно явные и заранее так и задуманные.


      1. mayorovp
        14.08.2019 06:42

        А зачем может понадобиться на всех уровнях использовать контекст синхронизации?


        1. lair
          14.08.2019 11:58

          Если есть возможность встроить клиентский код в библиотеку (через события, колбеки, пайплайн, что угодно еще), то этот код может зависеть от контекста, и если перед ним вызвать .ConfigureAwait(false), он расстроится. Но! Библиотека все-таки должна знать, предоставляет она такую функциональность, или нет (и расставлять ConfigureAwait в зависимости от этого).


          Хороший (и грустный пример) — это логирование, которое берет данные для enrichment из Http-контекста, который, в свою очередь, недоступен без синхронизации. А библиотека об этом и не знает, она вообще не для веба писалась. Решение — брать enrichment из другого места, но об этом, блин, тоже подумать надо.


          1. mayorovp
            14.08.2019 12:35

            В таких случаях лучше не с ConfigureAwait баловаться, а явно или неявно (по аналогии с IProgress) передавать в библиотеку сам контекст синхронизации.


            Что же до Http-контекста — в asp.net core он через статическое свойство больше не доступен.


            1. lair
              14.08.2019 13:24
              +1

              К сожалению, логирование — это один из тех (редких) случаев, где явно передать все-таки сложно (без потери выразительности кода).


              1. MonkAlex
                14.08.2019 14:00

                Таки AsyncLocal вроде поможет в такой ситуации? Оно правда тоже не дешево.


                1. lair
                  14.08.2019 14:01

                  Это как раз означает, что кто-то должен подумать, и положить все, что нужно для логирования, в соответствующее хранилище.


  1. SemenPV
    13.08.2019 23:42
    +1

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


    1. Caraul
      15.08.2019 20:42

      Не совсем то, но есть CA2007: Do not directly await a Task — входит в пакет Microsoft.CodeAnalysis.FxCopAnalyzers.


  1. Imbecile
    14.08.2019 03:02

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


    1. andreyverbin
      14.08.2019 10:41

      Контекст синхронизации UI это просто способ запустить произвольный код в потоке UI. Вы передаёте в контекст функцию, он посылает сообщение в очередь сообщений UI потока, обработчик этого сообщения запускает вашу функцию. Можно придумать другие контексты, которые будут запускать ваши функции в пуле потоков или на удалённом сервере или ещё как нибудь.


      Task может выполнять своё продолжение (код после await x.MethodAsync();) либо в пуле потоков, либо с помощью контекста (SynchronisationContext.Current). По умолчанию Task использует контекст. Также по умолчанию в UI приложениях контекст синхронизации выполняет код в UI потоке. В итоге если ничего не делать специально, то продолжение будет вызвано в UI потоке, а именно это обычно и нужно.


      Загляните в официальную документацию, там все подробно описано.


      1. Imbecile
        14.08.2019 10:46

        Спасибо за пояснение. Уже более лучше.
        А по поводу подробно — не значит понятно. Я потому и написал, что хотелось бы более популярного, на пальцах.


    1. goncharov_a_v Автор
      14.08.2019 10:46
      +1

      В статье "Parallel Computing — It's All About the SynchronizationContext" дается информация о различных контекстах синхронизации.


  1. LMSn
    14.08.2019 11:48
    +1

    В корне не согласен с объявлением параметра continueOnCapturedContext в методах библиотечного кода. Это как раз библиотеке решать, нужен ей контекст или нет. Почти всегда — нет, поэтому и рекомендуется использовать .ConfigureAwait(false). Если все же нужен — используем .ConfigureAwait(true), либо вообще его не пишем.
    Клиентскому коду абсолютно ни к чему управлять этим поведением. Если клиентскому коду нужен контекст — он может захватывать его на своем уровне. Библиотечный .ConfigureAwait(false) ему в этом никак не мешает.

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


    1. MonkAlex
      14.08.2019 11:55
      +1

      Всё правильно пишите.

      Я в своё время затупил и явно проверял (не нашел нигде в литературе пояснения) — а достаточно ли на уровне пользователя библиотеки сказать, что нужен контекст. И таки да, достаточно. Что бы там библиотека внутри не делала, если я сказал что после библиотечного вызова верни мне контекст, я хочу UI поменять — всё отлично работает.


      1. mayorovp
        14.08.2019 12:41

        Это связано с тем, что за сохранение контекста отвечает не вызываемый код, а сам оператор await (точнее, скрытая за ним структура-awaiter).


    1. goncharov_a_v Автор
      14.08.2019 12:50
      +1

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


      1. mayorovp
        14.08.2019 13:08
        +1

        Если задаче выделен высокоприоритетный поток — её надо исполнять полностью синхронно.


        Потому что в противном случае избежать частичного попадания кода в пул потоков просто не получится, в стандартной библиотеке есть слишком много мест, которые от него зависят. Даже простейший вызов Task.Delay и тот не способен работать без стандартного пула потоков.


      1. LMSn
        14.08.2019 13:52

        А я ничего не напутаю, если скажу, что контекст синхронизации и исполняющий поток — вещи из разных плоскостей? ConfigureAwait определяет необходимость захвата контекста синхронизации, а не использования того или иного потока. Т.е. использование continueOnCapturedContext для того, чтобы оставить код в потоке — концептуально неверное решение.


        1. goncharov_a_v Автор
          14.08.2019 15:04

          Для того чтобы это понять, достаточно рассмотреть как работает контекст в WinForms, и добавить что очередь на обработку разбирается не одним потоком, а набором потоков из отдельного пула. И ConfigureAwait, в таком случае, сильно связан с вопросом, из какого пула будет взят поток для выполнения задачи.
          Об этом, и не только, написано в статье "Parallel Computing — It's All About the SynchronizationContext". Достаточно просто внимательно ее прочесть.


    1. goncharov_a_v Автор
      14.08.2019 12:52

      Удалил комментарий, дублирует предыдущий.