17
0
0
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
Назад

Многопоточность. Снизу вверх. Синхронизация и атомарные действия

Время чтения 20 минут
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
17
0
0
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники

Добро пожаловать на заключительную часть серии статей про многопоточность в .NET. В предыдущих статьях мы разобрались, как работает процессор и операционная система, поговорили о потоках и асинхронности, а сегодня рассмотрим примитивы синхронизации, атомарные действия и конкурентные коллекции.

Многопоточность. Снизу вверх. Синхронизация и атомарные действия

Примитивы синхронизации

Механизм работы примитивов синхронизации мы подробно разбирали в статье про ОС. Здесь же посмотрим на практические примеры их использования и разберем, когда какой инструмент применять.

А зачем они вообще нужны? Представьте: у вас есть банковский счет, и два человека одновременно пытаются снять с него деньги. Без механизмов синхронизации система может прочитать баланс дважды (например, 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, который возвращает bool, — это нужно потому, что между проверкой существования ключа и добавлением элемента другой поток может успеть что-то изменить. 

А для сложных сценариев есть 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 в целом.

Комментарии0
Тоже интересно
Комментировать
Поделиться
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники