Примитивы синхронизации
Механизм работы примитивов синхронизации мы подробно разбирали в статье про ОС. Здесь же посмотрим на практические примеры их использования и разберем, когда какой инструмент применять.
А зачем они вообще нужны? Представьте: у вас есть банковский счет, и два человека одновременно пытаются снять с него деньги. Без механизмов синхронизации система может прочитать баланс дважды (например, 1000 рублей), выполнить два списания по 800 рублей, и в итоге на счете окажется 200 рублей, а у тех, кто списывает деньги, — по 800. Примитивы синхронизации как раз помогают избежать подобных ситуаций.

Lock
Примитив lock — это самый простой способ синхронизировать доступ к разделяемому ресурсу внутри одного процесса. Он гарантирует, что только один поток может войти в критическую секцию.
class BankAccount
{
private decimal _balance = 1000;
private readonly object _locker = new object();
public void Withdraw(decimal amount)
{
lock (_locker)
{
// Только один поток может выполнять операцию списания
if (_balance >= amount)
{
Console.WriteLine($"Списываем {amount}, баланс был {_balance}");
_balance -= amount;
Console.WriteLine($"Новый баланс: {_balance}");
}
else
{
Console.WriteLine($"Недостаточно средств для списания {amount}");
}
}
}
}
Объект _locker служит маркером блокировки. Когда поток входит в блок lock (_locker), он «захватывает» этот объект. Другие потоки, пытающиеся войти в тот же блок с тем же объектом, будут ждать освобождения.
Под капотом lock раскрывается в использование класса Monitor:
Monitor.Enter(_locker);
try
{
// ваш код
}
finally
{
Monitor.Exit(_locker);
}
Когда использовать: для простой синхронизации внутри одного приложения.
Mutex
Мьютекс — более мощный инструмент, который может синхронизировать не только потоки внутри одного процесса, но и разные приложения.
Практический кейс: у вас есть приложение для синхронизации файлов с облаком. Запускать его дважды опасно: из-за параллельности файлы могут дублироваться или повредиться. Именованный мьютекс поможет запретить повторный запуск.
class SingleInstanceApp
{
private static Mutex _mutex;
static void Main()
{
// Пытаемся создать именованный мьютекс
_mutex = new Mutex(true, "MyUniqueAppName", out bool createdNew);
if (!createdNew)
{
Console.WriteLine("Приложение уже запущено!");
return;
}
try
{
Console.WriteLine("Приложение запущено. Нажмите Enter для завершения.");
Console.ReadLine();
}
finally
{
_mutex.ReleaseMutex();
}
}
}

Когда использовать: когда нужно синхронизировать между собой процессы, а не только потоки.
Semaphore
Семафор позволяет ограничить количество потоков, одновременно получающих доступ к ресурсу. Представьте сервер с пулом соединений к базе данных — одновременно может быть активно только ограниченное количество подключений.
class ConnectionPool
{
private readonly Semaphore _connectionSemaphore;
public ConnectionPool(int maxConnections)
{
// Создаем семафор, допускающий одновременное использование не более maxConnections соединений
_connectionSemaphore = new Semaphore(maxConnections, maxConnections);
}
public void ExecuteQuery(string query)
{
_connectionSemaphore.WaitOne(); // Запрос на доступ к соединению
try
{
// Код, который использует соединение с БД
Console.WriteLine($"Выполняем запрос: {query}");
Thread.Sleep(2000); // Имитация времени выполнения запроса
}
finally
{
_connectionSemaphore.Release(); // Освобождение соединения
}
}
}

Когда использовать: когда у вас есть ограниченный ресурс (соединения с БД, файловые дескрипторы, лицензии ПО) и нужно контролировать количество одновременных обращений к нему. А еще с помощью семафоров можно ограничить количество параллельных операций, чтобы не нагрузить БД/сервер при параллельной обработке каких-то данных.
Атомарные действия
Для некоторых базовых операций (инкремент, декремент, замена значения) использование lock
может быть избыточным. В .NET есть специальный класс Interlocked, который предоставляет набор методов для выполнения атомарных операций над примитивными типами данных без необходимости блокировок.
Increment и Decrement
Самый частый кейс — нужен счетчик, который может изменять множество потоков:
int counter = 0;
// НЕ потокобезопасно
counter++; // Операция состоит из: чтение -> инкремент -> запись
// Потокобезопасно
Interlocked.Increment(ref counter);
Почему counter++ не работает в многопоточности? Операция инкремента кажется атомарной, но на самом деле состоит из трех этапов: чтение значения из памяти, увеличение на 1, запись обратно. Между этими этапами другой поток может вмешаться и испортить результат.
Часто нам необходим счетчик для отслеживания прогресса многопоточных операций. Например, при параллельной обработке элементов в какой-то миграции:
class Counter
{
private int _unsafeCounter = 0;
private int _safeCounter = 0;
public void IncrementUnsafe()
{
// НЕ потокобезопасно - может привести к потере инкрементов
_unsafeCounter++;
}
public void IncrementSafe()
{
// Потокобезопасно - гарантированно атомарная операция
Interlocked.Increment(ref _safeCounter);
}
public void PrintResults()
{
Console.WriteLine($"Небезопасный счетчик: {_unsafeCounter}");
Console.WriteLine($"Безопасный счетчик: {_safeCounter}");
}}
}
// Program.cs
var counter = new Counter();
const int iterations = 100000;
const int threadCount = 10;
var tasks = new Task[threadCount];
// Запускаем 10 потоков, каждый делает 100,000 инкрементов
for (int i = 0; i < threadCount; i++)
{
tasks[i] = Task.Run(() =>
{
for (int j = 0; j < iterations; j++)
{
counter.IncrementUnsafe();
counter.IncrementSafe();
}
});
}
Task.WaitAll(tasks);
counter.PrintResults();
// Ожидаемый результат: 1,000,000 для обоих счетчиков
// Реальный результат:
// Небезопасный счетчик: 847,293 (случайное число меньше миллиона)
// Безопасный счетчик: 1,000,000 (всегда точно)
С методом Interlocked.Decrement работа аналогична — он атомарно уменьшает значение на 1.
Exchange — атомарная замена
Когда нужно заменить значение переменной и при этом получить предыдущее значение, используют Interlocked.Exchange
. Этот метод полезен для реализации паттернов конфигурации или флагов состояния:
class ConfigManager
{
private string _currentConfig = "default_config.json";
public string UpdateConfig(string newConfig)
{
string oldConfig = Interlocked.Exchange(ref _currentConfig, newConfig);
Console.WriteLine($"Конфигурация изменена с '{oldConfig}' на '{newConfig}'");
return oldConfig;
}
public string GetCurrentConfig()
{
return _currentConfig; // Чтение строки атомарно в .NET
}
}
Конкурентные коллекции
Теперь поговорим о готовых потокобезопасных структурах данных. В .NET есть специальные коллекции из пространства имен System.Collections.Concurrent
, которые спроектированы для работы в многопоточной среде.
ConcurrentDictionary
Самая популярная из конкурентных коллекций. В отличие от обычного Dictionary<TKey, TValue>
, с ConcurrentDictionary можно безопасно работать из множества потоков.
Что интересно, с конкурентным словарем взаимодействие строится по-другому: вместо привычного метода Add
здесь используется TryAdd
, который возвращает boo
l, — это нужно потому, что между проверкой существования ключа и добавлением элемента другой поток может успеть что-то изменить.
А для сложных сценариев есть AddOrUpdate
, который принимает функции для создания и обновления значений — это позволяет атомарно выполнить логику «если ключа нет, создай новое значение, если есть — обнови существующее».
ConcurrentDictionary, как и другие коллекции, часто используется для in-memory хранения различных параметров, настроек или конфигураций:
class ConfigManager
{
private readonly ConcurrentDictionary<string, string> _settings = new();
public void SetSetting(string key, string value)
{
_settings.AddOrUpdate(key, value, (k, oldValue) => {
Console.WriteLine($"Настройка {k} изменена с '{oldValue}' на '{value}'");
return value;
});
}
public string GetSetting(string key, string defaultValue = "")
{
return _settings.GetValueOrDefault(key, defaultValue);
}
public bool HasSetting(string key) => _settings.ContainsKey(key);
}
Когда использовать: когда нужен потокобезопасный словарь для кеширования, хранения сессий, счетчиков и т. д.
ConcurrentBag
ConcurrentBag<T> — потокобезопасная коллекция, которая не гарантирует порядок элементов. Она оптимизирована для сценариев, где множество потоков добавляют и извлекают элементы, например при пакетной обработке данных:
class DataBuffer<T>
{
private readonly ConcurrentBag<T> _buffer = new();
private readonly int _batchSize;
public DataBuffer(int batchSize = 100)
{
_batchSize = batchSize;
}
public void Add(T item)
{
_buffer.Add(item);
if (_buffer.Count >= _batchSize)
{
ProcessBatch();
}
}
private void ProcessBatch()
{
var batch = new List<T>();
// Извлекаем элементы для обработки с помощью метода TryTake
while (batch.Count < _batchSize && _buffer.TryTake(out T item))
{
batch.Add(item);
}
if (batch.Count > 0)
{
// Здесь логика пакетной обработки
}
}
public int GetBufferSize() => _buffer.Count;
}
Когда использовать: когда порядок элементов не важен, а нужна максимальная производительность для параллельного добавления и извлечения элементов.
Помимо словаря и Bag, есть еще ConcurrentQueue и ConcurrentStack. Они нужны для сохранения порядка при параллельной обработке данных, и о них можно почитать подробнее в документации Microsoft.
Заключение
Примитивы синхронизации, атомарные операции и конкурентные коллекции — это основные инструменты для безопасной работы в многопоточной среде. Выбор конкретного инструмента зависит от задачи: для простых операций подойдет Interlocked
, для сложной логики — lock
, для готовых структур данных — конкурентные коллекции.
Спасибо за прочтение статьи! А еще я веду телеграм-канал Flexible Coding, где пишу про .NET и IT в целом.