Практические рекомендации по многопоточности: .NET — что решить до добавления потоков
· Обновлено: · Го Комура · Windows, Многопоточность, C#, .NET, Бизнес-приложения, Разбор сбоев, Проектирование
История изменений (2 обновлений, последнее 31 Aug 2026)
Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.
- Русский текст исправлен, чтобы соответствовать японскому оригиналу.
- Русский текст переписан по текущему навыку технического перевода как полный перевод японского оригинала.
- Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.22175834)
Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.
Го Комура (2026). Практические рекомендации по многопоточности: .NET — что решить до добавления потоков. KomuraSoft LLC. https://comcomponent.com/ru/blog/multithreading-best-practices-dotnet/
- DOI (зарегистрированный архив)
- 10.5281/zenodo.22175834
- DOI (последняя зарегистрированная версия)
- 10.5281/zenodo.22175835
«Обработка шла медленно, поэтому мы запустили потоки и распараллелили — и теперь итоги агрегации иногда расходятся». «Добавили фоновую обработку — и раз в месяц приложение зависает». «Под отладчиком не воспроизводится, но у заказчика это точно происходит». Страх многопоточного программирования в том, что сразу после написания код выглядит правильным. Ошибки гонки зависят от момента выполнения: они проходят тесты и всплывают только в продакшене.
При этом многоядерность уже норма, и даже в бизнес-приложениях многопоточности не избежать там, где нужно «выполнять тяжёлую работу, не замораживая 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
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 28, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
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: Циклическое ожидание взаимной блокировки. Как только стрелки ожидания замыкаются в кольцо, каждый поток в этом кольце останавливается навсегда.
Сложность в том, что оба явления зависят от момента выполнения. Чередование (interleaving) — конкретная комбинация порядка выполнения, которая на машине разработки встречается раз на десятки тысяч запусков, на машине заказчика с другим числом ядер и другим таймингом может случаться каждый день. «Не воспроизводится с подключённым отладчиком» и «пропало, когда добавили логирование» происходят потому, что само наблюдение меняет тайминг. Это типичное поведение ошибок гонки.
Именно поэтому все принципы ниже смотрят в одну сторону: прежде чем «синхронизировать правильно», сокращать места, которые требуют синхронизации. Это основной принцип многопоточного проектирования.
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-bound работу распараллеливать, I/O-bound делать асинхронной — первая граница, которую стоит провести на входе в многопоточное проектирование.
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; } // Поймать и сообщить после ожидания завершения
try
{
try { await worker; } // Дождаться завершения всегда, независимо от успеха 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(); // Источник после ожидания уничтожить (освобождение 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-bound или I/O-bound (если второе, ответ — async/await, а не поток)?
- Не собираетесь ли вы писать
new Thread(нельзя ли выразить через Task,Parallelили пул потоков)? - Какие изменяемые данные разделяются между потоками — можете ли вы их перечислить?
- Нельзя ли убрать это совместное использование «разделением», «неизменяемостью» или «передачей через очередь»?
- Для каждого оставшегося общего куска данных назначена ли ровно одна соответствующая блокировка?
- Уникален ли порядок захвата блокировок для всех потоков, и нет ли внешних вызовов, пока блокировка удерживается?
- Передан ли
CancellationTokenво всякую длительную обработку, и можете ли вы объяснить путь остановки? - Сосредоточен ли код, который трогает UI, на UI-потоке?
Ошибки многопоточности не проявляются в день написания — они всплывают у заказчика, когда вы уже забыли. Иначе говоря, если на этапе проектирования пройти этот контрольный список, самый дорогой вид сбоев — «изредка падает», «раз в месяц зависает» — можно отсечь ещё до строки кода.
Похожие статьи
- Практические рекомендации по многопоточности: C++
- Практические рекомендации по многопоточности: C
- Практические рекомендации по многопоточности: 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, остановку через jthread и stop_token, предо...
WMI/CIM из C# и PowerShell — практическое руководство по сведениям об оборудовании, мониторингу процессов и удалённым запросам
WMI/CIM — стандартный способ получить серийный номер ПК, следить за свободным местом на диске и ловить запуск процессов. Разбираем команд...
Как выбрать межпроцессное взаимодействие в Windows — таблица: именованные каналы, TCP, gRPC, разделяемая память, COM
Как выбрать способ связи между Windows-приложениями. В таблице решений разобраны сильные стороны и типичные ошибки именованных каналов, л...
Где хранить данные Windows-приложения: таблица решений SQLite / JSON / реестр / Access
Куда и в каком виде хранить данные настольного Windows-приложения. Разбираем выбор между AppData и ProgramData, сильные стороны и ловушки...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы 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, сложения всё равно теряются. Для увеличения и уменьшения счётчика или для операции «сравнить и подменить» используйте класс Interlocked; когда нужно защитить несколько переменных вместе — lock. volatile имеет смысл почти только в простом случае вроде флага остановки, где один поток пишет, а остальные только читают. И даже такой флаг сейчас принято выражать через CancellationToken.
- Как понять, что редко проявляющийся сбой связан с многопоточностью?
- Три признака, на которые стоит обратить внимание: «одна и та же операция то проявляет сбой, то нет», «сбой исчезает, как только подключают отладчик или добавляют логирование», «проявляется только под высокой нагрузкой или сразу после запуска». Ошибка, зависящая от момента выполнения, тем и характерна, что результат меняется от запуска к запуску — это определение состояния гонки. Чтобы локализовать причину, сначала перечислите все разделяемые изменяемые данные и сведите в таблицу, какая блокировка какое из них защищает. Даже один незащищённый доступ — подозреваемый. При зависании снимите стеки всех потоков и проверьте, не ждут ли они блокировки друг друга по кругу. Приостановите отладчик Visual Studio и откройте «Параллельные стеки» (Parallel Stacks) либо в продакшене снимите дамп и разберите его.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.