Avatar ·

В C#: Volatile, Interlocked или lock — что выбрать для потокобезопасности простого флага?

Привет, коллеги! Я начинающий C# разработчик и пытаюсь разобраться с многопоточностью. У меня есть простой класс, который запускает фоновый поток. Поток должен работать, пока флаг `_isRunning` равен true. Другой поток (например, UI) может установить этот флаг в false, чтобы остановить фоновую работу. Вот упрощённый код: ```csharp public class Worker { private bool _isRunning = true; public void Start() { Task.Run(() => { while (_isRunning) { // Какая-то работа Console.WriteLine("Working..."); Thread.Sleep(100); } }); } public void Stop() { _isRunning = false; } } ``` Я прочитал, что простое чтение/запись bool не является атомарным на уровне процессора, и компилятор может оптимизировать цикл, закешировав значение `_isRunning` в регистре. Тогда поток никогда не увидит изменения. В C# есть три инструмента для решения: `volatile`, `Interlocked` и `lock`. Я понимаю, что `lock` — это тяжелая артиллерия, `volatile` — лёгкая, а `Interlocked` — где-то посередине. Но я не понимаю, когда какой использовать. Для моего примера с флагом — какой подход правильный? В чём разница в производительности и гарантиях? Объясните, пожалуйста, на пальцах, с примерами кода. <<>>

Краткий ответ: для простого флага остановки потока — используйте volatile или Interlocked. lock — избыточен.

Вы правильно заметили проблему: без специальных маркеров компилятор, JIT и процессор могут кешировать поле _isRunning в регистре, и цикл while (_isRunning) никогда не увидит изменений из другого потока. Это приводит к "зависанию" потока.

Все три подхода (volatile, Interlocked, lock) решают эту проблему, но с разной степенью "тяжести" и гарантиями. Давайте разберём каждый.

1. volatile — лёгкий барьер памяти

Ключевое слово volatile говорит компилятору и JIT: "Не оптимизируй это поле! Всегда читай его из оперативной памяти, а не из кеша. И не переставляй операции чтения/записи через этот барьер".

Это самый лёгкий способ для простых флагов:

private volatile bool _isRunning = true;

public void Stop()
{
    _isRunning = false; // Запись сразу видна всем потокам
}

Минусы volatile:

  • Работает только с типами, размер которых ≤ 4 байт (int, bool, ссылки) на 32-битных системах (и ≤ 8 байт на 64-битных).
  • Не гарантирует атомарности! Если вы делаете _counter++, то volatile не спасёт — операция чтение-модификация-запись не атомарна.
  • Может быть избыточным для сложной логики.

2. Interlocked — атомарные операции

Класс Interlocked предоставляет атомарные методы для чтения, записи и модификации. Он гарантирует как атомарность, так и барьер памяти.

Для вашего флага можно использовать Interlocked.Exchange и Interlocked.CompareExchange:

private int _isRunning = 1; // Используем int, так как Interlocked не работает с bool напрямую

public void Start()
{
    Task.Run(() =>
    {
        while (Interlocked.CompareExchange(ref _isRunning, 0, 0) == 1) // Атомарное чтение
        {
            Console.WriteLine("Working...");
            Thread.Sleep(100);
        }
    });
}

public void Stop()
{
    Interlocked.Exchange(ref _isRunning, 0); // Атомарная запись
}

Или проще, используя Interlocked.Read (только для 64-битных значений на 32-битных системах):

while (Interlocked.Read(ref _isRunning) == 1)

Плюсы Interlocked: атомарность + барьер памяти. Идеально для счётчиков, флагов, переключения состояний.

Минусы: чуть больше кода, чем volatile. Не работает с bool напрямую (нужен int).

3. lock — тяжелая артиллерия

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

private readonly object _lock = new object();
private bool _isRunning = true;

public void Start()
{
    Task.Run(() =>
    {
        bool shouldRun;
        lock (_lock) { shouldRun = _isRunning; }
        while (shouldRun)
        {
            Console.WriteLine("Working...");
            Thread.Sleep(100);
            lock (_lock) { shouldRun = _isRunning; }
        }
    });
}

public void Stop()
{
    lock (_lock) { _isRunning = false; }
}

Для простого флага это избыточно: вы платите за захват монитора (блокировка), хотя вам нужна только видимость изменений, а не взаимное исключение.

Итоговая рекомендация

  • Для простого флага остановки потока: используйте volatile — это самый простой и производительный вариант.
  • Для счётчиков или операций типа "прочитай-измени-запиши": используйте Interlocked (например, Interlocked.Increment).
  • Для сложных структур данных или необходимости синхронизации нескольких потоков: используйте lock (или более современные ReaderWriterLockSlim, SemaphoreSlim).

В вашем примере с bool-флагом volatile — идеальное решение.

<<>>

Дополнение: CancellationToken и современные подходы

В современном C# (начиная с .NET 4.0) для остановки фоновых задач лучше использовать CancellationTokenSource и CancellationToken. Это более идиоматичный и гибкий подход, чем ручные флаги.

public class Worker
{
    private CancellationTokenSource _cts;

    public void Start()
    {
        _cts = new CancellationTokenSource();
        var token = _cts.Token;

        Task.Run(() =>
        {
            while (!token.IsCancellationRequested)
            {
                Console.WriteLine("Working...");
                Thread.Sleep(100);
            }
        }, token);
    }

    public void Stop()
    {
        _cts?.Cancel();
        _cts?.Dispose();
    }
}

Преимущества:

  • Встроенная поддержка во всей экосистеме .NET (Task, async/await, Parallel).
  • Можно передавать таймауты и связывать несколько токенов.
  • Не нужно думать о volatile/Interlocked — CancellationToken уже потокобезопасен внутри.

Однако, если вы пишете низкоуровневый код (например, собственную блокировку или работу с неуправляемой памятью), понимание volatile и Interlocked необходимо. Для прикладных задач — используйте готовые абстракции.