Многопоточность в .NET: практическое руководство
Быстрые ссылки
- Что делает многопоточность сложной?
- Используйте Interlocked для атомарных операций
- Потокобезопасные коллекции
- Распараллеливание циклов
- Многопоточность в Unity

Что делает многопоточность сложной?
По умолчанию весь код вашего приложения выполняется в основном потоке. Это упрощает рассуждения о состоянии программы: вы точно знаете порядок выполнения. Но современные процессоры имеют множество ядер, и если приложение выполняет всё последовательно, оно редко использует доступную вычислительную мощность полностью.
Ключевая проблема многопоточности — согласованность общего состояния. Если несколько потоков читают и изменяют один и тот же ресурс одновременно, поведение может зависеть от порядка выполнения операций. Это приводит к двум типичным классическим проблемам:
- Гонки (race conditions) — неправильные или непредсказуемые результаты при одновременном доступе к разделяемым данным.
- Дедлоки (deadlocks) — взаимная блокировка потоков, когда каждый ждёт освобождения ресурса, который удерживает другой.
Простейший пример гонки: счётчик, который инкрементируется из нескольких потоков. На абстрактном уровне это выглядит как “прибавить 1”, но на уровне CPU это серия действий: загрузить значение, увеличить, записать обратно. Если два потока читают одно и то же значение одновременно, оба могут записать один и тот же результат, и итоговый счёт меньше ожидаемого.

В .NET есть примитивы для управления конкурентным доступом. Самый простой — ключевое слово lock, которое сериализует доступ к участку кода, позволяя одному потоку выполнять критическую секцию за раз. Для lock обычно используют ссылочные объекты как маркеры.

Но чрезмерное или неверное применение lock приводит к потере параллелизма: если вы блокируете слишком большую область (например, весь цикл), то многопоточность превращается в последовательное выполнение с накладными расходами на синхронизацию. Поэтому важно минимизировать область блокировки и выбирать правильный инструмент.
Important: блокируйте только ту часть кода, где действительно происходит конфликт. Избегайте lock’ов вокруг медленных операций (файловый ввод-вывод, сетевые вызовы и т. п.).
Используйте Interlocked для атомарных операций
Для простых операций (инкремент, декремент, замена) lock может быть избыточным. Класс Interlocked предоставляет атомарные примитивы, реализованные на уровне процессора, и работает значительно быстрее для простых случаев.
Основные методы:
- Interlocked.Increment(ref count) — атомарный ++
- Interlocked.Decrement(ref count) — атомарный –
- Interlocked.Add(ref value, delta) — атомарное сложение
- Interlocked.Exchange(ref location, value) — атомарная замена значения
- Interlocked.CompareExchange(ref location, value, comparand) — заменить, если текущее равно comparand
Использование Interlocked избавляет от необходимости создавать объекты-блокировщики для самых простых счётчиков и флагов, но не решает всех задач: если вам нужно выполнить несколько связанных операций над сложной структурой, всё ещё потребуется синхронизация.
Пример: атомарное добавление к счётчику
int counter = 0;
// из разных потоков
Interlocked.Increment(ref counter);Пример с CompareExchange (псевдо-код):
int original, newValue;
do
{
original = currentValue;
newValue = ComputeFrom(original);
} while (Interlocked.CompareExchange(ref currentValue, newValue, original) != original);Этот цикл реализует алгоритм повтора (compare-and-swap): вычисляем новое значение, пытаемся установить его только если текущее равно тому, что мы читали. Если между чтением и записью значение изменилось, повторяем.

Потокобезопасные коллекции
Коллекции из пространства System.Collections.Generic не гарантируют потокобезопасность при одновременных модификациях. Для многопоточного доступа Microsoft предоставляет набор коллекций в System.Collections.Concurrent:
- ConcurrentDictionary
— потокобезопасный словарь - ConcurrentBag
— неупорядоченная коллекция для быстрой вставки/чтения - ConcurrentQueue
и ConcurrentStack — очереди и стеки с поддержкой несколькими потоками - BlockingCollection
— обёртка вокруг коллекции с поддержкой блокирующих операций - OrderablePartitioner — для разделения упорядоченных источников данных на партиции
Пример использования ConcurrentDictionary:
var dict = new ConcurrentDictionary();
dict.AddOrUpdate("key", 1, (k, old) => old + 1); Эти коллекции реализованы с внутренней синхронизацией или lock-free алгоритмами, чтобы уменьшить накладные расходы и избежать общих блокировок. Тем не менее, сложные модификации нескольких коллекций всё ещё требуют внешней синхронизации или атомарных стратегий.
Распараллеливание циклов с Parallel
Самая простая и часто эффективная зона для распараллеливания — тяжёлые циклы. System.Threading.Tasks.Parallel предоставляет статические методы Parallel.For и Parallel.ForEach, которые автоматически распределяют итерации по доступным ядрам.
Ключевые принципы работы:
- Parallel создаёт не 1:1 потоков на итерацию, а использует пул рабочих потоков и назначает несколько итераций на поток.
- Parallel поддерживает локальное состояние на поток (localInit, body, localFinally), что позволяет собирать промежуточные результаты без частых lock’ов.
- Нужно убедиться, что тело цикла безопасно к параллельному выполнению (нет неконтролируемого доступа к общим ресурсам).
Пример с Parallel.ForEach и локальным состоянием (сериализация объектов в JSON и сбор строк):
var input = GetLargeListOfObjects();
var output = new List();
object outputLock = new object();
Parallel.ForEach(
input,
() => new List(), // localInit
(item, state, localList) => // body
{
var s = SerializeToJson(item);
localList.Add(s);
return localList; // передаём локальное состояние дальше
},
localList => // localFinally
{
lock (outputLock)
{
output.AddRange(localList);
}
}
); Таким образом вы избегаете lock’а в каждой итерации и выполняете блокировку лишь один раз на поток, чтобы слить локальные результаты в глобальную коллекцию.

Советы при использовании Parallel:
- Измерьте: иногда параллелизм не даёт выигрыша для лёгкой работы из-за накладных расходов на планирование.
- Ограничьте степень параллелизма через ParallelOptions.MaxDegreeOfParallelism, если хотите контролировать загрузку CPU.
- Используйте Partitioner или Partitioner.Create для больших упорядоченных наборов данных.
Типичные паттерны и идиомы
Ниже — краткий набор паттернов, которые часто применяются при многопоточности в .NET.
- Счётчики и флаги: Interlocked.Increment / CompareExchange.
- Буферизация и батчи: собирайте данные локально, потом сливайте одной операцией.
- Очереди задач: BlockingCollection для продуцентов/потребителей.
- Lock-free чтение: immutable объекты читаются без блокировок, обновления через обмен (Exchange/CompareExchange).
- Отказ от общей мутабельности: по возможности проектируйте потокобезопасные иммутабельные структуры.
Отладка, тестирование и мониторинг
Многопоточные баги часто трудно воспроизвести: они зависят от таймингов. Вот практики, которые повышают шансы найти ошибки на ранней стадии:
- Стресс-тесты под нагрузкой: прогоняйте твой код с большим количеством итераций и потоков.
- Детерминистические тесты: инструментируйте запуск, подставляйте тайминги, фейковые планировщики.
- Инструменты: Visual Studio Concurrency Visualizer, Thread Sanitizers (для других платформ), логи с идентификаторами потоков.
- Включайте временные задержки в тестах (усыпления) чтобы смоделировать гонки.
- Используйте временные таймауты и таймауты ожидания в production (не позволять потоку виснуть навсегда).
Критерии приёмки
- Все сценарии с параллельными изменениями покрыты тестами.
- Нет постоянных дедлоков при длительном прогоне.
- Память и объёмы логов находятся в пределах ожиданий при пиковых нагрузках.
Многопоточность в Unity
Unity не потокобезопасен на уровне API движка. Любые вызовы к Unity API (GameObject, Transform, Physics, SceneManager и т. п.) должны происходить из основного потока, иначе приложение может упасть.
Можно безопасно выполнять вычисления (векторная арифметика, сериализация, подготовка данных) в фоновых потоках, а затем отправлять результаты обратно на главный поток для применения к сцене.
Практики в Unity:
- Разделяйте вычисления и работу с движком. Чистые вычислительные классы не должны ссылаться на Unity API.
- Используйте главную очередь действий: фоновая задача добавляет задачу в ConcurrentQueue, главный поток в Update() обрабатывает очередь.
- Для высокопроизводительных задач рассмотрите Unity.JobSystem и Burst Compiler: предназначены для безопасного и эффективного многопоточного вычисления в рамках экосистемы Unity.
Когда многопоточность не поможет (контрпримеры)
- Малые операции: если операция выполняется быстрее, чем накладные расходы на создание/планирование потоков, параллелизм замедлит программу.
- IO-bound задачи без асинхронности: для операций ввода-вывода лучше использовать async/await и неблокирующие API, а не нагружать потоки ожиданием.
- Сильная взаимосвязь данных: если каждая итерация зависит от предыдущей, распараллеливание невозможно без изменения алгоритма.
Альтернативные подходы
- Async/await: эффективен для IO-bound задач, освобождает поток, пока операция ожидает завершения.
- Акторы: модели без общей мутабельности (например, Akka.NET) уменьшают сложность синхронизации.
- Параллелизм данных на GPU: для массивно-параллельных задач (векторные операции, матричные умножения) использовать GPU может быть эффективнее.
Руководства и чеклисты по ролям
Developer — чеклист:
- Определи, является ли задача CPU-bound или IO-bound.
- Попробуй простой Interlocked вместо lock для счётчиков.
- Используй Concurrent коллекции вместо ручных lock’ов, где возможно.
- Минимизируй область критической секции.
- Напиши стресс-тесты и юнит-тесты для сценариев гонки.
Architect — чеклист:
- Оцени степень параллелизма системы и возможный уровень конкуренции.
- Реши, где допустима eventual consistency, а где требуется строгая консистентность.
- Выбери стратегию (lock-based, lock-free, actor model, partitioning).
QA — чеклист:
- Запускай прогон длительный (smoke + stress) на CI с высокой параллельной нагрузкой.
- Используй инструменты трассировки и профилирования.
- Симулируй сетевые задержки и отказ узлов.
Мини-методология внедрения параллелизма
- Измерь текущую производительность (baseline).
- Идентифицируй тяжёлые участки (hot-spots).
- Выбери локальную стратегию: Interlocked, Concurrent коллекции или Parallel.
- Имплементируй и добавь тесты для гонок/дедлоков.
- Прогони нагрузочные тесты и сравни с baseline.
- При деплое мониторь метрики CPU, latency и ошибки.
Runbook при дедлоках
- Соберите дампы потоков и стеки вызовов.
- Проанализируйте взаимные блокировки объектов (какие lock’и удерживаются).
- Найдите порядок захвата lock’ов в коде и убедитесь, что он консистентен.
- Временно добавьте таймауты на ожидание и логирование для локализации проблемы.
- Исправьте порядок захвата или уменьшите область блокировок; рассмотрите использование TryEnter с таймаутом.
Примеры тестовых случаев
- Многократный параллельный инкремент счётчика и проверка итогового значения.
- Одновременные добавления/удаления в коллекцию и проверка целостности.
- Смоделированный дедлок: два потока пытаются захватить lock A и lock B в разном порядке — тест должен обнаружить зависание.
Decision flowchart
flowchart TD
A[Нужно ускорить] --> B{CPU-bound или IO-bound?}
B -->|CPU-bound| C[Распараллеливание/Parallel]
B -->|IO-bound| D[Использовать async/await]
C --> E{Есть простые счётчики/флаги?}
E -->|Да| F[Interlocked]
E -->|Нет| G{Много общих структур?}
G -->|Да| H[Concurrent коллекции]
G -->|Нет| I[lock или redesign]1-line глоссарий
- Race condition — конфликт доступа к общему ресурсу, приводящий к некорректному результату.
- Deadlock — взаимная блокировка потоков, которые ожидают ресурсы друг друга.
- Atomic operation — неделимая операция, выполняемая целиком.
- Lock-free — алгоритмы, позволяющие прогресс без использования блокировок.
Меры безопасности и производительности
- Ограничивайте MaxDegreeOfParallelism, чтобы не перегружать сервер и не снижать отзывчивость.
- Предпочитайте иммутабельные структуры для распространения данных между потоками.
- Снимайте профили и метрики перед и после внесения изменений, чтобы принимать решения, основанные на измерениях.
Локальные особенности для России и СНГ
- На инфраструктуре с ограниченными ресурсами (например, виртуалки с одним или двумя vCPU) распараллеливание может не дать прироста — измеряйте в целевой среде.
- При использовании облачных провайдеров учитывайте затраты: увеличение числа потоков обычно означает повышение потребления CPU и, возможно, стоимости.
Заключение
Многопоточность — мощный инструмент для ускорения приложений, но он требует дисциплины в проектировании и тестировании. Начинайте с простых атомарных примитивов (Interlocked), используйте потокобезопасные коллекции и распараллеливайте тяжёлые циклы через Parallel, не забывая про тесты и профилирование. В Unity ограничивайте операции движка главным потоком и используйте механизм очередей или Unity Job System для безопасных фоновых вычислений.
Important: многопоточность не заменяет правильную архитектуру. Часто лучшим решением будет переработать алгоритм так, чтобы уменьшить взаимозависимости данных.
Notes:
- Всегда измеряйте до и после — оптимизации без метрик легко ухудшают поведение.
- При сомнениях выбирайте простые, проверенные примитивы и добавляйте сложные lock-free решения только при подтверждённой необходимости.
Похожие материалы
Несколько аккаунтов Skype: Multi Skype Launcher
Журнал для работы: повысить продуктивность
Персональные звуки уведомлений на Android
Скачивание шоу Hulu для офлайн‑просмотра
Microsoft Start: персонализированная новостная лента