company_banner

ConfigureAwait, кто виноват и что делать?

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


    [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. Буду благодарен за конструктивную критику.

    Семинары Станислава Сидристого
    165,15
    CLRium #6: Concurrency & Parallelism
    Поделиться публикацией

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

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

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


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


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


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

        +2

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


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

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

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

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

              –1

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


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

                –1

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


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

                  0

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

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

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

          +1
          Всеми силами избегайте блокировок. Другими словами, не смешивайте синхронный и асинхронный код без особой необходимости.
          Может заставить инструмент помочь в этом. Написать Roslyn анализатор, чтобы он заставлял по крайней мере задуматься о написанном решении и без дополнительных усилий не позволял скомпилироваться коду с реализацией нерекомендованного подхода.
          +4
          Ещё бы что-то, где на пальцах про контекст синхронизации. Так чтобы было очень понятно. А то вроде понимаю, но есть ощущение, что не до конца.
            +1

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


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


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

              +1
              Спасибо за пояснение. Уже более лучше.
              А по поводу подробно — не значит понятно. Я потому и написал, что хотелось бы более популярного, на пальцах.
              +1
              В статье "Parallel Computing — It's All About the SynchronizationContext" дается информация о различных контекстах синхронизации.
              +3
              В корне не согласен с объявлением параметра continueOnCapturedContext в методах библиотечного кода. Это как раз библиотеке решать, нужен ей контекст или нет. Почти всегда — нет, поэтому и рекомендуется использовать .ConfigureAwait(false). Если все же нужен — используем .ConfigureAwait(true), либо вообще его не пишем.
              Клиентскому коду абсолютно ни к чему управлять этим поведением. Если клиентскому коду нужен контекст — он может захватывать его на своем уровне. Библиотечный .ConfigureAwait(false) ему в этом никак не мешает.

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

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

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

                  +2

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

                    +2

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


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

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

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

                      0

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

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое