Асинхронный вызов из синхронного кода в C#: битва подходов

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Как вызвать асинхронный метод из синхронного в C#? Мясо, а не вода

Представь: ты сидишь вечером за кодом, допиливаешь старую консольную утилиту на C#. Всё работает синхронно, как часы. И тут — бац! — тебе нужно дёрнуть API, который сделан только через async Task. Не переписывать же всю программу? Знакомая боль, да?

Вопрос «How to call asynchronous method from synchronous method in C#» — один из самых популярных на Stack Overflow. И не зря. Там, в комментариях, тонны советов от «сделай .Result и не парься» до «ты чё, псих, у тебя же дедлок будет!». Давай разложим по полочкам: какие подходы реально работают, а какие — путь в ад.

Почему просто «await» не работает?

Если ты попытаешься написать:

public void SyncMethod()
{
    await DoSomethingAsync(); // Ошибка CS4032
}

Компилятор просто откажется это компилировать. await можно использовать только внутри метода, помеченного модификатором async. А async void — это отдельная песня, которую мы тоже разберём.

Итак, у нас есть асинхронный метод:

public async Task<string> GetDataAsync()
{
    using var httpClient = new HttpClient();
    return await httpClient.GetStringAsync("https://api.example.com/data");
}

И есть синхронный метод, из которого мы хотим его вызвать. Поехали по вариантам.

Подход 1: GetAwaiter().GetResult() — хакерский шик

Самый популярный и, пожалуй, самый элегантный способ:

public string CallSync()
{
    return GetDataAsync().GetAwaiter().GetResult();
}

Почему это круто:

  • Не генерирует исключение AggregateException (в отличие от .Result). Если внутри асинхронного метода упадёт ошибка, ты получишь её исходный тип, а не обёртку.
  • Код читается нормально, без лишних try-catch.

Но есть нюанс: если твой синхронный метод выполняется в контексте с SynchronizationContext (например, в UI-потоке WinForms или WPF), ты рискуешь получить дедлок — взаимную блокировку потоков. Поток A ждёт выполнения асинхронной задачи, а задача не может завершиться, потому что ждёт, пока поток A освободится. Пиздец.

Как избежать: Используй .ConfigureAwait(false) в самом асинхронном методе. Но это уже меняешь API, а не только вызывающий код.

Подход 2: Task.Run(async () => await ...).GetAwaiter() — вынос в пул потоков

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

public string CallSyncSafe()
{
    return Task.Run(async () => await GetDataAsync()).GetAwaiter().GetResult();
}

Магия: Task.Run запускает делегат на потоке из ThreadPool, у которого нет SynchronizationContext. Дедлок невозможен. Даже если оригинальный метод использует ConfigureAwait(true).

Минус: Лишний переключатель контекста (context switch). Для сотен вызовов — не ок. Для разовых — идеально.

Подход 3: .Result или .Wait() — бабушкин метод

public string CallSyncOldSchool()
{
    return GetDataAsync().Result; // Или .Wait()
}

Чем плохо:

  • При ошибке выбрасывает AggregateException. Чтобы добраться до настоящей ошибки, нужно копаться в InnerException.
  • Всё тот же риск дедлока в UI-контексте.
  • Код выглядит как костыль из 2012 года.

Этот подход — красная тряпка для код-ревью. Если увижу такое в пулл-реквесте — заверну.

Подход 4: AsyncHelper или кастомный RunSync

Бывалые разработчики иногда пишут свои хелперы. Например, такой:

public static class AsyncHelper
{
    private static readonly TaskFactory _taskFactory = new
        TaskFactory(CancellationToken.None,
                    TaskCreationOptions.None,
                    TaskContinuationOptions.None,
                    TaskScheduler.Default);

    public static TResult RunSync<TResult>(Func<Task<TResult>> func)
    {
        return _taskFactory
            .StartNew(func)
            .Unwrap()
            .GetAwaiter()
            .GetResult();
    }
}

// Использование:
var result = AsyncHelper.RunSync(() => GetDataAsync());

Идея: Мы форсируем выполнение задачи на TaskScheduler.Default (пул потоков), а не на текущем контексте. Это надёжно, как швейцарские часы. Но код — не для слабонервных: нужно понимать, как работает Unwrap() и TaskFactory.

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

Подводные камни и чек-лист

Перед тем как выбрать способ, ответь себе на три вопроса:

  1. Где выполняется синхронный метод? В UI-потоке (WinForms, WPF, Blazor Server) — используй Task.Run или AsyncHelper. В консоли/фоновом сервисе — можно GetAwaiter().GetResult().
  2. Можешь ли изменить асинхронный метод? Да — добавь .ConfigureAwait(false) везде, где возможно. Это снизит риск дедлока.
  3. Это разовый вызов или горячий путь? Для сотен вызовов в секунду лучше переписать всё на async, а не городить синхронные обёртки.

Чего делать НЕ надо: чёрный список

  • async void в синхронном методе — исключение словить не сможешь, программа упадёт молча.
  • Thread.Sleep в ожидании задачи — тормозит поток, убивает производительность.
  • Task.Wait() без таймаута — если задача зависнет, твой поток зависнет навсегда.

Итоговый вердикт

Если ты читаешь это и думаешь «а может, ну его, этот синхронный вызов?» — ты прав. Идеальный вариант — сделать весь стек вызовов асинхронным. Но если начальник сказал «сделай быстро» или ты правишь легаси — используй Task.Run + GetAwaiter как золотой стандарт.

Запомни простую табличку:

Блоги

Рекомендаций по книгам