Как вызвать асинхронный метод из синхронного в 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.
Лично я использую этот подход в библиотечных методах, где нельзя менять сигнатуру, но нужна гарантия отсутствия дедлоков.
Подводные камни и чек-лист
Перед тем как выбрать способ, ответь себе на три вопроса:
- Где выполняется синхронный метод? В UI-потоке (WinForms, WPF, Blazor Server) — используй
Task.Runили AsyncHelper. В консоли/фоновом сервисе — можноGetAwaiter().GetResult(). - Можешь ли изменить асинхронный метод? Да — добавь
.ConfigureAwait(false)везде, где возможно. Это снизит риск дедлока. - Это разовый вызов или горячий путь? Для сотен вызовов в секунду лучше переписать всё на async, а не городить синхронные обёртки.
Чего делать НЕ надо: чёрный список
async voidв синхронном методе — исключение словить не сможешь, программа упадёт молча.Thread.Sleepв ожидании задачи — тормозит поток, убивает производительность.Task.Wait()без таймаута — если задача зависнет, твой поток зависнет навсегда.
Итоговый вердикт
Если ты читаешь это и думаешь «а может, ну его, этот синхронный вызов?» — ты прав. Идеальный вариант — сделать весь стек вызовов асинхронным. Но если начальник сказал «сделай быстро» или ты правишь легаси — используй Task.Run + GetAwaiter как золотой стандарт.
Запомни простую табличку: