16. Ловушки и антипаттерны¶
О главе
Цель: узнавать антипаттерны в чужом коде, знать замену и понимать, какие из них поймает компилятор или анализатор, а какие нет.
Лабораторная: start/ — девять блоков кода, каждый «работает», но врёт. final/ — исправленные. Запуск: dotnet run -c Release --project start (или final).
Статус: ✅ проверено на стенде Ubuntu 26.04 (2 ядра, .NET 10.0.12): блоки запущены по три раза, диагностики компилятора и анализаторов сняты сборкой. Перепроверено на Windows 11 (8 ядер): вывод совпадает, отличаются только миллисекунды.
16.1. Девять блоков: найдите проблему¶
Запустите start. Сборка даёт одно предупреждение (CS4014 в блоке 2), а проблем девять:
1) обработано 0 из 5
2) порядок: после SaveAsync -> сохранено
3) таймаут=True через 201 мс; а операция всё ещё идёт: True
4) 8 опросов по 3 x 100 мс: 2408 мс
5) данных в репозитории: 0
6) три запроса по 100 мс: 302 мс
7) отмену заметили: False
8) одновременно выполнялось задач: 200
9) исключение fire-and-forget заметил: да, через GC
Подсказка по блокам 1–9 — в таблице ниже; разбор:
asyncлямбда вList.ForEach(строка 9):ForEachпринимаетAction, лямбда становитсяasync void,ForEachне ждёт, иConsole.WriteLineпечатает 0 из 5. НуженTask.WhenAll(items.Select(async …)).- Забытый
await(строка 14):SaveAsyncвыполняется «в стороне», порядок нарушен. Единственный блок, о котором предупреждает компилятор (CS4014). WhenAny+Delayкак таймаут (строка 22): «таймаут» наступил через ~200 мс, ноSlowOperationAsyncпродолжает работать и дальше (глава 9). Нужен токен, а не гонка сDelay.Thread.Sleepвasync-методе безawait(строки 85–89): метод выполняется синхронно в вызывающем потоке, пока не встретит приостановку, а её нет. Восемь «параллельных» опросов выполнились один за другим: 8 × 300 мс = 2400 мс. Вfinalсawait Task.Delay— 305 мс.- Асинхронная загрузка в конструкторе (строка 94):
_ = LoadAsync(), аCountчитают сразу: 0 вместо 3. ФабричныйCreateAsync. awaitв цикле для независимых запросов: три запроса по 100 мс идут друг за другом (302 мс).WhenAllдаёт ~100 мс.catch (Exception)вокругawait: отмена (OperationCanceledException) проглочена, вызывающий код считает, что всё в порядке.WhenAllпо сотням задач без ограничения: 200 задач одновременно. СParallel.ForEachAsync(MaxDegreeOfParallelism = 8)ровно 8.- Fire-and-forget: исключение
FailAsyncнигде не обработано; узнать о нём можно только когда сборщик мусора финализирует задачу и сработаетUnobservedTaskException(глава 8). БезGC.Collect()в программе вы о нём вообще не узнали бы.
Факт: async-метод без await не асинхронный
Блок 4 показывает то, что в прошлых главах было «в общем»: async сам по себе не создаёт параллелизма. Пока в методе нет ни одного незавершённого await, он выполняется на потоке вызывающего, включая Thread.Sleep. Поэтому «блокирует поток пула» в таблице ниже верно, когда метод вызван из потока пула, а здесь заблокирован вызывающий поток; результат (потеря параллелизма) тот же.
Факт: final исправляет все девять блоков
1) обработано 5 из 5
2) порядок: сохранено -> после SaveAsync
3) отменено через 225 мс; операция остановлена: True
4) 8 опросов по 3 x 100 мс: 309 мс
5) данных в репозитории: 3
6) три запроса по 100 мс: 109 мс (параллельно)
7) отмену заметили: True
8) одновременно выполнялось задач: 8
залогировано: потерянная ошибка
9) ошибок зафиксировано сразу: 1
На Windows 11 (8 ядер) то же самое в трёх запусках start и трёх final: все девять результатов, включая «0 из 5», порядок вызовов, 200 и 8 одновременных задач и «заметил через GC», совпадают. Отличаются только миллисекунды, в сторону «больше»: блок 4 в start — 2 525–2 535 мс против 2 408 на Linux, таймаут — 206 мс против 201, final — 107–108 мс против 109 и 318–330 мс против 309 в блоке 4. Причина та же, что в главе 10: на Windows таймеры и Thread.Sleep округляются вверх до ≈ 15,6 мс (8 вызовов Thread.Sleep(300) растянулись на ≈ 17 мс каждый).
16.2. Таблица антипаттернов¶
| Антипаттерн | Чем плох | Что делать |
|---|---|---|
.Result / .Wait() / GetAwaiter().GetResult() в асинхронном коде |
дедлок при контексте, голодание пула без него | await, «async all the way» |
async void вне обработчика события |
исключения не поймать, процесс может упасть, нельзя дождаться | async Task |
Task.Run вокруг I/O в ASP.NET Core |
лишний поток, нет выигрыша | вызывать async-API напрямую |
Fire-and-forget (_ = DoAsync();) |
потерянные исключения, незавершённая работа при остановке, scope запроса умирает | очередь (Channel) + BackgroundService, логирование ошибок |
Забытый await |
задача бежит «в стороне», ошибки теряются, гонки | компилятор предупреждает CS4014; сделать ошибкой |
await в цикле, где можно параллельно |
лишняя последовательность | собрать задачи и WhenAll (с ограничением) |
Task.WhenAll по тысячам задач |
перегрузка зависимости | Parallel.ForEachAsync / SemaphoreSlim |
async внутри lock |
не компилируется (CS1996) | SemaphoreSlim |
async лямбда в List.ForEach / Action |
это async void |
цикл await или WhenAll |
await Task.WhenAny(task, Task.Delay(t)) для таймаута |
задача не отменяется, таймер висит | WaitAsync или токен |
ConfigureAwait(false) не везде в библиотеке |
в одной цепочке остаётся захват контекста | на каждом await либо анализатор CA2007 |
Task в конструкторе (ctor ждёт async) |
нельзя await в конструкторе |
фабричный метод static async Task<T> CreateAsync() |
| Асинхронность «по умолчанию» для чисто синхронного кода | лишняя машина состояний | возвращать значение или Task.FromResult |
Не передан CancellationToken |
запрос обрывается, а работа идёт | протягивать токен |
Ловить Exception вокруг await и глотать OperationCanceledException |
скрывает отмену | catch ... when (ct.IsCancellationRequested) |
Thread.Sleep в async-методе |
блокирует поток пула | await Task.Delay |
16.3. Что поймает компилятор, что анализаторы, а что только вы¶
Проверка: сборка с <AnalysisMode>All</AnalysisMode> и пакетом Microsoft.VisualStudio.Threading.Analyzers (VSTHRD) на коде с типичными нарушениями (.NET SDK 10.0.401).
| Нарушение | Кто сообщил | Диагностика |
|---|---|---|
await внутри lock |
компилятор, ошибка | CS1996: Cannot await in the body of a lock statement |
Span<T> живёт через await |
компилятор, ошибка | CS4007: Instance of type 'System.Span |
Забытый await (Forgotten();) |
компилятор | CS4014 (предупреждение; можно сделать ошибкой) |
.Result / .Wait() в async-методе |
анализаторы | CA1849 и VSTHRD103: synchronously blocks |
Thread.Sleep, File.ReadAllText, Stream.Write в async-методе |
анализаторы | CA1849, VSTHRD103 |
async void |
VSTHRD | VSTHRD100: Avoid "async void" methods |
| Токен не передан дальше | анализаторы | CA2016: Forward the 'ct' parameter… |
await без ConfigureAwait |
анализаторы | CA2007 (только при AnalysisMode=All, по умолчанию выключено) |
Метод возвращает Task, но без суффикса Async |
VSTHRD | VSTHRD200 |
Факт: большинство антипаттернов таблицы ловит только человек
На том же тестовом коде не сообщили ничего: _ = Task.Run(...) (fire-and-forget с явным _), async лямбда в Action (блок 1), WhenAny + Delay, catch (Exception), повторный await одного ValueTask, который лежит в локальной переменной (CA2012 в этом случае молчит), неограниченный WhenAll. CA2007 по умолчанию выключен. Анализаторы закрывают блокировки и токены; остальное — код-ревью и эта таблица.
Включить стоит: CS4014 как ошибку (<WarningsAsErrors>CS4014</WarningsAsErrors>), Microsoft.VisualStudio.Threading.Analyzers, а CA2007 — только в библиотеках.
Факт: Span<T> в async-методе разрешён, пока не живёт через await
Метод с Span<int> s = stackalloc int[4]; s[0] = 1; await Task.Delay(1); (спан не используется после await) компилируется. То же с ref-локалью, которая не переживает await. Ошибка CS4007 возникает только тогда, когда значение нужно после await. Для хранения через await — Memory<T>.
Асинхронные деструкторы и Dispose. Финализатор нельзя сделать асинхронным. Используйте IAsyncDisposable и await using. Нельзя вызывать .Wait() внутри Dispose для асинхронной очистки (риск дедлока), лучше реализовать оба интерфейса.
Итоги¶
- Компилятор ловит мало: CS4014, CS1996, CS4007. Остальное — анализаторы (блокировки, токены) и ревью.
asyncбез реальной приостановки выполняется синхронно:Thread.Sleepв нём блокирует вызывающего.- Fire-and-forget,
WhenAny+Delay,catch (Exception)иWhenAllбез ограничений — антипаттерны, о которых тулинг молчит. - Каждый блок лабораторной проверяется числом: 0 из 5, 2408 мс против 305, 200 против 8 одновременных задач.
Код лабораторной¶
Запуск из папки главы: dotnet run -c Release --project start или --project final.
| final/Program.cs | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 | |