Перейти к содержанию

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), а проблем девять:

start, Ubuntu 26.04, .NET 10.0.12 (три запуска дали одно и то же, кроме мс)
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
start/Program.cs, блоки 1–5
// --- 1 ---
var processed = 0;
var items = new List<int> { 1, 2, 3, 4, 5 };
items.ForEach(async x => { await Task.Delay(100); Interlocked.Increment(ref processed); });
Console.WriteLine($"1) обработано {processed} из {items.Count}");

// --- 2 ---
var log = new List<string>();
SaveAsync(log);
log.Add("после SaveAsync");
await Task.Delay(200);
Console.WriteLine($"2) порядок: {string.Join(" -> ", log)}");

// --- 3 ---
var sw = Stopwatch.StartNew();
var slow = SlowOperationAsync();
var winner = await Task.WhenAny(slow, Task.Delay(200));
Console.WriteLine($"3) таймаут={winner != slow} через {sw.ElapsedMilliseconds} мс; а операция всё ещё идёт: {!slow.IsCompleted}");

// --- 4 ---
sw.Restart();
await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => PollAsync()));
Console.WriteLine($"4) 8 опросов по 3 x 100 мс: {sw.ElapsedMilliseconds} мс");

// --- 5 ---
var repo = new Repository();
Console.WriteLine($"5) данных в репозитории: {repo.Count}");

Подсказка по блокам 1–9 — в таблице ниже; разбор:

  1. async лямбда в List.ForEach (строка 9): ForEach принимает Action, лямбда становится async void, ForEach не ждёт, и Console.WriteLine печатает 0 из 5. Нужен Task.WhenAll(items.Select(async …)).
  2. Забытый await (строка 14): SaveAsync выполняется «в стороне», порядок нарушен. Единственный блок, о котором предупреждает компилятор (CS4014).
  3. WhenAny + Delay как таймаут (строка 22): «таймаут» наступил через ~200 мс, но SlowOperationAsync продолжает работать и дальше (глава 9). Нужен токен, а не гонка с Delay.
  4. Thread.Sleep в async-методе без await (строки 85–89): метод выполняется синхронно в вызывающем потоке, пока не встретит приостановку, а её нет. Восемь «параллельных» опросов выполнились один за другим: 8 × 300 мс = 2400 мс. В final с await Task.Delay — 305 мс.
  5. Асинхронная загрузка в конструкторе (строка 94): _ = LoadAsync(), а Count читают сразу: 0 вместо 3. Фабричный CreateAsync.
  6. await в цикле для независимых запросов: три запроса по 100 мс идут друг за другом (302 мс). WhenAll даёт ~100 мс.
  7. catch (Exception) вокруг await: отмена (OperationCanceledException) проглочена, вызывающий код считает, что всё в порядке.
  8. WhenAll по сотням задач без ограничения: 200 задач одновременно. С Parallel.ForEachAsync(MaxDegreeOfParallelism = 8) ровно 8.
  9. Fire-and-forget: исключение FailAsync нигде не обработано; узнать о нём можно только когда сборщик мусора финализирует задачу и сработает UnobservedTaskException (глава 8). Без GC.Collect() в программе вы о нём вообще не узнали бы.

Факт: async-метод без await не асинхронный

Блок 4 показывает то, что в прошлых главах было «в общем»: async сам по себе не создаёт параллелизма. Пока в методе нет ни одного незавершённого await, он выполняется на потоке вызывающего, включая Thread.Sleep. Поэтому «блокирует поток пула» в таблице ниже верно, когда метод вызван из потока пула, а здесь заблокирован вызывающий поток; результат (потеря параллелизма) тот же.

Факт: final исправляет все девять блоков

final, Ubuntu 26.04, .NET 10.0.12
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' cannot be preserved across 'await' or 'yield' boundary
Забытый 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.

start/Program.cs
// Глава 16. Код с антипаттернами. Каждый блок работает «как будто правильно», но врёт.
// Задание: найдите проблему в каждом блоке, назовите её по таблице из README и исправьте.
// Обратите внимание на предупреждения компилятора при сборке (всего одно: CS4014, а блоков с проблемами девять).
using System.Diagnostics;

// --- 1 ---
var processed = 0;
var items = new List<int> { 1, 2, 3, 4, 5 };
items.ForEach(async x => { await Task.Delay(100); Interlocked.Increment(ref processed); });
Console.WriteLine($"1) обработано {processed} из {items.Count}");

// --- 2 ---
var log = new List<string>();
SaveAsync(log);
log.Add("после SaveAsync");
await Task.Delay(200);
Console.WriteLine($"2) порядок: {string.Join(" -> ", log)}");

// --- 3 ---
var sw = Stopwatch.StartNew();
var slow = SlowOperationAsync();
var winner = await Task.WhenAny(slow, Task.Delay(200));
Console.WriteLine($"3) таймаут={winner != slow} через {sw.ElapsedMilliseconds} мс; а операция всё ещё идёт: {!slow.IsCompleted}");

// --- 4 ---
sw.Restart();
await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => PollAsync()));
Console.WriteLine($"4) 8 опросов по 3 x 100 мс: {sw.ElapsedMilliseconds} мс");

// --- 5 ---
var repo = new Repository();
Console.WriteLine($"5) данных в репозитории: {repo.Count}");

// --- 6 ---
sw.Restart();
var results = new List<int>();
foreach (int id in new[] { 1, 2, 3 })
    results.Add(await FetchAsync(id));
Console.WriteLine($"6) три запроса по 100 мс: {sw.ElapsedMilliseconds} мс");

// --- 7 ---
using var cts7 = new CancellationTokenSource(50);
bool cancelledSeen = false;
try { await Task.Delay(1000, cts7.Token); }
catch (Exception) { /* «что-то пошло не так» */ }
Console.WriteLine($"7) отмену заметили: {cancelledSeen}");

// --- 8 ---
int running = 0, peak = 0;
await Task.WhenAll(Enumerable.Range(0, 200).Select(async _ =>
{
    int now = Interlocked.Increment(ref running);
    InterlockedMax(ref peak, now);
    await Task.Delay(50);
    Interlocked.Decrement(ref running);
}));
Console.WriteLine($"8) одновременно выполнялось задач: {peak}");

// --- 9 ---
var failures = 0;
TaskScheduler.UnobservedTaskException += (_, _) => failures++;
_ = FailAsync();
await Task.Delay(100);
GC.Collect(); GC.WaitForPendingFinalizers();
Console.WriteLine($"9) исключение fire-and-forget заметил: {(failures > 0 ? "да, через GC" : "никто")}");

static async Task<int> FetchAsync(int id) { await Task.Delay(100); return id; }

static async Task FailAsync() { await Task.Delay(10); throw new InvalidOperationException("потерянная ошибка"); }

static void InterlockedMax(ref int target, int value)
{
    int cur;
    while (value > (cur = Volatile.Read(ref target)) && Interlocked.CompareExchange(ref target, value, cur) != cur) { }
}

static async Task SaveAsync(List<string> log)
{
    await Task.Delay(50);
    log.Add("сохранено");
}

static async Task SlowOperationAsync() => await Task.Delay(1000);

static async Task PollAsync()
{
    for (int i = 0; i < 3; i++)
        Thread.Sleep(100);
}

sealed class Repository
{
    private List<int>? _data;
    public Repository() { _ = LoadAsync(); }
    public int Count => _data?.Count ?? 0;
    private async Task LoadAsync() { await Task.Delay(100); _data = [1, 2, 3]; }
}
final/Program.cs
// Глава 16, итог. Те же девять блоков, исправленные.
using System.Diagnostics;

// --- 1. async-лямбда в List.ForEach — это async void. Исправление: собрать задачи и дождаться.
var processed = 0;
var items = new List<int> { 1, 2, 3, 4, 5 };
await Task.WhenAll(items.Select(async x => { await Task.Delay(100); Interlocked.Increment(ref processed); }));
Console.WriteLine($"1) обработано {processed} из {items.Count}");

// --- 2. Забытый await (CS4014). Исправление: await.
var log = new List<string>();
await SaveAsync(log);
log.Add("после SaveAsync");
Console.WriteLine($"2) порядок: {string.Join(" -> ", log)}");

// --- 3. WhenAny + Delay как таймаут: операция не отменяется. Исправление: токен.
var sw = Stopwatch.StartNew();
using (var cts = new CancellationTokenSource(200))
{
    var slow = SlowOperationAsync(cts.Token);
    try { await slow; }
    catch (OperationCanceledException) { }
    Console.WriteLine($"3) отменено через {sw.ElapsedMilliseconds} мс; операция остановлена: {slow.IsCanceled}");
}

// --- 4. Thread.Sleep в async-методе занимает поток пула. Исправление: await Task.Delay.
sw.Restart();
await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => PollAsync()));
Console.WriteLine($"4) 8 опросов по 3 x 100 мс: {sw.ElapsedMilliseconds} мс");

// --- 5. Async-работа в конструкторе. Исправление: асинхронный фабричный метод.
var repo = await Repository.CreateAsync();
Console.WriteLine($"5) данных в репозитории: {repo.Count}");

// --- 6. await в цикле для независимых запросов. Исправление: запустить все, потом WhenAll.
sw.Restart();
int[] fetched = await Task.WhenAll(new[] { 1, 2, 3 }.Select(FetchAsync));
Console.WriteLine($"6) три запроса по 100 мс: {sw.ElapsedMilliseconds} мс (параллельно)");

// --- 7. catch (Exception) глотает отмену. Исправление: отдельно ловить OperationCanceledException.
using var cts7 = new CancellationTokenSource(50);
bool cancelledSeen = false;
try { await Task.Delay(1000, cts7.Token); }
catch (OperationCanceledException) { cancelledSeen = true; }
Console.WriteLine($"7) отмену заметили: {cancelledSeen}");

// --- 8. WhenAll по сотням задач без ограничения. Исправление: Parallel.ForEachAsync с MaxDegreeOfParallelism.
int running = 0, peak = 0;
await Parallel.ForEachAsync(Enumerable.Range(0, 200), new ParallelOptions { MaxDegreeOfParallelism = 8 }, async (_, ct) =>
{
    int now = Interlocked.Increment(ref running);
    InterlockedMax(ref peak, now);
    await Task.Delay(50, ct);
    Interlocked.Decrement(ref running);
});
Console.WriteLine($"8) одновременно выполнялось задач: {peak}");

// --- 9. Fire-and-forget без наблюдения за ошибкой. Исправление: ловим ошибку внутри и логируем.
var failures = 0;
_ = Task.Run(async () =>
{
    try { await FailAsync(); }
    catch (Exception ex) { Interlocked.Increment(ref failures); Console.WriteLine($"   залогировано: {ex.Message}"); }
});
await Task.Delay(100);
Console.WriteLine($"9) ошибок зафиксировано сразу: {failures}");

static async Task<int> FetchAsync(int id) { await Task.Delay(100); return id; }

static async Task FailAsync() { await Task.Delay(10); throw new InvalidOperationException("потерянная ошибка"); }

static void InterlockedMax(ref int target, int value)
{
    int cur;
    while (value > (cur = Volatile.Read(ref target)) && Interlocked.CompareExchange(ref target, value, cur) != cur) { }
}

static async Task SaveAsync(List<string> log)
{
    await Task.Delay(50);
    log.Add("сохранено");
}

static async Task SlowOperationAsync(CancellationToken ct) => await Task.Delay(1000, ct);

static async Task PollAsync()
{
    for (int i = 0; i < 3; i++)
        await Task.Delay(100);
}

sealed class Repository
{
    private readonly List<int> _data;
    private Repository(List<int> data) => _data = data;
    public int Count => _data.Count;

    public static async Task<Repository> CreateAsync()
    {
        await Task.Delay(100);
        return new Repository([1, 2, 3]);
    }
}