Гид по технологиям

Многопоточность в .NET: практическое руководство

• 9 min read • Программирование • Обновлено 21 Nov 2025
Многопоточность в .NET — практическое руководство
Многопоточность в .NET — практическое руководство

Быстрые ссылки

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

Иконка C# и многопоточности

Что делает многопоточность сложной?

По умолчанию весь код вашего приложения выполняется в основном потоке. Это упрощает рассуждения о состоянии программы: вы точно знаете порядок выполнения. Но современные процессоры имеют множество ядер, и если приложение выполняет всё последовательно, оно редко использует доступную вычислительную мощность полностью.

Ключевая проблема многопоточности — согласованность общего состояния. Если несколько потоков читают и изменяют один и тот же ресурс одновременно, поведение может зависеть от порядка выполнения операций. Это приводит к двум типичным классическим проблемам:

  • Гонки (race conditions) — неправильные или непредсказуемые результаты при одновременном доступе к разделяемым данным.
  • Дедлоки (deadlocks) — взаимная блокировка потоков, когда каждый ждёт освобождения ресурса, который удерживает другой.

Простейший пример гонки: счётчик, который инкрементируется из нескольких потоков. На абстрактном уровне это выглядит как “прибавить 1”, но на уровне CPU это серия действий: загрузить значение, увеличить, записать обратно. Если два потока читают одно и то же значение одновременно, оба могут записать один и тот же результат, и итоговый счёт меньше ожидаемого.

Иллюстрация гонки при инкременте переменной

В .NET есть примитивы для управления конкурентным доступом. Самый простой — ключевое слово lock, которое сериализует доступ к участку кода, позволяя одному потоку выполнять критическую секцию за раз. Для 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): вычисляем новое значение, пытаемся установить его только если текущее равно тому, что мы читали. Если между чтением и записью значение изменилось, повторяем.

Interlocked и CompareExchange на уровне операций CPU

Потокобезопасные коллекции

Коллекции из пространства 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.ForEach с локальными субтоталами

Советы при использовании Parallel:

  • Измерьте: иногда параллелизм не даёт выигрыша для лёгкой работы из-за накладных расходов на планирование.
  • Ограничьте степень параллелизма через ParallelOptions.MaxDegreeOfParallelism, если хотите контролировать загрузку CPU.
  • Используйте Partitioner или Partitioner.Create для больших упорядоченных наборов данных.

Типичные паттерны и идиомы

Ниже — краткий набор паттернов, которые часто применяются при многопоточности в .NET.

  1. Счётчики и флаги: Interlocked.Increment / CompareExchange.
  2. Буферизация и батчи: собирайте данные локально, потом сливайте одной операцией.
  3. Очереди задач: BlockingCollection для продуцентов/потребителей.
  4. Lock-free чтение: immutable объекты читаются без блокировок, обновления через обмен (Exchange/CompareExchange).
  5. Отказ от общей мутабельности: по возможности проектируйте потокобезопасные иммутабельные структуры.

Отладка, тестирование и мониторинг

Многопоточные баги часто трудно воспроизвести: они зависят от таймингов. Вот практики, которые повышают шансы найти ошибки на ранней стадии:

  • Стресс-тесты под нагрузкой: прогоняйте твой код с большим количеством итераций и потоков.
  • Детерминистические тесты: инструментируйте запуск, подставляйте тайминги, фейковые планировщики.
  • Инструменты: 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 с высокой параллельной нагрузкой.
  • Используй инструменты трассировки и профилирования.
  • Симулируй сетевые задержки и отказ узлов.

Мини-методология внедрения параллелизма

  1. Измерь текущую производительность (baseline).
  2. Идентифицируй тяжёлые участки (hot-spots).
  3. Выбери локальную стратегию: Interlocked, Concurrent коллекции или Parallel.
  4. Имплементируй и добавь тесты для гонок/дедлоков.
  5. Прогони нагрузочные тесты и сравни с baseline.
  6. При деплое мониторь метрики CPU, latency и ошибки.

Runbook при дедлоках

  1. Соберите дампы потоков и стеки вызовов.
  2. Проанализируйте взаимные блокировки объектов (какие lock’и удерживаются).
  3. Найдите порядок захвата lock’ов в коде и убедитесь, что он консистентен.
  4. Временно добавьте таймауты на ожидание и логирование для локализации проблемы.
  5. Исправьте порядок захвата или уменьшите область блокировок; рассмотрите использование 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 решения только при подтверждённой необходимости.
Поделиться: X/Twitter Facebook LinkedIn Telegram
Автор
Редакция

Похожие материалы

Несколько аккаунтов Skype: Multi Skype Launcher
Программное обеспечение

Несколько аккаунтов Skype: Multi Skype Launcher

Журнал для работы: повысить продуктивность
Productivity

Журнал для работы: повысить продуктивность

Персональные звуки уведомлений на Android
Android.

Персональные звуки уведомлений на Android

Скачивание шоу Hulu для офлайн‑просмотра
Стриминг

Скачивание шоу Hulu для офлайн‑просмотра

Microsoft Start: персонализированная новостная лента
Новости

Microsoft Start: персонализированная новостная лента

Как изменить имя в Epic Games быстро
Гайды

Как изменить имя в Epic Games быстро