company_banner

Асинхронный код в Startup ASP.NET Core: 4 способа обхода GetAwaiter().GetResult()

    С тех пор, как в C# 5.0 завезли механизм async/await, нас постоянно во всех статьях и доках учат, что использовать асинхронный код в синхронном очень плохо. И призывают бояться как огня конструкции GetAwaiter().GetResult(). Однако есть один случай, когда сами программисты Microsoft не гнушаются этой конструкцией.



    Предыстория про рабочую задачу


    Сейчас мы находимся в процессе перехода со старой легаси-аутентификации на OAuth 2.0, который фактически уже является стандартом в нашей отрасли. Сервис, над которым я сейчас работаю, стал пилотом для интеграции с новой системой и для перехода на JWT-аутентификацию.

    В процессе интеграции мы экспериментировали, рассматривая разные варианты, как уменьшить нагрузку на провайдер токенов (IdentityServer в нашем случае) и обеспечить большую надёжность всей системы. Подключение валидации на основе JWT в ASP.NET Core осуществляется очень просто и не привязано к конкретной реализации провайдера токенов:

    services
          .AddAuthentication()
          .AddJwtBearer(); 
    

    Но что скрывается за этими двумя строчками? Под их капотом создаётся JWTBearerHandler, который уже и имеет дело с JWT от клиента API.


    Взаимодействие клиента, API и провайдера токенов при запросе

    Когда JWTBearerHandler получает токен от клиента, он не отправляет токен на валидацию провайдеру, а, наоборот, запрашивает у провайдера Signing Key — открытую часть ключа, которым подписан токен. На основании этого ключа убеждается, что токен подписан нужным провайдером.

    Внутри JWTBearerHandler сидит HttpClient, который и взаимодействует с провайдером по сети. Но, если предположить, что Signing Key нашего провайдера не планирует меняться часто, то его можно забрать один раз при запуске приложения, закэшировать себе и избавиться от постоянных сетевых запросов.

    Такой код получения Signing Key получился у меня:

    public static AuthenticationBuilder AddJwtAuthentication(this AuthenticationBuilder builder, AuthJwtOptions options)
    {
        var signingKeys = new List<SecurityKey>();
    
        var jwtBearerOptions = new JwtBearerOptions {Authority = options?.Authority};
        
        new JwtBearerPostConfigureOptions().PostConfigure(string.Empty, jwtBearerOptions);
        try
        {
            var config = jwtBearerOptions.ConfigurationManager
                .GetConfigurationAsync(new CancellationTokenSource(options?.AuthorityTimeoutInMs ?? 5000).Token)
                .GetAwaiter().GetResult();
            var providerSigningKeys = config.SigningKeys;
            signingKeys.AddRange(providerSigningKeys);
        }
        catch (Exception)
        {
            // ignored
        }
    
        builder
            .AddJwtBearer(options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    // ...
                    IssuerSigningKeys = signingKeys,
                    // ...
                };
            });
        return builder;
    }

    В 12 строке мы встречаем .GetAwaiter().GetResult(). Всё потому что AuthenticationBuilder конфигурируется внутри public void ConfigureServices(IServiceCollection services) {...} класса Startup, и метод этот не имеет асинхронного варианта. Беда.

    Начиная с C# 7.1 у нас появился асинхронный Main(). А вот асинхронных методов конфигурирования Startup в Asp.NET Core до сих пор не завезли. Меня эстетически напрягало писать GetAwaiter().GetResult() (меня же учили так не делать!), поэтому я полез в интернет, чтобы поискать, как другие справляются с этой проблемой.

    Меня напрягает GetAwaiter().GetResult(), а Microsoft – нет


    Одним из первых я нашёл вариант, который применили программисты Microsoft в похожей задаче получения секретов из Azure KeyVault. Если спуститься вниз через несколько слоёв абстракции, то мы увидим:

    public override void Load() => LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult();

    Снова здравствуй, GetAwaiter().GetResult()! А нет ли каких-то иных решений?

    После непродолжительного гугления, я нашёл целую серию замечательных статей Эндрю Лока (Andrew Lock), который год назад задумался над тем же самым вопросом, что и я. Даже по тем же причинам — ему эстетически не нравится синхронно вызывать асинхронный код.

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

    Роль асинхронных задач в запуске веб-сервиса


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

    • Валидация строготипизированных конфигов.
    • Заполнение кэша.
    • Предварительное подключение к БД или другим сервисам.
    • JIT и подгрузка Assembly (прогрев сервиса).
    • Миграция БД. Это один из примеров Эндрю Лока, однако он сам признаёт, что всё-таки эту операцию нежелательно выполнять при запуске сервиса.

    Хочется найти такое решение, которое позволит выполнять произвольные асинхронные задачи при старте приложения, причём естественным для них способом, без GetAwaiter().GetResult().

    Эти задачи должны завершиться перед тем, как приложение начнёт принимать запросы, но для своей работы им могут быть необходимы конфигурация и зарегистрированные сервисы приложения. Поэтому выполнение этих задач должно происходить после конфигурации DI.

    Эту идею можно представить в виде схемы:


    Решение №1: рабочее решение, которое может запутать наследников


    Первое рабочее решение, предлагаемое Локом:

    public class Program
    {
       public static async Task Main(string[] args)
       {
           IWebHost webHost = CreateWebHostBuilder(args).Build();
    
           using (var scope = webHost.Services.CreateScope())
           {
               // Получаем нужный сервис
               var myService = scope.ServiceProvider.GetRequiredService<MyService>();
    
               await myService.DoAsyncJob();
           }
    
           await webHost.RunAsync();
       }
    
       public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
           WebHost.CreateDefaultBuilder(args)
               .UseStartup<Startup>();
    }

    Такой подход стал возможен благодаря появлению асинхронного Main() из C# 7.1. Его единственный минус заключается в том, что часть конфигурирования мы перенесли из Startup.cs в Program.cs. Такое нестандартное для ASP.NET-фреймворка решение может запутать человека, которому наш код достанется по наследству.

    Решение №2: встраиваем асинхронные операции в DI


    Поэтому Эндрю предложил улучшенную версию решения. Объявляется интерфейс для асинхронных задач:

    public interface IStartupTask
    {
        Task ExecuteAsync(CancellationToken cancellationToken = default);
    }

    А также метод расширения, регистрирующий эти задачи в DI:

    public static class ServiceCollectionExtensions
    {
        public static IServiceCollection AddStartupTask<T>(this IServiceCollection services)
            where T : class, IStartupTask
            => services.AddTransient<IStartupTask, T>();
    }

    Далее объявляется ещё один метод расширения, уже для IWebHost:

    public static class StartupTaskWebHostExtensions
    {
        public static async Task RunWithTasksAsync(this IWebHost webHost, CancellationToken cancellationToken = default)
        {
            // Получить все асинхронные задачи из DI
            var startupTasks = webHost.Services.GetServices<IStartupTask>();
    
            // Выполнить эти задачи
            foreach (var startupTask in startupTasks)
            {
                await startupTask.ExecuteAsync(cancellationToken);
            }
    
            // Запустить сервис как обычно
            await webHost.RunAsync(cancellationToken);
        }
    }

    И в Program.cs мы меняем только одну строчку. Вместо:

    await CreateWebHostBuilder(args).Build().Run();

    Вызываем:

    await CreateWebHostBuilder(args).Build().RunWithTasksAsync();

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

    Решение №3: для тех, кто перешел на ASP.NET Core 3.x


    Но если вы используете ASP.NET Core 3.x, то есть ещё один вариант. Вновь сошлюсь на статью Эндрю Лока.

    Вот код запуска WebHost из ASP.NET Core 2.x:

    public class WebHost
    {
        public virtual async Task StartAsync(CancellationToken cancellationToken = default)
        {
            // ... initial setup
            await Server.StartAsync(hostingApp, cancellationToken).ConfigureAwait(false);
    
            // Fire IApplicationLifetime.Started
            _applicationLifetime?.NotifyStarted();
    
            // Fire IHostedService.Start
            await _hostedServiceExecutor.StartAsync(cancellationToken).ConfigureAwait(false);
    
            // ...remaining setup
        }
    }

    А вот этот же метод в ASP.NET Core 3.0:

    public class WebHost
    {
        public virtual async Task StartAsync(CancellationToken cancellationToken = default)
        {
            // ... initial setup
    
            // Fire IHostedService.Start
            await _hostedServiceExecutor.StartAsync(cancellationToken).ConfigureAwait(false);
    
            // ... more setup
            await Server.StartAsync(hostingApp, cancellationToken).ConfigureAwait(false);
    
            // Fire IApplicationLifetime.Started
            _applicationLifetime?.NotifyStarted();
    
            // ...remaining setup
        }
    }

    В ASP.NET Core 3.x сначала запускаются HostedServices и только затем основной WebHost, а раньше было ровно наоборот. Что нам это даёт? Теперь все асинхронные операции можно вызывать внутри метода StartAsync(CancellationToken) интерфейса IHostedService и добиться того же эффекта, не создавая отдельных интерфейсов и методов расширения.

    Решение №4: история с health check и Kubernetes


    На этом можно было бы успокоиться, но есть ещё один подход, и он внезапно оказывается важным в текущих реалиях. Это использование health check.

    Основная идея: как можно раньше запустить сервер Kestrel, чтобы сообщить балансировщику нагрузки о том, что сервис готов принимать запросы. Но в то же время все запросы, не связанные с health check, будут возвращать 503 (Service Unavailable). На сайте Microsoft есть достаточно обширная статья про то, как использовать health check в ASP.NET Core. Я же хотел рассмотреть этот подход без особых подробностей в применении к нашей задаче.

    У Эндрю Лока выделена отдельная статья под этот подход. Главное его преимущество в том, что он позволяет избежать сетевых таймаутов.
    Будет лучше, если сервис быстро вернёт ответ на запрос с кодом ошибки, чем не вернёт ничего, приводя к таймауту у клиента. Запуская Kestrel как можно раньше, приложение также может раньше отвечать на запросы, даже если ответы будут по типу «я ещё не готов».

    Не буду приводить здесь полностью решение Эндрю Лока для подхода с health check. Оно достаточно объёмное, но в нём нет ничего сложного.

    Расскажу в двух словах: нужно запустить веб-сервис, не дожидаясь завершения асинхронных операций. При этом health check endpoint должен знать о статусе этих операций, выдавать 503, пока они выполняются, и 200, когда они уже завершились.

    Честно говоря, когда я изучал этот вариант, у меня был определённый скепсис. Всё решение выглядело громоздким по сравнению с предыдущими подходами. А если проводить аналогию, то это как снова использовать EAP-подход с подпиской на события, вместо уже ставшего привычным async/await.

    Но тут в игру вступил Kubernetes. У него есть своя концепция readiness probe. Приведу цитату из книги «Kubernetes in Action» в моём вольном изложении:
    Всегда определяйте readiness probe.

    Если у вас нет readiness probe, ваши поды становятся endpoint’ами сервисов практически мгновенно. Если у вашего приложения слишком много времени занимает подготовка к тому, чтобы принимать входящие запросы, – запросы клиентов к сервису будут в том числе попадать на стартующие поды, которые ещё не готовы принимать входящие соединения. В итоге клиенты получат ошибку «Connection refused».

    Я провёл простейший эксперимент: создал сервис ASP.NET Core 3 c долгой асинхронной задачей в HostedService:

    public class LongTaskHostedService : IHostedService
    {
        public async Task StartAsync(CancellationToken cancellationToken)
        {
                Console.WriteLine("Long task started...");
                await Task.Delay(5000, cancellationToken);
                Console.WriteLine("Long task finished.");
        }
    
        public Task StopAsync(CancellationToken cancellationToken)
        {...}
    }

    Когда я запустил этот сервис с помощью minikube, а потом увеличил количество под до двух, то в течение 5 секунд задержки каждый мой второй запрос выдавал не полезную информацию, а «Connection refused».

    UPD от Kubernetes 1.16
    Когда статья уже была написана, оказалось, что в Kubernetes 1.16 появилась startup probe (специально для таких случаев). Пока что я не разобрался, чем она отличается от readiness probe. Интересующихся отсылаю к документации. Можем вместе обсудить это в комментариях.

    Выводы


    Какой вывод можно сделать из всех этих исследований? Пожалуй, каждый должен сам решить для своего проекта, какое решение подойдёт лучше всего. Если предполагается, что асинхронная операция не будет занимать слишком много времени, а у клиентов есть какая-то политика повторов, то можно использовать все подходы, начиная с GetAwaiter().GetResult() и заканчивая IHostedService в ASP.NET Core 3.x.

    С другой стороны, если вы используете Kubernetes и ваши асинхронные операции могут выполняться ощутимо долгое время, то без health check (он же readiness/startup probe) вам не обойтись.
    Dodo Pizza Engineering
    О том как IT доставляет пиццу

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

      +4
      Недавно решал такую же задачу. Сначала очень хотелось сохранить асинхронность вызовов, делал всякие хаки типа вызова конфигурации после создания хоста, но до его старта. Это даже работало. Но как правильно упомянули — это не очень понятно для тех, кто потом будет читать код.
      В итоге сделал элементарную обёртку:
      public static class RunSyncUtil
          {
              private static readonly TaskFactory factory = new
                  TaskFactory(default,
                      TaskCreationOptions.None,
                      TaskContinuationOptions.None,
                      TaskScheduler.Default);
      
              public static void RunSync(Func<Task> task)
                  => factory.StartNew(task).Unwrap().GetAwaiter().GetResult();
      }

      И в нескольких местах проекта, где раньше вызывался GetAwaiter().GetResult() заменил на такой вызов. Таким оразом мы явным образом берём поток из ThreadPool и уже на нём выполняем блокировку GetResult
      Делать это пришлось не только из-за эстетических соображений, но ещё потому что у нас зависали интеграционные тесты. Дело в том, что сам по себе asp.net core не устанавливает никакой специальный SynchronizationContext, поэтому в нём можно без последствий вызывать GetAwaiter().GetResult(). Но XUnit применяет свой хитрый контекст для контроля параллельного выполнения теста и тут мы легко приходим к дедлоку.
      Кстати, есть баг, который описывает потенциальное решение проблемы github.com/dotnet/aspnetcore/issues/5897 но пока его не хотят исправлять и это печально
        +1
        Да, Ваш вариант тоже можно добавить в копилку. Отдельное спасибо за ссылку на гитхаб. Собственно, основная мысль, которая побудила меня написать статью, что до тех пор, пока у нас не появится официального async Startup, мы все будем вынуждены придумывать свои более или менее очевидные решения.
          +1

          Проблема не только в Startup, ещё есть места, где требуется вызов async кода в синхронных методах. И везде в таких случаях использование GetResult может вести к дедлокам

        +1
        new JwtBearerPostConfigureOptions().PostConfigure(string.Empty, jwtBearerOptions);
        try
        {
        var config = jwtBearerOptions.ConfigurationManager
        .GetConfigurationAsync(new CancellationTokenSource(options?.AuthorityTimeoutInMs ?? 5000).Token)
        .GetAwaiter().GetResult();
        var providerSigningKeys = config.SigningKeys;
        signingKeys.AddRange(providerSigningKeys);
        }

        На сколько я знаю стандартная реализация по умолчанию кэширует конфигурацию, получая конфигурацию через ConfigurationManager при запросе (в асинхронном стеке) JwtBearerPostConfigureOptions
        JwtBearerHandler
        ConfigurationManager
          0
          Нет, не кэширует. Перед тем, как написать этот код, я специально разбирался, как работает хендлер. Не только читал код, но и дебажил.
          Вся суть в том, что JwtBearerHandler региструется транзиентно, поэтому вот это условие при каждом вызове возвращает true, и конфигурация читается заново.
            +1
            Да, но она кэшируется внутри ConfigurationManager, ссылка есть выше
                    public async Task<T> GetConfigurationAsync(CancellationToken cancel)
                    {
                        DateTimeOffset now = DateTimeOffset.UtcNow;
                        if (_currentConfiguration != null && _syncAfter > now)
                        {
                            return _currentConfiguration;
                        }
            
              0
              Посмотрел внимательнее. Похоже, Вы правы. Что-то я не дожал этот вопрос. Что ж, я завтра еще раз перепроверю, и если всё так, то выкину этот кусок кода) Спасибо!

              Что, надеюсь, не отменяет полезности оставшейся части статьи)
                +1
                Разумеется нет) Пусть в данном примере и можно избавиться от вызова асинхронной инициализации, но действительно существуют случаи когда это необходимо.
                Мы в свое время пришли ко второму варианту.
          0
          Бегло прочитал заголовок и думал, когда успел выйти ASP.NET Core 4:)
            +1
            :D
            Вот, скажу нашим сочинителям заголовков, что они должны быть не только завлекательными, но и не вводящими в заблуждение!
            0
            Я прочитал статью, но так и не понял, какие задачи может решить асинхронная процедура инициализации сервиса. Ведь до её завершения сервис всё равно не способен обрабатывать запросы клиентов. Так что уменьшить задержку (для чего обычно полезна асинхронность) между запуском и готовностью отвечать на запросы экземпляра сервиса такая процедура всё равно не позволит.
            Возможность писать await t вместо t.GetAwaiter().GetResult(), на мой взгляд, имеет малую практическую полезность (ну, кроме сокращения числа нажатий на клавиатуре при написании), потому как семантически это почти (если не учитывать вариант IsCompleted==true) одно и тоже. Видимо именно поэтому Microsoft такая проблема и не напрягает.
            Единственная польза, которую я тут смог усмотреть — это борьба с криво написанными/настроенными маршрутизаторами запросов пользователей к серверу (они же, по выполняемой функции — балансировщики нагрузки), которые не способны контролировать доступность экземпляров сервиса и не направлять запросы к недоступным экземплярам, приводящие к большим задержкам ответа клиентам об ошибке. Но почему-то мне кажется, что эту задачу всё равно надо решать как-то по-другому — например, правильным выбором или настройкой балансировщика, — а не перекладывать её на клиента.
            Я не прав?
              +1
              По первому пункту — какой-то принципиальной практической разницы действительно нет, это сугубо вопрос организации кода. Опять же, жили без асинхронного Main(), с ним особо ничего не поменялось, кроме организации кода.
              Про балансировщики — признаюсь, я не очень глубоко копал этот вопрос. Но выглядит так, как будто помимо настройки самого балансировщика необходимо будет настроить и сервис, чтобы весь механизм healthcheck/readiness/startup заработал.
                +1

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

                +1

                Это же Startup, он выполняется один раз при старте. Почему не использовать старый добрый Task.Run с асинхронной лямбдой внутри, который выполнит асинхронный метод в отдельном background-потоке, а мы его просто подождём? Это и в msdn описывалось как универсальный подход для запуска асинхронного метода в синхронном, и я не припомню случая, чтобы это вызывало проблемы.


                Шаблон:


                var value = Task.Run(async () => await GetValueAsync()).Result;

                Или


                Task.Run(async () => await SomeInitializationAsync()).Wait();
                  +1
                  Автору впервую очередь нужен красивый код, а во вторую — рабочее решение.
                    0
                    В точку! Я бы даже обобщил. Это моё сугубо личное мнение, основанное не то чтобы на очень богатом опыте использования, но по факту весь фреймворк ASP.NET — он как раз про организацию кода. А я всегда за то, чтобы код был организован единообразно.
                    +1
                    Можете использовать этот подход вполне. А можете один из тех четырех, что описаны в статье. А можете вариант из первого комментария.
                    Мне вот лично не нравится вызывать асинхронный код в синхронном, если этого можно не делать. Какой-то принципиальной выгоды этот подход не даёт.
                      0
                      async/await разве влияет здесь на что-то?
                      +2
                      Интересно, есть ли разница между:
                      LoadAsync().GetAwaiter().GetResult();
                      и
                      LoadAsync().Result
                      ?
                        +2
                        Есть.
                        GetAwaiter().GetResult() возвращает осмысленный Exception, в то время как просто Result возвращает AggregateException.
                          0
                          Спасибо
                        +1
                        Недавно я тоже наткнулся на startup probe в k8s 1.16.

                        На сколько я понял, отличие от readiness probe заключается в том, что startup probe завершает свою работу после положительного результата (приложение успешно запустилось и готово к работе), в то время как readiness probe работает на протяжении всей жизни приложения и в случае поломки readiness probe k8s перестает давать трафик этому приложению до тех пор, пока readiness probe снова не заработает.
                          +1
                          Асинхронный код в инициализации не нужен
                          Выстраданный правильный способ инициализации долго запускающегося сервиса:
                          1. Определить liveness healthcheck /healthz — отвечать ok всегда когда поднят web-сервер
                          2. Определить rediness healthcheck /healthz/ready — отвечать ok когда все зарегистрированные HealthCheck с тегом ready отвечают что всё в порядке
                          3. Вынести инициализацию в HostedServic'ы, которые по завершению инициализации выставляет флаги готовности, которые уже считывают HealthCheck.
                          4. Однажды введенный в строй сервис выводить из балансировки только по факту завершения
                          5. Настроить liveness probe на /healthz, а rediness probe на /healthz/ready

                          Это стандартный функционал вроде как:
                          docs.microsoft.com/en-US/aspnet/core/host-and-deploy/health-checks
                            +1
                            Это то, что я пытался описать в решениях 3 и 4. Возможно, не так структурировано, как у Вас. И тут всегда остаётся вопрос, что есть «долго запускающийся» сервис. Получение секретов из внешнего хранилища — это долго или нет? По мнению тех, кто писал получение из Azure KeyVault, недолго, раз можно обойтись без healthcheck в этом случае.
                              0
                              Не важно долго или не долго. Если используется Kubernates — то в каждом сервисе должны быть подняты оконечные точки для liveness & rediness. И до каждого разработчика должно быть донесено, что его сервис не будет запущен на production среде если этого не будет сделано.
                              В случае если нет HealthCheck с тегом ready, rediness будет всегда выдавать ок. В дальнейшем как только вам понадобится этот функционал у вас появится HostedService который будет соответствующий чекер устанавливать.
                              Весь каркас занимает дай бог 20 строчек кода.
                              Асинхронный код для двух вещей:
                              1. Высокая нагрузка, как следствие много запросов и потоки не должны ждать на Network/IO и прочих операциях и продолжать заниматься полезным делом
                              2. WPF и графический интерфейс — для удобства
                              Его не надо пихать везде
                                0
                                Как это противоречит тому, что я написал в выводе?
                                  0
                                  Я просто не догнал зачем нужен async в инициализации )

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

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