Практические лучшие практики многопоточности: издание .NET — что решить, прежде чем добавлять потоки
· Го Комура · Windows, Многопоточность, C#, .NET, Бизнес-приложения, Расследование ошибок, Проектирование
«Обработка шла медленно, поэтому мы подняли потоки, чтобы распараллелить, — и теперь итоги агрегации иногда расходятся.» «Добавили фоновую обработку, и раз в месяц приложение зависает.» «Говорят, под отладчиком не воспроизводится, но у заказчика это точно происходит.» — Страх многопоточного программирования в том, что сразу после написания код выглядит правильным. Ошибки гонки зависят от тайминга: они проскакивают тесты и показывают лицо только в продакшене.
Вместе с тем, когда многоядерность стала нормой, даже в бизнес-приложениях нельзя обойти многопоточность там, где требуют «крутить тяжёлую обработку, не замораживая UI» или «обрабатывать несколько устройств или файлов параллельно». Важно решить принципы проектирования до того, как добавлять потоки. Ошибки многопоточности не выжигают отладкой — их проектируют так, чтобы им некуда было просочиться.
Эта статья — издание .NET практической серии о многопоточности. Она адресована разработчикам бизнес-приложений на Windows, которым понадобилась многопоточность, и собирает принципы проектирования, которые держатся независимо от языка и ОС, вместе с конкретным инструментарием C#/.NET, опираясь на первоисточники по состоянию на август 2026. Сами принципы не меняются ни на Linux, ни в C++. Если вы пишете нативный код, те же принципы, разложенные на инструменты каждого языка, есть в «издании C++» и «издании C»; если пишете на Java — в «издании Java».
1. Сначала суть
- Первая лучшая практика — не создавать потоки самим. Вместо
new Threadсадитесь на API более высокого уровня — Task, пул потоков,Parallel— и оставляйте управление числом потоков среде выполнения.12 - Первое, что стоит урезать при распараллеливании, — «разделяемое изменяемое состояние». Места, где несколько потоков пишут в одну переменную, — источник гонок; прежде чем защищать их блокировками, сократите само разделение через разбиение данных, неизменяемость и передачу.3
- Соблюдайте дисциплину блокировок. Решите взаимно однозначно, «какие данные какая блокировка защищает», и сделайте объект блокировки выделенным экземпляром, невидимым снаружи.
lock(this)иlock(typeof(X))запрещены. Начиная с .NET 9 используйте выделенный типSystem.Threading.Lock.4 - Передачу данных между потоками сводите к очереди. Схема производитель/потребитель на
System.Threading.Channelsили потокобезопасных коллекциях проще в проектировании, чем сеть блокировок повсюду, и даёт ясную границу.56 - Сначала спроектируйте, как останавливаться. Для остановки единственный верный ответ — кооперативная отмена через
CancellationToken;Thread.Abortна .NET (линейка Core) бросает исключение времени выполнения.78 - UI принадлежит исключительно UI-потоку. Ни элементы управления WinForms, ни элементы WPF нельзя трогать с любого потока, кроме того, который их создал. С другого потока запрашивайте через
Control.Invoke/Dispatcher.910 - «Параллельно — значит быстрее» не всегда верно. Цикл, где работа одной итерации мала, из-за накладных расходов распараллеливания может стать медленнее. Всегда измеряйте, прежде чем принимать.3
2. Почему многопоточность трудна — гонки и взаимные блокировки
Если свести к сути, проблемы, которые вводит многопоточность, бывают двух видов.4
Гонка (race condition) — ошибка, при которой результат меняется в зависимости от того, в каком порядке несколько потоков доходят до конкретного участка кода. Классический пример — инкремент общего счётчика: одна строка count++ на деле распадается на три шага — «чтение → сложение → запись назад». Если два потока выполняют эти три шага одновременно, запись одного перезаписывает сложение другого, и сложение теряется. Результат меняется от запуска к запуску, и какой получится — непредсказуемо.4
sequenceDiagram
accTitle: Гонка на общем счётчике
accDescr: Классическая гонка, в которой сложение общего счётчика теряется. Если другой поток вклинивается в три шага count++, какая бы запись назад ни случилась последней, она перезаписывает другую
participant A as Поток A
participant M as Общая переменная count
participant B as Поток B
Note over M: count = 10
A->>M: Чтение (10)
B->>M: Чтение (10)
A->>A: Локальное сложение (11)
B->>B: Локальное сложение (11)
A->>M: Запись назад (11)
B->>M: Запись назад (11)
Note over M: Два сложения произошли, но count = 11<br/>сложение потока A потеряно
Рис. 1: Классическая гонка, в которой сложение общего счётчика теряется. Если другой поток вклинивается в три шага count++, какая бы запись назад ни случилась последней, она перезаписывает другую
Взаимная блокировка (deadlock) — состояние, в котором два потока каждый ждут блокировку, которую держит другой, и ни один не может продвинуться. Поток A держит блокировку 1 и ждёт блокировку 2; поток B держит блокировку 2 и ждёт блокировку 1 — этого одного достаточно, чтобы оба остановились навсегда.4
flowchart LR
accTitle: Циклическое ожидание взаимной блокировки
accDescr: Циклическое ожидание взаимной блокировки. В момент, когда стрелки ожидания образуют кольцо, каждый поток в этом кольце останавливается навсегда
A["Поток A<br/>держит блокировку 1"] -->|"ожидание освобождения блокировки 2"| B["Поток B<br/>держит блокировку 2"]
B -->|"ожидание освобождения блокировки 1"| A
Рис. 2: Циклическое ожидание взаимной блокировки. В момент, когда стрелки ожидания образуют кольцо, каждый поток в этом кольце останавливается навсегда
Неудобство обоих в том, что они зависят от тайминга. Переплетение (конкретная комбинация порядка выполнения), которое на машине разработки бьёт раз в десятки тысяч запусков, на машине заказчика с другим числом ядер и другим таймингом может случаться каждый день. «Не воспроизводится с подключённым отладчиком» и «пропало, когда я добавил логирование» происходят оба потому, что само наблюдение меняет тайминг — это классическое поведение ошибок гонки.
Именно поэтому каждый принцип дальше смотрит в одну сторону: прежде чем «синхронизировать правильно», сокращать места, которые требуют синхронизации — это основной принцип многопоточного проектирования.
3. Принцип 1: не создавать потоки самим
3.1. Садиться на Task и пул потоков
Писать поток напрямую через new Thread(...) в сегодняшнем .NET — исключительное последнее средство. Начиная с .NET Framework 4 рекомендованное средство для многопоточного и параллельного кода — TPL (Task Parallel Library), то есть семейство API вокруг Task. TPL динамически подстраивает степень параллелизма под доступные процессоры и берёт на себя всю низкоуровневую рутину: деление работы, планирование на пул потоков, отмену, управление состоянием.1
Пул потоков — инфраструктура, которой само .NET пользуется широко: выполнение Task, завершение асинхронного I/O, колбэки таймеров и многое другое. Пока вы бросаете ему короткие куски работы, разработчику не нужно самому управлять жизненным циклом потоков.2
// Тяжёлая CPU-обработка в фоне
var result = await Task.Run(() => HeavyCalculation(input));
// Несколько независимых обработок параллельно и ждать все (когда элементов мало)
// * Такая форма предполагает, что ProcessAsync — асинхронный метод с доминированием I/O.
// WhenAll только «ждёт уже запущенные Task», поэтому если нужно параллелить
// CPU-вычисления, оберните каждую обработку в Task.Run(() => Calc(x)) и положите на пул потоков
var results = await Task.WhenAll(items.Select(x => ProcessAsync(x)));
// Если элементов много, ограничьте степень одновременности
await Parallel.ForEachAsync(items,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = callerCt },
async (x, ct) => await ProcessAsync(x, ct));
// Два пункта: подключить токен вызывающей стороны к ParallelOptions
// (если забыть, ct внутри тела всегда None) и пропустить тот же ct в тело (не выбрасывать)
Есть одна оговорка. Task.WhenAll(items.Select(...)) в момент перечисления запускает обработку всех элементов сразу. Для фиксированных нескольких или нескольких десятков работ это нормально, но на большой коллекции вы разом исчерпаете сокеты, соединения с БД и память. Для работы, чей объём непредсказуем, либо ограничьте степень одновременности, как в Parallel.ForEachAsync выше, либо управляйте потоком через ограниченный канал, о котором ниже.
Свой поток оправдан почти только когда требование — свойство самого потока: «нужен собственный цикл сообщений», «нужно задать апартамент потока (STA)», «должен работать всё время жизни приложения».
3.2. Параллелизм данных — Parallel.For / ForEach
Для параллелизма данных — «применить одну и ту же обработку к каждому элементу коллекции, чтобы ускорить целое» — используйте Parallel.For / Parallel.ForEach, а не режьте цикл по потокам сами. Деление источника данных (партиционирование) и перераспределение нагрузки берёт на себя TPL, и для обычного цикла блокировки не нужны.11
Однако официальная документация прямо называет две ловушки.3
- Не думайте, что параллельное всегда быстрее. Цикл с малым числом итераций или с лёгкой работой на одну итерацию может стать медленнее, потому что накладные расходы распараллеливания перевешивают само тело. На производительность влияет много факторов, поэтому всегда измеряйте и решайте по измерению.
- Не заставляйте итерации ждать друг друга. Нет гарантии, что каждая итерация
Parallel.Forдействительно выполняется параллельно. Код, в котором одна итерация ждёт, пока другая выставит событие, в зависимости от планирования может взаимно заблокироваться.
3.3. «Ожидающую» обработку — в асинхронный I/O, а не в потоки
Обработка, где главное — ожидание I/O — файлы, сеть, БД, — не кандидат на добавление потоков. Занимать целый поток, пока он ждёт, просто расточительно; асинхронный I/O через async/await не потребляет поток во время ожидания. Это различие — CPU-ограниченную работу распараллеливать, I/O-ограниченную делать асинхронной — первая линия, которую стоит провести на входе в многопоточное проектирование.
flowchart TB
accTitle: Ветвления до создания потока
accDescr: Ветвления до того, как «поднимать поток». Большая часть бизнес-обработки попадает в один из трёх верхних выходов; до new Thread доходят лишь исключительные случаи
S["Есть работа, которую хочется вести параллельно"] --> Q1{"Что преобладает в работе?"}
Q1 -->|"Ожидание I/O<br/>файлы, сеть, БД"| ASYNC["Асинхронный I/O через async/await<br/>потоки не добавлять"]
Q1 -->|"Вычисления на CPU"| Q2{"Какова форма работы?"}
Q2 -->|"Одна и та же обработка<br/>для всех элементов коллекции"| PAR["Parallel.For / ForEach"]
Q2 -->|"Независимый блок<br/>фоновой обработки"| TASK["Task.Run / Task.WhenAll"]
Q2 -->|"Цикл сообщений, требование STA и т. п.<br/>свойство самого потока — требование"| TH["new Thread<br/>(исключительное последнее средство)"]
Рис. 3: Ветвления до того, как «поднимать поток». Большая часть бизнес-обработки попадает в один из трёх верхних выходов; до new Thread доходят лишь исключительные случаи
Практические решения по async/await разобраны в «Практическая таблица решений для C# async/await — Task.Run и ConfigureAwait», а как внизу связаны пул потоков и асинхронный I/O — подробно в «Глубины ввода-вывода Windows (часть 3) — порты завершения ввода-вывода (IOCP) и пул потоков .NET».
4. Принцип 2: минимизировать разделяемое изменяемое состояние
Гонка возникает только когда есть и «несколько потоков», и «разделяемые изменяемые данные». Число потоков диктуют требования, поэтому проект может урезать именно разделение. Средств три.
4.1. Разбивать — каждый поток трогает только свои данные
Самый простой и сильный ход — раздать данные по потокам. Для агрегации в параллельном цикле вместо того, чтобы писать в общую сумму на каждой итерации, возьмите перегрузку Parallel.For, которая принимает потоково-локальное состояние: каждый поток копит свой промежуточный итог у себя, и вы сливаете их один раз в конце. Записи в общее состояние падают с «каждую итерацию» до «один раз на поток», и стоимость синхронизации, и окно гонки сжимаются на порядки.3
long total = 0;
Parallel.For(0, items.Length,
() => 0L, // Начальное значение, локальное для потока
(i, state, local) => local + Weigh(items[i]), // Каждая итерация складывает только в свой local
local => Interlocked.Add(ref total, local)); // Слияние — один раз на поток
flowchart TB
accTitle: Потоково-локальная агрегация
accDescr: Потоково-локальная агрегация. Пока идёт обработка, каждый поток трогает только свои данные, поэтому гонки нет; запись в общее состояние происходит один раз на поток, в момент слияния
SRC["Массив данных (объект обработки)"] --> T1["Поток 1<br/>обрабатывает свою долю и<br/>складывает только в свой промежуточный итог"]
SRC --> T2["Поток 2<br/>обрабатывает свою долю и<br/>складывает только в свой промежуточный итог"]
SRC --> T3["Поток 3<br/>обрабатывает свою долю и<br/>складывает только в свой промежуточный итог"]
T1 --> M["Слияние: Interlocked.Add отражает<br/>итог в сумму один раз на поток"]
T2 --> M
T3 --> M
Рис. 4: Потоково-локальная агрегация. Пока идёт обработка, каждый поток трогает только свои данные, поэтому гонки нет; запись в общее состояние происходит один раз на поток, в момент слияния
4.2. Делать неизменяемым — то, что не переписывают, можно свободно разделять
Данные, которые только читают, безопасно читать с любого числа потоков сразу. Значения конфигурации, справочники, входы вычислений и тому подобное можно свободно разделять без синхронизации, если сделать их неизменяемыми — не переписывать после конструирования. В C# этому проекту помогают типы record и свойства init. Одно решение «когда нужно изменение, построить новый экземпляр и подменить, а не переписывать существующий» снимает ещё один кусок изменяемого состояния, который иначе пришлось бы охранять.
Но «выглядит как только для чтения» и «является неизменяемым» — разные вещи. Интерфейс только для чтения вроде IReadOnlyList<T> означает лишь «через этот интерфейс переписать нельзя» — он не мешает переписать лежащий внизу List<T> через другую ссылку. Гарантия record / init тоже мелкая: она не защищает объекты, на которые указывает свойство. Для данных, которые вы действительно хотите безопасно разделять между потоками, либо используйте неизменяемую коллекцию из System.Collections.Immutable, например ImmutableArray<T>, либо в момент разделения передайте копию, оборвав путь переписи целиком. Тогда условие — сам тип элемента T тоже должен быть неизменяемым. Неизменяемая коллекция защищает только «порядок» — ссылки на изменяемые объекты-элементы по-прежнему разделяются как есть, поэтому если содержимое элемента можно переписать по другому пути, гонка остаётся. Либо сделайте граф объектов неизменяемым до самых листьев, либо передайте глубокую копию.
4.3. Передавать — вместо совместного доступа отправлять через очередь
Данные между потоками всё равно нужно двигать. Тогда вместо «обе стороны трогают общую переменную» стройте схему производитель/потребитель, где одна сторона пишет, другая читает, а между ними очередь.
Первый выбор на .NET — System.Threading.Channels. Это FIFO, в который производитель пишет данные асинхронно, а потребитель читает асинхронно; всей синхронизацией управляет сам канал.5
var channel = Channel.CreateBounded<WorkItem>(100); // Ёмкость 100 — включает обратное давление
// Сторона производителя
await channel.Writer.WriteAsync(item, ct); // Если полон, ждёт, пока появится место
// …когда все производители закончили писать:
channel.Writer.Complete(); // Объявляет «больше не придёт». Без этого цикл читателя никогда не закончится
// Сторона потребителя
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
Process(item);
}
flowchart LR
accTitle: Производитель/потребитель через канал
accDescr: Схема производитель/потребитель вокруг канала. Ни одна сторона не трогает общую переменную напрямую; и ожидание, и контроль ёмкости отданы каналу
P1["Производитель 1<br/>WriteAsync"] --> CH["Ограниченный канал (ёмкость 100)<br/>FIFO-очередь<br/>синхронизацией управляет канал"]
P2["Производитель 2<br/>WriteAsync"] --> CH
CH --> C1["Потребитель 1<br/>ReadAllAsync"]
CH --> C2["Потребитель 2<br/>ReadAllAsync"]
CH -.->|"Если полон, заставляет писателей ждать<br/>(обратное давление)"| P1
CH -.->|"Если пуст, заставляет читателей ждать"| C1
Рис. 5: Схема производитель/потребитель вокруг канала. Ни одна сторона не трогает общую переменную напрямую; и ожидание, и контроль ёмкости отданы каналу
На практике важно выбирать канал с ограниченной ёмкостью (bounded). Поведение по умолчанию при достижении предела — «сторона записи ждёт места», и это становится естественным обратным давлением (backpressure). Безграничная очередь в схеме, где производство обгоняет потребление, — бомба замедленного действия: «продолжает работать», но память растёт.5
В синхронном мире ту же роль, что ограниченный канал, играет BlockingCollection<T> с заданной ёмкостью. Она сочетает блокировку и контроль ёмкости: лимит не даёт производителю слишком далеко обогнать потребителя, а когда пусто — блокирует потребителя и заставляет ждать.12 ConcurrentQueue<T> / ConcurrentStack<T>, напротив, — быстрые коллекции, которые достигают потокобезопасности только операциями Interlocked, без блокировок6, но это голые потокобезопасные очереди: ни лимита ёмкости, ни механизма «ждать, когда пусто». Считайте их деталью, а не главным героем передачи работы. Заметьте также, что BlockingCollection<T> не рассчитана на асинхронный доступ, поэтому в паре с async/await выбирайте Channel<T>.12
Ещё оговорка: остерегайтесь мысли «заменил словарь на ConcurrentDictionary — значит потокобезопасно». Даже когда отдельные операции потокобезопасны, составные операции вроде «проверить наличие, затем добавить» по-прежнему гоняются (используйте метод, сделанный для составных операций, например GetOrAdd). И у самого GetOrAdd есть оговорка: значение, которое в итоге сохранится, одно, но фабричная функция, которая строит значение, при состязании может вызваться несколько раз. Если в фабрику вложить побочный эффект — открыть соединение, создать файл и т. п. — повторный вызов его «протечёт», поэтому либо делайте фабрику без побочных эффектов, либо для инициализации, которая должна случиться ровно один раз, храните как значение Lazy<T>. Смена типа коллекции не заменяет сокращение разделяемого изменяемого состояния.
5. Принцип 3: соблюдать дисциплину блокировок
Даже сократив разделяемое изменяемое состояние, часто не удаётся свести его к нулю. Для того, что осталось общим, используйте взаимное исключение (блокировку), но блокировка — не инструмент «на всякий случай обернуть lock вокруг всего подозрительного». Дисциплина из четырёх пунктов.
5.1. Решить «что защищаем» и блокировать выделенным объектом
Единицу блокировки мыслите не как «отрезок кода», а как «данные». Каждому набору изменяемых данных, которые хотите защитить, поставьте в соответствие один объект блокировки и берите ту же блокировку во всяком месте, которое эти данные трогает — сломанная версия этой таблицы соответствий и есть то, чем на деле являются ошибки гонки.
Объект блокировки — выделенный экземпляр, который не выставляется наружу. lock(this) делит блокировку с любым внешним кодом, который может ссылаться на ваш экземпляр, а lock(typeof(X)) — со всем доменом приложения: и то и другое — рассадник взаимных блокировок. Начиная с .NET 9 / C# 13 рекомендуется использовать экземпляр выделенного типа System.Threading.Lock как объект блокировки.4
public class OrderBook
{
private readonly Lock _gate = new(); // .NET 9+ (до этого — readonly object)
private readonly List<Order> _orders = []; // Данные, которые защищает _gate
public void Add(Order order)
{
lock (_gate) { _orders.Add(order); }
}
}
Оператор lock в C# гарантирует, что блокировка будет отпущена даже при исключении. Как он раскрывается, зависит от типа объекта блокировки: для обычного объекта это вызов Monitor.Exit в finally, для типа Lock — EnterScope() и его уничтожение.413 Иначе говоря, поле типа Lock — другой механизм, чем Monitor, и если только часть кода вручную пишет Monitor.Enter(_gate), взаимное исключение с lock (_gate) не держится. При любом типе безопаснее перестать писать Monitor.Enter / Exit вручную и всегда унифицировать на синтаксис lock.4
5.2. Не делать «долгого» и «внешнего», пока держите блокировку
Чем короче держите блокировку, тем лучше; единственное, что можно делать, держа её, — читать и писать данные, которые она защищает. Код, который при удержании блокировки делает I/O или вызывает внешний код через событие или колбэк, не только удлиняет удержание — он открывает путь, где вызванный пытается взять другую блокировку и взаимно блокируется. Готовить вне блокировки, а внутри только подменять — базовая форма.
Заметьте, что внутри lock нельзя await (это ошибка компиляции). Это защита, а не просто ограничение: у Monitor есть привязка к потоку (thread affinity) — поток, который взял блокировку, должен её отпустить, — и это несовместимо с асинхронным кодом, где поток выполнения может смениться через await. Для исключения в асинхронном коде используйте SemaphoreSlim с начальным счётчиком 1.14
private readonly SemaphoreSlim _asyncGate = new(1, 1);
public async Task SaveAsync(Data data, CancellationToken ct)
{
await _asyncGate.WaitAsync(ct);
try { await WriteToFileAsync(data, ct); }
finally { _asyncGate.Release(); }
}
5.3. Несколько блокировок всегда в одном порядке
Когда блокировок две или больше, классический образец взаимной блокировки — порядок захвата меняется от потока к потоку. Исправление простое: сделайте правилом, что каждый поток берёт блокировки в одном порядке. Там, где порядок гарантировать нельзя, используйте перегрузку Monitor.TryEnter с тайм-аутом: если взять не удалось, отпустите и повторите (или зафиксируйте аномалию) — вечное зависание превращается в обнаруживаемый сбой.4
5.4. Простые обновления — Interlocked; если чтений много — ReaderWriterLockSlim
Для атомарного обновления одной переменной — увеличения или уменьшения счётчика, подмены флага — класс Interlocked (Increment / Add / CompareExchange) быстрее, чем lock. Без состязания это может стоить одного префикса машинной инструкции.4 Обратная сторона: на этом Interlocked заканчивается; он не может держать несколько переменных согласованными вместе. Самодельная безблокировочная структура в сочетании с volatile — инструмент экспертов, требующий глубокого понимания модели памяти, и в бизнес-приложении его писать не следует.
Для общих данных, где «чтения часты, записи редки», есть ещё вариант ReaderWriterLockSlim: он исключает только записи, а чтения пропускает одновременно.13
6. Принцип 4: сначала спроектировать, как останавливаться
Первый вопрос на разборе многопоточного проекта: «как это останавливается?» Код, который начинает работать, можно написать и без раздумий; код, который останавливается безопасно, не рождается, пока его не спроектировали.
6.1. Кооперативная отмена (CancellationToken) — единственный верный ответ
Модель остановки в .NET унифицирована вокруг кооперативной отмены. Останавливающая сторона создаёт CancellationTokenSource и передаёт его Token каждой обработке. Когда нужно остановить — вызывает Cancel(). Сторона обработки следит за токеном и в удобной для себя точке прибирается и заканчивает — это сотрудничество, а не сила, поэтому обработка может завершиться, сохранив согласованное состояние.7
private CancellationTokenSource? _cts;
private Task? _worker;
public void Start()
{
if (_worker is { IsCompleted: false }) // Отклонить повторный Start, пока ещё работает
throw new InvalidOperationException("Работник уже выполняется.");
if (_worker is { IsFaulted: true }) // Не пересоздавать, проглотив предыдущий сбой
throw new InvalidOperationException("Предыдущий работник завершился сбоем.", _worker.Exception);
_cts = new CancellationTokenSource();
var token = _cts.Token; // Сначала поймать в локальную, чтобы не гоняться с повторным Start после остановки
_worker = Task.Run(() => WorkLoop(token), token);
}
private void WorkLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested) // Следить опросом
{
ProcessNextItem(ct); // Передавать ct в блокирующие вызовы для немедленного прерывания
}
}
public async Task StopAsync()
{
var cts = _cts; // Закрепить в локальных, чтобы даже если поля подменят,
var worker = _worker; // пока мы ждём, не остановить не ту цель
if (cts is null || worker is null) return;
Exception? cancelFailure = null;
try { cts.Cancel(); } // Колбэк, зарегистрированный на токене, может бросить
catch (Exception ex) { cancelFailure = ex; } // Поймать и сообщить после join
try
{
try { await worker; } // Join всегда, независимо от успеха Cancel, и наблюдать любой сбой на лету
catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
{ } // «Нормальной» считать только остановку, которую запросили мы
catch (Exception ex) when (cancelFailure is not null)
{
throw new AggregateException(cancelFailure, ex); // Не потерять ни один из двух сбоев
}
}
finally
{
cts.Dispose(); // Источник после join уничтожить (освобождение OS-ресурсов вроде WaitHandle).
if (ReferenceEquals(_cts, cts))
{
_cts = null; // Не дать последующему StopAsync пользоваться уже уничтоженным источником
_worker = null;
}
}
if (cancelFailure is not null)
throw new AggregateException(cancelFailure);
}
Заметьте, что этот Start / StopAsync — минимальная конструкция, которая предполагает последовательные вызовы с одного потока (например, UI-потока). Если жизненным циклом могут одновременно управлять несколько потоков, сериализуйте сами Start / StopAsync чем-то вроде SemaphoreSlim — бессмысленно, если гоняются уже операции управления работником, ещё до защиты самого работника.
В этом небольшом образце уже заложены приёмы, которые работают на практике. Во-первых, Start отклоняет повторный вызов, пока выполняется. Безусловная перезапись _cts и _worker теряет ссылку на прежнего работника и оставляет рядом «осиротевший поток», который нельзя ни остановить, ни дождаться. API жизненного цикла (Start/Stop) должен сам охранять «одновременно один». Дальше ещё три пункта. Первый: API остановки ждёт завершения. Cancel() только «запрашивает» отмену; в момент возврата работник ещё может быть посреди ProcessNextItem. Сделать Stop(), который только запросил и вернулся, — значит создать новую гонку, где вызывающая сторона уже начала уборку, а работник ещё работает. Второй: держите Task, а не выбрасывайте. Отбросить _ = Task.Run(...) — и никто не заметит, если работник умрёт от исключения. Третий: поймайте токен в локальную переменную и передайте её, а не ссылайтесь на _cts.Token внутри лямбды. Ссылка внутри лямбды вычисляется в момент выполнения, и если сразу после остановки случится повторный Start, старый работник схватит новый токен. Если тот же токен передать ещё и вторым аргументом Task.Run, то когда сторона обработки закончится через ThrowIfCancellationRequested или OperationCanceledException от API с поддержкой отмены, Task классифицируется как «Canceled», а не «Faulted» (в этом примере обычный выход по условию цикла по-прежнему считается нормальным завершением). Ещё одно: catch в StopAsync через фильтр when ловит только отмену от своего токена. Безусловно проглатывать OperationCanceledException — значит настоящий сбой, брошенный другим токеном внутри обработки (тайм-аут на элемент и т. п.), тоже выглядел бы как «остановилось, значит нормально». Заметьте, что опознание по совпадению токена ломается, если WorkLoop внутри использует связанный токен (композиция связкой из этого же 6.1): из него вылетает исключение с токеном стороны связи. В такой схеме явно, как проектное решение, выберите либо вызвать ct.ThrowIfCancellationRequested() на выходе из WorkLoop, чтобы «перевести» на внешний токен перед выходом, либо ослабить фильтр до when (cts.IsCancellationRequested) и принять «отмена, пока запрошена остановка, считается нормальной».
flowchart TB
accTitle: Схема кооперативной отмены
accDescr: Схема кооперативной отмены. Останавливающая сторона только вызывает Cancel(); «когда и как закончить» решает каждая обработка сама. Поэтому можно остановиться, сохранив согласованное состояние
OWNER["Останавливающая сторона"] -->|"Вызывает Cancel() один раз"| CTS["CancellationTokenSource"]
CTS -->|"Передаёт Token"| W1["Рабочая обработка 1"]
CTS -->|"Передаёт Token"| W2["Рабочая обработка 2"]
CTS -->|"Передаёт Token"| W3["API библиотеки<br/>с поддержкой отмены"]
W1 -->|"Проверяет IsCancellationRequested<br/>прибирается и заканчивает сам"| E1["Нормальное завершение"]
W2 -->|"ThrowIfCancellationRequested"| E2["OperationCanceledException<br/>= считается завершением отмены"]
W3 -->|"Прерывается сразу даже во время ожидания"| E3["Отмена завершена"]
Рис. 6: Схема кооперативной отмены. Останавливающая сторона только вызывает Cancel(); «когда и как закончить» решает каждая обработка сама. Поэтому можно остановиться, сохранив согласованное состояние
Есть установленная практика и на стороне библиотеки. Операция, которую можно отменить, должна предоставлять открытый метод, принимающий CancellationToken; в вычислительном цикле либо периодически проверяйте IsCancellationRequested, либо вызывайте ThrowIfCancellationRequested(). Последний бросает OperationCanceledException, и Task трактует это как «завершение отмены», а не как «сбой». Когда нужно останавливаться и по внешне переданному токену, и по внутренней причине (тайм-аут и т. п.), составьте их связанным токеном.7
6.2. Thread.Abort считать несуществующим
Thread.Abort — «убить снаружи поток, который не слушается» — на .NET Core / .NET 5 и новее просто бросает PlatformNotSupportedException; пользоваться им больше нельзя. Бросать исключение в поток, не зная, что он сейчас выполняет, рискует прервать освобождение ресурсов и разрушить состояние. Если нужно принудительно завершить сторонний код, который не отвечает на кооперативную отмену (или который нельзя переписать, чтобы отвечал), официальная рекомендация — запускать его в отдельном процессе и останавливать через Process.Kill.8
6.3. Когда ждёте — через wait handle, а не опросом
Писать «ждать в цикле Sleep(100), пока не поднимется флаг» расточает и CPU, и отзывчивость. Для сигнала между потоками есть примитивы синхронизации вроде ManualResetEventSlim и SemaphoreSlim, которые правильно укладывают поток спать, пока не придёт сигнал.13 Выбор между точностью таймера и ожиданием события в Windows подробно разобран в «Почему в Windows стоит предпочитать ожидание события, а не Sleep(1)».
7. Особый случай UI-потока — закон настольных приложений Windows
У настольных приложений Windows сверх общих принципов есть ещё одно жёсткое ограничение. Закон: UI может трогать только поток, который его создал (UI-поток).
Элементы управления WinForms не потокобезопасны; операции с нескольких потоков загоняют элемент в несогласованное состояние и вызывают гонки, взаимные блокировки и зависания. Windows требует от приложения один выделенный поток, который принимает системные сообщения, и создание и манипуляция UI должны быть сосредоточены на этом потоке.9 У WPF ровно та же структура: менять элементы UI может только UI-поток.10
Когда нужно обновить UI с другого потока, не трогайте напрямую — превратите это в «запрос к UI-потоку».
flowchart LR
accTitle: Обновление UI как запрос
accDescr: Превращайте обновление UI в «запрос». Работа фонового потока заканчивается тем, что её кладут в очередь сообщений; элемент управления всегда трогает сам UI-поток
OS["Windows<br/>мышь, клавиатура, перерисовка"] --> Q["Очередь сообщений<br/>UI-потока"]
BG["Фоновый поток<br/>(тяжёлая обработка, связь)"] -->|"Запрос через Control.Invoke /<br/>Dispatcher.InvokeAsync"| Q
Q --> UI["UI-поток<br/>единственный поток, который может трогать элементы управления"]
BG -.->|"Трогать элемент управления напрямую"| NG["Запрещено<br/>причина гонок, взаимных блокировок, зависаний"]
Рис. 7: Превращайте обновление UI в «запрос». Работа фонового потока заканчивается тем, что её кладут в очередь сообщений; элемент управления всегда трогает сам UI-поток
| Фреймворк | Средство запроса |
|---|---|
| WinForms | Control.Invoke (синхронно) / Control.BeginInvoke (асинхронно) / начиная с .NET 9 — Control.InvokeAsync9 |
| WPF | Dispatcher.Invoke (синхронно) / Dispatcher.InvokeAsync / Dispatcher.BeginInvoke (асинхронно)10 |
Среди них синхронные формы (Control.Invoke / Dispatcher.Invoke) требуют осторожности. Если UI-поток синхронно ждёт завершения этого работника, а работник вызывает Invoke, получается взаимная блокировка, где каждый ждёт другого (то самое циклическое ожидание из раздела 2). Для уведомлений и отчётов о прогрессе с фона делайте значением по умолчанию асинхронные формы (BeginInvoke / InvokeAsync), а синхронные оставляйте только ситуациям, где можно уверенно сказать, что UI-поток вас не ждёт.
На практике есть ещё лучший ответ. Пишите обработку, начатую на UI-потоке, через async/await: await захватывает SynchronizationContext UI-потока и автоматически возобновляет продолжение на UI-потоке, поэтому мест, где нужно вручную писать Invoke, становится гораздо меньше. Это, однако, не безусловное свойство. Код, вошедший из фонового колбэка, или продолжение после ConfigureAwait(false) на UI-поток не возвращается, поэтому если по этому пути вы трогаете UI, явная диспетчеризация по-прежнему нужна. Свести форму к «тяжёлая работа — в Task.Run или асинхронный I/O, отражение результата на экране — в продолжении после await» — базовая форма современного Windows-приложения. Связь UI-потока и async/await сведена на одном листе в «WPF/WinForms: async и UI-поток — сводка на одном листе».
Кроме того, когда задействован COM — интеграция с Office, устаревшие компоненты и т. п. — добавляется ещё один слой: собственная модель потоков COM (STA/MTA). Инциденты вроде «создали COM-объект на UI-потоке, вызвали с другого и зависло» принадлежат этому слою и разобраны в «Основы COM STA/MTA — модели потоков и как избежать зависаний».
8. Когда пишете на нативном коде (C++/C)
Принципы до этой точки — не создавать потоки напрямую, сокращать разделяемое изменяемое состояние, дисциплина блокировок, проектирование остановки — напрямую применимы и к нативному коду. Меняется инструментарий. В C++ соответствия — RAII вместе с std::jthread / std::mutex / std::atomic; в C — _beginthreadex Win32 API, SRW-блокировки, условные переменные и схема события остановки. Каждое, включая ловушки конкретного языка (деструктор std::thread, опасность TerminateThread, DllMain и блокировка загрузчика и т. д.), разобрано в «издании C++» и «издании C» этой серии.
9. Проверка и отладка — готовиться из предпосылки, что не воспроизведётся
На то, что ошибки многопоточности найдут тесты, рассчитывать нельзя. Обычный модульный тест считает успехом проход, в котором гонка «случайно не случилась». Мыслите подготовку в три слоя.
Первая линия обороны — сами принципы проектирования, разобранные до сих пор. Между приложением с пятью кусками разделяемого изменяемого состояния и приложением с пятьюдесятью число подозреваемых мест отличается в десять раз. На разборе подтверждайте таблицей: «какие изменяемые данные разделяются», «какая блокировка какое из них защищает», «уникален ли порядок захвата блокировок», «где путь остановки». Проект, для которого вы не можете написать эту таблицу, ещё не закончен, даже если сейчас работает.
Второе: делайте аномалии наблюдаемыми, а не прячьте их. Обнаруживайте аномалии ожидания блокировки тайм-аутом Monitor.TryEnter и пишите в лог4, не проглатывайте, а фиксируйте ненаблюдаемые исключения работы, брошенной на пул потоков, и будьте готовы при зависании снять полный дамп, чтобы проверить стек каждого потока — борьба с ошибкой, которая «случается лишь изредка», решается тем, сколько информации вы снимете с того одного раза, когда она случилась. Настройка дампов и логов разобрана в «Проектирование Windows-приложений, сохраняющих логи и дампы при сбое».
Третье: растрясите под нагрузкой. Стресс-тесты, которые облегчают «джекпот» переплетения на машине разработки — долгий прогон со степенью параллелизма больше числа ядер, рандомизация порядка обработки, искусственные задержки и т. п. — реалистичный способ выжечь гонки до поставки. Ошибка, которая исчезает под отладчиком, часто охотно воспроизводится в release-сборке плюс высокая нагрузка.
10. Итог — контрольный список до того, как добавлять потоки
Если свести к сути, лучшая практика многопоточного программирования — не «умение правильно писать синхронизацию», а «проект, в котором синхронизацию писать не нужно». Если до начала работы вы можете ответить на следующие восемь вопросов, почти все крупные аварии предотвращаются.
- Эта работа CPU-ограничена или I/O-ограничена (если второе, ответ — async/await, а не поток)?
- Не собираетесь ли вы писать
new Thread(нельзя ли выразить через Task,Parallelили пул потоков)? - Какие изменяемые данные разделяются между потоками — можете ли вы их перечислить?
- Нельзя ли убрать это разделение «разбиением», «неизменяемостью» или «передачей через очередь»?
- Для каждого оставшегося общего куска данных назначена ли ровно одна соответствующая блокировка?
- Уникален ли порядок захвата блокировок для всех потоков, и нет ли внешних вызовов, пока блокировка удерживается?
- Передан ли
CancellationTokenво всякую длительную обработку, и можете ли вы объяснить путь остановки? - Сосредоточен ли код, который трогает UI, на UI-потоке?
Ошибки многопоточности не проявляются в день написания — они скалят зубы у заказчика, когда вы уже забыли. Иначе говоря, если на этапе проектирования пройти этот контрольный список, самый дорогой вид сбоев — «изредка падает», «раз в месяц зависает» — можно вырвать ещё до строки кода.
Похожие статьи
- Практические лучшие практики многопоточности: издание C++ — устранять аварии структурой с RAII и jthread
- Практические рекомендации по многопоточности: издание C — безопасное письмо в духе Win32 API
- Практические лучшие практики многопоточности: издание Java — соглашения эпохи виртуальных потоков
- Практическая таблица решений для C# async/await — Task.Run и ConfigureAwait
- WPF/WinForms: async и UI-поток — сводка на одном листе
- Глубины ввода-вывода Windows (часть 3) — порты завершения ввода-вывода (IOCP) и пул потоков .NET
- Основы COM STA/MTA — модели потоков и как избежать зависаний
- Почему в Windows стоит предпочитать ожидание события, а не Sleep(1)
- Разделяемая память: подводные камни и практические рекомендации
Смежные области консультирования
KomuraSoft LLC занимается разбором проектирования бизнес-приложений, включая многопоточность, расследованием первопричин трудновоспроизводимых сбоев вроде «изредка падает или зависает» (анализ дампов, локализация гонок) и техническими консультациями по распараллеливанию и асинхронизации существующих приложений. Можно подключаться уже на этапе «посмотрите, не возникнет ли гонка в этом проекте».
- Технические консультации и ревью дизайна
- Расследование ошибок и причин
- Разработка приложений для Windows
- Связаться с нами
Справочные ссылки
-
Microsoft Learn, Task Parallel Library (TPL). О том, что TPL — рекомендованное средство для многопоточного и параллельного кода начиная с .NET Framework 4; о динамической подстройке степени параллелизма под доступные процессоры; о том, что TPL берёт на себя деление работы, планирование на пул потоков, отмену и управление состоянием; о том, что цикл с малой работой на итерацию может замедлиться из-за накладных расходов распараллеливания; и о том, что даже при использовании TPL рекомендуется базовое понимание блокировок, взаимных блокировок и гонок. ↩ ↩2
-
Microsoft Learn, The managed thread pool. О том, что класс ThreadPool предоставляет пул управляемых системой рабочих потоков и позволяет разработчику сосредоточиться на задачах приложения, а не на управлении потоками; и о том, что .NET широко использует пул потоков для операций TPL, завершения асинхронного I/O, колбэков таймеров, зарегистрированных ожиданий, соединений сокетов и т. д. ↩ ↩2
-
Microsoft Learn, Potential Pitfalls in Data and Task Parallelism. О том, что параллельный цикл иногда медленнее последовательного и его всегда нужно измерять; о том, что записи в общую память внутри параллельного цикла следует избегать, а рекомендуется перегрузка с потоково-локальным состоянием; и о том, что нет гарантии параллельного выполнения каждой итерации For/ForEach, поэтому код, который ждёт между итерациями, может взаимно заблокироваться. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Managed threading best practices. О определениях гонки (пример, где инкремент счётчика распадается на чтение, сложение и запись назад и теряется из-за перезаписи) и взаимной блокировки; о том, что вместо Thread.Abort следует использовать кооперативную отмену; о том, что тип или this нельзя брать целью блокировки, а начиная с .NET 9 / C# 13 следует использовать выделенный экземпляр System.Threading.Lock; о том, что оператор lock в C# гарантирует Monitor.Exit в finally; о обнаружении взаимных блокировок тайм-аутом Monitor.TryEnter; о том, что для простых изменений состояния быстрее класс Interlocked; и о проектном указании, что статические данные по умолчанию должны быть потокобезопасны, а данные экземпляра — нет. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
Microsoft Learn, System.Threading.Channels library. О том, что канал — FIFO модели производитель/потребитель, который сам управляет синхронизацией; о возможности создать канал с лимитом ёмкости через CreateBounded; о том, что поведение по умолчанию при достижении предела — ожидание стороны записи (Wait), при этом можно выбрать и другие FullMode вроде DropOldest; и о том, что когда запись обгоняет чтение, возникает обратное давление. ↩ ↩2 ↩3
-
Microsoft Learn, Thread-safe collections. О том, что коллекции из System.Collections.Concurrent достигают потокобезопасности мелкозернистыми блокировками или безблокировочными механизмами; и о том, что ConcurrentQueue и ConcurrentStack реализованы без блокировок, операциями Interlocked, и выдерживают частое добавление и удаление с нескольких потоков. ↩ ↩2
-
Microsoft Learn, Cancellation in Managed Threads. О процедуре кооперативной отмены через CancellationTokenSource и CancellationToken; о том, что отмена — сотрудничество, а не сила, и способ остановки решает слушатель; о трёх средствах наблюдения — опрос, регистрация колбэка и wait handle; о том, что ThrowIfCancellationRequested бросает OperationCanceledException, который Task трактует как завершение отмены; о композиции нескольких токенов связанным токеном; и о том, что библиотека должна предоставлять открытые методы, принимающие CancellationToken. ↩ ↩2 ↩3
-
Microsoft Learn, Using threads and threading. О том, что для остановки потока следует использовать CancellationToken; о том, что на .NET Core и .NET 5 и новее Thread.Abort бросает PlatformNotSupportedException, а начиная с .NET 5 ещё и выдаёт предупреждение устаревания на этапе компиляции (SYSLIB0006); и о том, что для принудительного завершения стороннего кода, который не отвечает на кооперативную отмену, его следует запускать в отдельном процессе и останавливать через Process.Kill. ↩ ↩2
-
Microsoft Learn, How to handle cross-thread operations with controls. О том, что доступ к элементам управления WinForms не потокобезопасен, и операции с нескольких потоков ведут к несогласованному состоянию, гонкам, взаимным блокировкам и зависаниям; о том, что все элементы управления должны создаваться и использоваться на одном потоке, а Windows требует выделенный UI-поток для доставки системных сообщений; и о безопасном вызове с другого потока через Control.Invoke, Control.InvokeAsync начиная с .NET 9 или BackgroundWorker. ↩ ↩2 ↩3
-
Microsoft Learn, Threading model (WPF). О том, что в WPF менять UI может только один поток, а фоновый поток регистрирует рабочие элементы у Dispatcher UI-потока, чтобы запросить их; о том, что Dispatcher.Invoke синхронен, а InvokeAsync и BeginInvoke асинхронны; и о том, что Dispatcher обрабатывает работу как очередь с приоритетами. ↩ ↩2 ↩3
-
Microsoft Learn, Data Parallelism (Task Parallel Library). О том, что Parallel.For / Parallel.ForEach дают параллелизм данных почти с тем же ощущением, что цикл for; о том, что не нужно создавать потоки или ставить рабочие элементы в очередь, а в обычном цикле не нужны и блокировки; и о том, что TPL делит источник данных между несколькими потоками и перераспределяет нагрузку, если она перекашивается. ↩
-
Microsoft Learn, BlockingCollection<T> Class. О том, что BlockingCollection — реализация производитель/потребитель с блокировкой и ограничением ёмкости; о том, что лимит ёмкости не даёт производителю слишком далеко обогнать потребителя; и о том, что она не рассчитана на асинхронный доступ, поэтому для асинхронного производителя/потребителя следует рассмотреть Channel<T>. ↩ ↩2
-
Microsoft Learn, Overview of synchronization primitives. О том, что Monitor даёт взаимное исключение через объект блокировки и имеет привязку к потоку; о том, что в C# следует использовать оператор lock, а не Monitor напрямую; о том, что ReaderWriterLockSlim делает записи исключительными, а чтения — одновременными; и о том, что SemaphoreSlim — лёгкий семафор только внутри процесса, тогда как Semaphore именованный и годится для межпроцессной синхронизации. ↩ ↩2 ↩3
-
Microsoft Learn, Async semaphores, locks, and reader/writer coordination. О том, что оператор lock в C# и тип Lock имеют привязку к потоку и поэтому не могут использоваться через await (потому что поток, выполняющий продолжение, может смениться до и после await); о том, что для взаимного исключения в асинхронном коде используют SemaphoreSlim со счётчиком 1 через WaitAsync и Release в try/finally; и о том, что для дросселирования альтернативой служит ограниченный Channel. ↩
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Практические рекомендации по многопоточности: издание C — безопасное письмо в духе Win32 API
Устоявшийся подход к многопоточности на C с Win32 — создание потоков через _beginthreadex, SRW-блокировки и условные переменные, функции ...
Практические лучшие практики многопоточности: издание C++ — устранять аварии структурой с RAII и jthread
В C++ многопоточность — мир, где гонка данных есть неопределённое поведение. Статья разбирает ловушку деструктора std::thread, проектиров...
Практические лучшие практики многопоточности: издание Java — соглашения эпохи виртуальных потоков
В Java устоявшаяся практика многопоточности — никогда не создавать потоки напрямую, а опираться на ExecutorService и виртуальные потоки. ...
WMI/CIM из C# и PowerShell — практическое руководство по сведениям об оборудовании, мониторингу процессов и удалённым запросам
WMI/CIM — стандартный способ получить серийный номер ПК, следить за свободным местом на диске и ловить запуск процессов. Разбираем команд...
Как выбрать межпроцессное взаимодействие в Windows — таблица решений: именованные каналы / TCP / gRPC / разделяемая память / COM
Разбираем, как выбрать способ взаимодействия Windows-приложений друг с другом: сильные стороны и ловушки именованных каналов, локального ...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Бизнес-приложения, интеграция оборудования и средства связи — от требований до разработки.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Почему стоит избегать lock(this) и lock(typeof(MyClass))?
- Потому что объект, на котором вы берёте блокировку, виден коду за пределами вашего. this — это сам ваш экземпляр, поэтому любой внешний код, который может ссылаться на этот экземпляр, может заблокировать тот же объект и вызвать непреднамеренное состязание или взаимные блокировки. typeof(MyClass) ещё опаснее: объект Type в домене приложения один, поэтому вы делите блокировку с совершенно посторонним кодом. В качестве цели блокировки используйте выделенный объект, который никогда не выставляется наружу. Начиная с .NET 9 / C# 13 рекомендуется брать экземпляр выделенного типа System.Threading.Lock как объект блокировки.
- Сколько потоков можно создавать? Каково оптимальное число потоков?
- «Не решать число потоков самим» — современный ответ. Если пользоваться Task и классом Parallel, пул потоков сам подстраивает степень параллелизма под число ядер CPU и текущую нагрузку. Проект, который руками снова и снова вызывает new Thread, на машине заказчика с другим числом ядер склонен к избытку или недостатку. Смотреть нужно не на число, а на вид работы: вычисления, которые насыщают CPU, не ускоряются от параллелизма сверх числа ядер, а обработка, где главное — ожидание I/O, вообще не должна получать дополнительные потоки — верный ход там асинхронный I/O через async/await.
- Делает ли volatile код потокобезопасным?
- Нет. volatile гарантирует упорядочение — что доступ к этому полю не переставляется с окружающими операциями памяти (семантика acquire/release), — а не атомарность составной операции вроде «прочитать, вычислить, записать назад». Например, даже если несколько потоков делают ++ над счётчиком volatile int, сложения всё равно теряются. Для увеличения и уменьшения счётчика или операции compare-and-swap используйте класс Interlocked, а когда нужно защитить несколько переменных вместе — lock. volatile имеет смысл почти только в простом случае вроде флага остановки, где один поток пишет, а остальные только читают — и даже этот флаг сейчас принято выражать через CancellationToken.
- Как понять, что ошибка, которая случается лишь изредка, вызвана многопоточностью?
- Три признака, которые стоит подозревать: «одна и та же операция то воспроизводится, то нет», «перестаёт воспроизводиться, как только подключаешь отладчик или добавляешь логирование», и «случается только под высокой нагрузкой или сразу после запуска». Ошибка, зависящая от тайминга, характерна тем, что результат меняется от запуска к запуску — это и есть определение гонки. Чтобы сузить круг, сначала перечислите все разделяемые изменяемые данные и сведите в таблицу, какая блокировка какое из них защищает. Даже один незащищённый доступ — подозреваемый. При зависании снимите стеки всех потоков и проверьте, не образуют ли они цикл ожидания чужих блокировок. Остановитесь в отладчике Visual Studio и посмотрите Parallel Stacks либо в продакшене снимите дамп и разберите его.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.