Переход от блокирующих вызовов .Result/.Wait() к сквозной асинхронности в устаревшей кодовой базе на C#
Поэтапный план удаления sync-over-async из существующей кодовой базы .NET: инвентаризация анализаторами, измерение истощения ThreadPool, перевод по одной цепочке вызовов и доведение счётчика до нуля на .NET 11.
Удаление sync-over-async из реальной кодовой базы не сводится к поиску с заменой. На сервис в несколько сотен тысяч строк закладывайте от одного до трёх спринтов и рассчитывайте, что работа примет форму серии вертикальных срезов, а не одного огромного PR. Ломаются в основном сигнатуры: каждый метод, переставший блокировать, должен возвращать Task, и это распространяется вверх через интерфейсы, конструкторы, Dispose, блоки lock и публичную поверхность вашего API. Делать это стоит, когда под нагрузкой наблюдается истощение ThreadPool или жёсткие взаимные блокировки на UI-потоке, и стоит отложить, когда блокирующий вызов находится в консольной утилите, которая запускается один раз и завершается. Этот план ориентирован на .NET 11 (Microsoft.NET.Sdk 11.0.0, C# 14); все упомянутые инструменты работают начиная с .NET 6, а шаг трассировки во время выполнения требует .NET 9 или новее.
Почему блокирующие вызовы должны уйти
- Истощение ThreadPool исчезает. Каждый
.Resultна пути обработки запроса паркует поток из пула. Собственное руководство Microsoft по истощению ThreadPool измеряет одну и ту же конечную точку: 3.48 с средней задержки при 125 одновременных соединениях с блокировкой и 532 мс после того, как вызов начинают ожидать. Это не разница в тонкой настройке, это другое приложение. - Жёсткие взаимные блокировки становятся невозможными, а не маловероятными. На потоке WPF, WinForms или классического ASP.NET блокировка на задаче, продолжение которой нуждается в этом же потоке, представляет собой циклическое ожидание. Механизм разобран в статье почему блокировка на асинхронном методе приводит к взаимной блокировке; удаление блокировки убирает весь этот класс ошибок.
- Потребление памяти падает вместе с числом потоков. Пул, стабилизировавшийся на 130 потоках ради компенсации блокировок, удерживает 130 стеков. Переход на асинхронность обычно возвращает счётчик к небольшому кратному числа ядер.
- Отмена начинает работать. Заблокированный поток не может наблюдать
CancellationToken. Как только цепочка становится асинхронной, тайм-ауты и разрывы соединения клиентом действительно распространяются.
Что ломается при переходе на асинхронность
| Область | Изменение | Серьёзность |
|---|---|---|
| Публичная поверхность API | T Get() превращается в Task<T> GetAsync(): ломает исходный код и двоичную совместимость для потребителей | высокая |
| Чужие интерфейсы | Методу интерфейса из стороннего пакета или фреймворка нельзя задать возвращаемый тип Task | высокая |
| Конструкторы, геттеры свойств | Ни то, ни другое не может быть async; работа переносится в фабричный метод или отложенный инициализатор | высокая |
Операторы lock | await внутри lock даёт ошибку компиляции CS1996; нужен SemaphoreSlim | средняя |
| Обработка исключений | AggregateException перестаёт появляться, поэтому catch (AggregateException) молча перестаёт срабатывать | средняя |
TransactionScope | Не проходит через await, если не создан с TransactionScopeAsyncFlowOption.Enabled | средняя |
IDisposable | Асинхронная очистка в Dispose требует IAsyncDisposable и await using | средняя |
| Набор тестов | Синхронные тестовые методы, вызывающие ставший асинхронным код, превращаются в async Task | низкая |
Строки с высокой серьёзностью определяют порядок работ. Всё остальное механично.
Проверки перед стартом
- Решение собирается без ошибок на .NET 6 или новее. Ничего из этого не требует .NET 11, но шаг трассировки во время выполнения нуждается в .NET 9+ ради события
WaitHandleWait. Microsoft.VisualStudio.Threading.Analyzersдобавлен во все проекты или хотя бы в проекты на горячем пути. Именно этот пакет находит блокирующие вызовы в синхронных методах, чего встроенные анализаторы .NET не делают.dotnet-counters,dotnet-traceиdotnet-stackустановлены как глобальные инструменты.- Нагрузочный тест, воспроизводящий симптом. Без него нельзя доказать ни то, что миграция сработала, ни то, что она ничего не ухудшила.
- Стратегия ветвления, допускающая много мелких PR. PR на 400 файлов, меняющий каждую сигнатуру в решении, никто не отревьюит.
Шаги миграции
-
Составьте инвентаризацию анализаторами, а не через grep.
grep -r "\.Result"находит обращения к свойству у всего, что называется Result, и полностью пропускает синхронный ввод-вывод. Включите два правила, которые действительно понимают этот шаблон:# .editorconfig -- .NET 11 SDK 11.0.0 [*.cs] # Avoid problematic synchronous waits (.Result, .Wait(), GetAwaiter().GetResult()) dotnet_diagnostic.VSTHRD002.severity = warning # Call async methods when in an async method dotnet_diagnostic.VSTHRD103.severity = warning # Built-in equivalent; off by default through .NET 10 dotnet_diagnostic.CA1849.severity = warningВ устаревшей кодовой базе это различие важно. CA1849 срабатывает только внутри метода, возвращающего
Task, поэтому в коде, где ещё ничего не асинхронно, он не сообщает почти ни о чём.VSTHRD002срабатывает на блокирующем вызове, где бы тот ни находился, а это ровно та выборка, которую вы пытаетесь пересчитать.Проверка: соберите решение и посчитайте строки
VSTHRD002в выводе. Сохраните это число. Это ваш график сгорания. -
Снимите базовые показатели под нагрузкой до того, как измените хотя бы строку.
Запустите нагрузочный тест и понаблюдайте за пулом:
dotnet-counters monitor -n YourApp System.RuntimeВ .NET 9 и новее нужные счётчики называются
dotnet.thread_pool.thread.count,dotnet.thread_pool.queue.lengthиdotnet.thread_pool.work_item.count. Признак истощения: медленно растущее число потоков при загрузке CPU заметно ниже 100%. Число, стабилизировавшееся выше примерно трёхкратного количества процессоров, означает, что код блокирует потоки пула, а среда выполнения компенсирует это созданием новых.Проверка: запишите стабилизировавшееся число потоков, задержку p95 и число запросов в секунду. С ними вы будете сравнивать на шаге проверки.
-
Найдите блокирующие вызовы, которых не видит анализ исходного кода.
Анализаторы не пометят
File.ReadAllText,SqlCommand.ExecuteReaderилиSemaphoreSlim.Wait(), закопанный в зависимости без исходников. Именно для этого в .NET 9 добавили событиеWaitHandleWait:dotnet trace collect -n YourApp --clrevents waithandle --clreventlevel verbose --duration 00:00:30Откройте полученный файл
.nettraceв PerfView или в сообществом сделанном .NET Events Viewer и разверните стекиWaitHandleWaitStart. Любой стек, в нижних кадрах которого упоминаютсяThreadPoolWorkQueue.DispatchилиWorkerThread.WorkerThreadStart, это заблокированный поток пула, а кадр над ожиданием называет ваш метод.Проверка: каждый стек в трассировке либо соответствует месту вызова, уже попавшему в инвентаризацию из шага 1, либо добавляется в неё.
-
Переводите цепочку вызовов целиком, а не отдельный файл.
Выберите одну самую горячую точку входа из шага 3. Начните с листа (метода, который реально вызывает
HttpClientили EF Core), заведите ему асинхронного близнеца и поднимайтесь по стеку, переводя каждого вызывающего, пока не дойдёте до метода, который может использоватьawait, не имея собственного вызывающего: действия контроллера,BackgroundService.ExecuteAsync, обработчика события илиMain.// .NET 11, C# 14 -- before: the block is three frames below the controller public IActionResult GetOrder(int id) { var order = _repository.Get(id); // sync wrapper return Ok(order); } // after: no wrapper, no block, Task all the way to the framework public async Task<IActionResult> GetOrderAsync(int id, CancellationToken ct) { var order = await _repository.GetAsync(id, ct); return Ok(order); }Частичный перевод на этом пути хуже, чем никакой. Один оставшийся
.Resultв любом месте синхронного участка возвращает и взаимную блокировку, и припаркованный поток, поэтому срез считается готовым только тогда, когда он доходит до точки входа.Проверка: повторите трассировку из шага 3 только для этой конечной точки. Ноль событий
WaitHandleWaitна потоках пула для этого стека. -
Удаляйте синхронного близнеца, а не храните оба.
Соблазнительный обходной путь: оставить
Get()на месте в видеGetAsync().GetAwaiter().GetResult(), чтобы больше ничего не менять. Это как раз та синхронная обёртка, против которой возражает Стивен Тоуб в статье Should I expose synchronous wrappers for asynchronous methods?, и в миграции она откровенно вредна: именно в обёртке прячутся оставшиеся блокирующие вызовы, и она навсегда позволяет вызывающим уклоняться от работы.Если у вас действительно есть и синхронный, и асинхронный потребитель и ни от одного нельзя отказаться, используйте вместо обёртки шаблон с флаговым аргументом, который применяет сама BCL:
// .NET 11, C# 14 -- one implementation, two entry points, no sync-over-async public int Read(byte[] buffer) => ReadCoreAsync(buffer, sync: true).GetAwaiter().GetResult(); public Task<int> ReadAsync(byte[] buffer) => ReadCoreAsync(buffer, sync: false); private async Task<int> ReadCoreAsync(byte[] buffer, bool sync) { // Every I/O call inside branches on `sync`, so the synchronous path // never awaits an incomplete task and cannot deadlock. return sync ? _stream.Read(buffer) : await _stream.ReadAsync(buffer); }Проверка: синхронная точка входа больше не появляется в трассировке
WaitHandleWait, потому что она никогда не ждёт незавершённую задачу. -
Разберитесь со стыками, которые действительно не могут стать асинхронными.
Таких в каждой миграции три. Конструктор не может быть
async, поэтому перенесите инициализацию в статическую фабрику (public static async Task<Foo> CreateAsync()) или в полеLazy<Task<T>>, которое ожидают вызывающие.Dispose, выполняющий асинхронную очистку, должен реализоватьIAsyncDisposableи потребляться через await using. Блокlockс новой асинхронной работой не компилируется с ошибкойCS1996, потому что монитор должен освобождаться тем же потоком, который его захватил:// .NET 11, C# 14 -- lock cannot span an await; SemaphoreSlim can private readonly SemaphoreSlim _gate = new(1, 1); public async Task<Config> LoadAsync(CancellationToken ct) { await _gate.WaitAsync(ct); try { return _cached ??= await FetchAsync(ct); } finally { _gate.Release(); } }Проверка: проект компилируется без
CS1996и без новыхasync voidвне обработчиков событий. -
Протяните CancellationToken, пока сигнатуры и так открыты.
Добавление
CancellationToken ct = defaultничего не стоит в сигнатуре, которую вы всё равно меняете, а вносить его задним числом мучительно. Передавайте токен в каждый асинхронный вызов цепочки, а не только в самый внешний, по правилам из статьи как протянуть CancellationToken через асинхронные методы.Проверка: отмените запрос на лету (оборвите соединение клиента) и убедитесь, что вызов к базе данных действительно прерывается, а не доходит до конца.
-
Зафиксируйте анализатор храповиком, чтобы счётчик мог только уменьшаться.
Как только проект дошёл до нуля, заприте его:
<!-- Directory.Build.props -- .NET 11 SDK 11.0.0 --> <PropertyGroup> <TreatWarningsAsErrors>false</TreatWarningsAsErrors> <WarningsAsErrors>$(WarningsAsErrors);VSTHRD002;CA1849</WarningsAsErrors> </PropertyGroup>Для проектов, находящихся в середине миграции, держите правила на уровне
warningи роняйте CI при росте счётчика, а не при любом предупреждении. Храповик, блокирующий новый долг, пока старый выгорает, единственная версия этой практики, которую команды действительно соблюдают.Проверка: добавьте намеренный
.Resultв уже переведённый проект и убедитесь, что сборка падает.
Как убедиться, что миграция действительно сработала
Компилирующиеся сигнатуры доказательством не являются. Запустите тот же нагрузочный тест из шага 2 и сравните четыре числа:
- Число потоков ThreadPool должно стабилизироваться около небольшого кратного числа ядер, а не расти до сотен.
- Задержка p95 под нагрузкой должна приблизиться к задержке одиночного запроса. Конечная точка из руководства по истощению вернулась с 3.48 с примерно к своим 500 мс без нагрузки.
- Пропускная способность должна вырасти, часто на порядок, потому что те же потоки теперь обслуживают гораздо больше запросов.
- События
WaitHandleWaitна потоках пула должны быть близки к нулю на переведённых путях.
Затем прогоните функциональные проверки: dotnet test с нулём падений, тест на отмену, доказывающий, что разрыв соединения клиентом прерывает нижележащий вызов, и ручной просмотр всех блоков catch (AggregateException) в затронутом коде, поскольку после исчезновения блокирующих вызовов они уже ни с чем не совпадают.
План отката
По срезам эта миграция откатывается чисто: каждый вертикальный срез представляет собой самодостаточный PR, и его откат восстанавливает блокирующий вызов вместе с сигнатурами. Это главный аргумент в пользу нарезки по цепочкам вызовов, а не по слоям.
Что не откатывается чисто, так это опубликованная библиотека. Замена T Get() на Task<T> GetAsync() ломает двоичную совместимость для всех потребителей, скомпилированных против прежней сборки, поэтому для пакета NuGet это миграция мажорной версии, и откат должен быть новым релизом, а не git revert. Решите до начала работ, будет ли пакет одну мажорную версию поставлять обе поверхности (через шаблон с флаговым аргументом из шага 5, но никогда через синхронную обёртку) или сломает совместимость разом.
Ловушки, стоившие нам времени
async void возвращается через лямбды. Лямбда, переданная в параметр типа Action, становится async void, поэтому исключения внутри неё роняют процесс, а не всплывают на задаче. List<T>.ForEach(async x => ...) и Parallel.ForEach с асинхронным телом это два типичных переносчика. VSTHRD101 ловит случай с делегатом; граница между законным и сломанным использованием разобрана в статье когда async void корректен, а когда это ловушка.
.Select(async x => ...) порождает IEnumerable<Task>, а не результаты. Оно компилируется, выглядит переведённым, и ничто его не ожидает. Дополните конструкцию await Task.WhenAll(...) или переведите перечисление на IAsyncEnumerable.
TransactionScope молча перестаёт распространяться. Конструктор по умолчанию не проносит окружающую транзакцию через await, поэтому код после первого await выполняется вне транзакции и без единой ошибки. Создавайте его с TransactionScopeAsyncFlowOption.Enabled.
ASP.NET Core начнёт бросать исключения раньше, чем вы закончите. Перевод внешних слоёв может обнажить InvalidOperationException: Synchronous operations are disallowed из синхронного Stream.Read где-то ниже, потому что AllowSynchronousIO по умолчанию равно false. Это исключение представляет собой карту оставшейся работы, а не повод вернуть переключатель обратно; подробности в статье как исправить synchronous operations are disallowed.
Блокировка на ValueTask это неопределённое поведение, а не просто медленно. Если переведённый лист возвращает ValueTask<T>, а какой-то вызывающий выше по стеку всё ещё блокирует, то .Result на нём даёт неопределённое поведение, а не только риск взаимной блокировки. Преобразуйте на этой границе через .AsTask(), пока вызывающий не переведён, и прочитайте ограничения в статье во что обходится ValueTask.
Не используйте ConfigureAwait(false) как замену доведению работы до конца. Он обезвреживает взаимную блокировку внутри вашей собственной библиотеки, но никак не помогает припаркованному потоку, а в ASP.NET Core и вовсе нет контекста, от которого можно было бы отказаться. Это смягчение для кода, который вы не можете изменить, а не стратегия миграции.
Мерилом успеха служит не обнуление счётчика анализатора. Им служит то, что число потоков пула перестало расти под нагрузкой, а отменённый запрос теперь действительно что-то отменяет.
Похожие статьи
- Fix: взаимная блокировка при вызове .Result или .Wait() на асинхронном методе в C#
- .Result vs .Wait() vs GetAwaiter().GetResult() vs await в C#
- Как протянуть CancellationToken через асинхронные методы в .NET 11
- Когда async void корректен, а когда это ловушка в C#
- lock vs Monitor vs SemaphoreSlim vs System.Threading.Lock в C#
Источники
- Debug ThreadPool starvation — Microsoft Learn
- CA1849: Call async methods when in an async method — Microsoft Learn
- VSTHRD002: Avoid problematic synchronous waits — Microsoft.VisualStudio.Threading
- Should I expose synchronous wrappers for asynchronous methods? — Stephen Toub
- CS1996: Cannot await in the body of a lock statement — Microsoft Learn
- Don’t Block on Async Code — Stephen Cleary
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.