Какие примитивы синхронизации потоков ты знаешь в .NET?

Ответ

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

1. Низкоуровневые (на основе атомарных операций)

  • Ключевое слово volatile: Гарантирует, что чтение и запись в поле будут атомарными, а компилятор или процессор не переупорядочат операции с ним. Не является полноценным примитивом синхронизации.
  • Класс Interlocked: Предоставляет атомарные операции для простых действий с числами и ссылками.
    int sharedCounter = 0;
    // Атомарное увеличение
    Interlocked.Increment(ref sharedCounter);
    // Атомарное сравнение и обмен (CAS - Compare-And-Swap)
    int oldValue, newValue;
    do {
        oldValue = sharedCounter;
        newValue = oldValue + 10;
    } while (Interlocked.CompareExchange(ref sharedCounter, newValue, oldValue) != oldValue);

2. Примитивы на основе ядра ОС (медленные, но мощные)

Используют переход в режим ядра, что дорого. Подходят для синхронизации между процессами.

  • Mutex (мьютекс): Примитив взаимоисключения. Может быть именованным для межпроцессного взаимодействия.
  • Semaphore и SemaphoreSlim: Ограничивают количество потоков, которые могут одновременно получить доступ к ресурсу.
  • EventWaitHandle (AutoResetEvent, ManualResetEvent): Позволяют потоку сигнализировать другим потокам о наступлении события.

3. Гибридные и пользовательские примитивы (быстрые для внутрипроцессной синхронизации)**

Сначала пытаются выполнить операцию в пользовательском режиме, при конфликте переходят в режим ядра.

  • lock (ключевое слово) / Monitor: Самый распространенный примитив для обеспечения эксклюзивного доступа к блоку кода.
    private readonly object _syncRoot = new object();
    public void ThreadSafeMethod()
    {
        lock (_syncRoot) // Вход в критическую секцию
        {
            // Работа с общим ресурсом
        }
    }
  • ReaderWriterLockSlim: Оптимизирован для сценариев, где много операций чтения и мало записи. Позволяет множественное чтение, но эксклюзивную запись.
  • ManualResetEventSlim и SemaphoreSlim: Облегченные версии своих аналогов на основе ядра, рекомендуются для использования внутри одного процесса.

Общее правило: Начинайте с самых "легковесных" примитивов (Interlocked, lock). Переходите к более сложным (ReaderWriterLockSlim, SemaphoreSlim) только при доказанной необходимости и понимании их семантики.

Ответ 18+ 🔞

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

1. Самые простые, на уровне процессора (быстрые, но для простых случаев)

  • Ключевое слово volatile: Это не волшебная таблетка от всех проблем, а так, намёк компилятору. Говорит: "Э, дружок, не переставляй операции с этим полем местами и всегда читай из памяти, а не из своего кэша". Для синхронизации одного его — маловато будет.
  • Класс Interlocked: Вот это уже дело. Позволяет делать простые операции (прибавить, сравнить и поменять) так, чтобы это было атомарно, то есть неразрывно. Прямо как на уровне процессорной инструкции.
    int sharedCounter = 0;
    // Атомарно плюсанули на единичку
    Interlocked.Increment(ref sharedCounter);
    // Атомарное "сравнял и если ок, то подменил" (CAS)
    int oldValue, newValue;
    do {
        oldValue = sharedCounter;
        newValue = oldValue + 10;
    } while (Interlocked.CompareExchange(ref sharedCounter, newValue, oldValue) != oldValue);

    Быстро, чётко. Но если логика сложная — уже не натянешь.

2. Тяжёлая артиллерия, через ядро ОС (медленные, но всесильные)

Тут уже серьёзно. Поток, если не может пройти, засыпает, и управление уходит в ядро операционки. Это овердохуища накладных расходов, зато могут синхронизировать даже разные процессы.

  • Mutex (мьютекс): Как один ключ от туалета на весь офис. Кто ключ взял — тот и царь. Можно даже именованный сделать, чтобы между разными программами синхронизироваться.
  • Semaphore и SemaphoreSlim: Как турникет в метро. Запускает одновременно только N потоков. Остальные ждут в очереди.
  • EventWaitHandle (AutoResetEvent, ManualResetEvent): Как кнопка "вызова" лифта. Один поток нажал — сигнал пошёл, другие проснулись. AutoResetEvent сам сбрасывается после пробуждения одного, ManualResetEvent — как флажок, пока вручную не опустишь.

3. Гибридные штуки (умные и обычно быстрые)

Хитрые ребята. Сначала пытаются договориться в пользовательском режиме (быстро), а если не получается — ну тогда уже идут в ядро (медленно). Идеально для драк внутри одного процесса.

  • lock (ключевое слово) / Monitor: Классика жанра, хлеб с маслом. Просто оборачиваешь кусок кода, и только один поток в него может зайти. Остальные тупо ждут.
    private readonly object _syncRoot = new object();
    public void ThreadSafeMethod()
    {
        lock (_syncRoot) // Захватили замок
        {
            // Делаем что-то с общим ресурсом, не опасаясь
        } // Замок сам отпустится, даже если вылетит исключение
    }
  • ReaderWriterLockSlim: Для умных сценариев, где много кто читает, но редко кто-то пишет. Позволяет пускать всех читателей одновременно, а вот писателя — только одного и в полной тишине. Экономит время, если читателей дохуя.
  • ManualResetEventSlim и SemaphoreSlim: То же самое, что их старшие братья из пункта 2, но облегчённые, заточенные под работу в рамках одного процесса. Почти всегда бери их, а не те.

Главный совет, как не выстрелить себе в ногу: Не гонись за сложным. Начинай с самого простого, что решает задачу — Interlocked для счётчиков, lock для кусков кода. Потом, если упрёшься в конкретные проблемы производительности и точно понимаешь, что тебе нужно (например, разделение на читателей и писателей), тогда уже хватай ReaderWriterLockSlim или SemaphoreSlim. А межпроцессные мьютексы — это вообще на крайний случай, когда уже совсем припёрло.