Что такое потокобезопасность (thread safety)?

Ответ

Потокобезопасность — это свойство кода (класса, метода, структуры данных) корректно работать в многопоточной среде, когда несколько потоков обращаются к нему одновременно, без возникновения ошибок данных или неопределённого поведения.

Основные угрозы, от которых защищает потокобезопасность:

  1. Состояние гонки (Race Condition): Результат выполнения зависит от неуправляемого порядка выполнения потоков.
  2. Некорректные видимые изменения (Visibility): Изменения, сделанные одним потоком в общей памяти, могут быть не сразу (или никогда) видны другому потоку из-за кэширования процессором.
  3. Взаимная блокировка (Deadlock): Два или более потока бесконечно ждут друг друга, освобождая необходимые им ресурсы.

Механизмы обеспечения потокобезопасности в C#:

  • lock (критическая секция): Самый простой способ для защиты блока кода.

    private readonly object _syncLock = new object();
    private int _sharedCounter = 0;
    
    public void IncrementSafe()
    {
        lock (_syncLock) // Только один поток может выполнять этот блок в данный момент.
        {
            _sharedCounter++; // Операция теперь атомарна и безопасна.
        }
    }
  • Атомарные операции с Interlocked: Для простых операций над примитивами (инкремент, сравнение с обменом). Гораздо быстрее lock.

    Interlocked.Increment(ref _sharedCounter);
  • Специализированные примитивы: Monitor, Mutex (для межпроцессного взаимодействия), Semaphore, ReaderWriterLockSlim (оптимизирован для сценариев «много чтений, редкая запись»).

  • Потокобезопасные коллекции (ConcurrentQueue, ConcurrentDictionary): Встроенные реализации, избавляющие от необходимости ручной синхронизации.

  • Неизменяемость (Immutability): Самый надёжный способ. Если объект после создания не может быть изменён, его можно свободно передавать между потоками.

    public record ImmutableData(int Id, string Name); // Record в C# 9+ по умолчанию неизменяем.

Пример опасного (непотокобезопасного) кода:

int counter = 0;
Parallel.For(0, 10000, i =>
{
    counter++; // Операция не атомарна: чтение -> увеличение -> запись.
});
Console.WriteLine(counter); // Результат будет меньше 10000 из-за потерь при гонке.

Важно: Потокобезопасность имеет стоимость (блокировки замедляют код). Не стоит делать потокобезопасным всё подряд. Применяйте синхронизацию только к действительно общим, изменяемым данным, и выбирайте наиболее подходящий и менее затратный механизм для конкретной задачи.

Ответ 18+ 🔞

Слушай, вот есть такая штука — потокобезопасность. Это когда твой код не сходит с ума, если на него одновременно набросится куча потоков, как голодные тараканы на крошку. По сути, это гарантия, что всё будет работать чётко, а данные не превратятся в кашу.

Основные проблемы, от которых эта штука тебя спасает:

  1. Состояние гонки (Race Condition): Представь, два потока лезут в одну переменную, как два мужика в один холодильник за последним пивом. Кто первый схватит — тот и выпил, а второй останется с носом. В коде это значит, что результат выполнения — лотерея, зависит от того, какой поток в какой микросекунду чихнул.
  2. Проблемы видимости (Visibility): Один поток что-то там посчитал и записал, а другой поток этого не видит, потому что данные застряли в кэше процессора, как в берлоге. И живёт второй поток в прошлом, со старыми значениями. Пиздец, да?
  3. Взаимная блокировка (Deadlock): Классика! Два потока упёрлись, как бараны. Один говорит: «Дай ресурс А, тогда отдам Б». Другой: «Нет, ты сначала дай Б, тогда отдам А». И стоят так до скончания времён. Компьютер вроде работает, а по факту — завис.

Чем в C# от этого отбиваться:

  • lock (обычная блокировка): Проще некуда. Всё, что внутри lock, в один момент времени может делать только один поток. Остальные ждут, как в очереди в сортир на вечеринке.

    private readonly object _syncLock = new object(); // Это наш "замок" на дверь
    private int _sharedCounter = 0;
    
    public void IncrementSafe()
    {
        lock (_syncLock) // Ключ взял — заходи, делай дела.
        {
            _sharedCounter++; // Теперь эта операция защищена.
        } // Ключ положил — следующий заходит.
    }
  • Атомарные операции (Interlocked): Для простых действий с числами — самое то. Не надо городить lock, работает на уровне процессора, быстро, как удар током.

    Interlocked.Increment(ref _sharedCounter); // Раз — и готово, без всякой возни.
  • Разная спецтехника: Monitor, Mutex (если нужно между процессами договориться), Semaphore, ReaderWriterLockSlim (это когда читателей много, а писатель один, чтобы не толкались).

  • Потокобезопасные коллекции: ConcurrentQueue, ConcurrentDictionary и прочие. Создали специально, чтобы ты не писал свой велосипед с блокировками. Бери и пользуйся — они уже всё за тебя продумали.

  • Неизменяемость (Immutability): Абсолютное оружие. Если объект, после того как его создали, нельзя изменить, то хоть тысячу потоков запусти — им нечего будет портить. Они могут только читать. Гениально и просто.

    public record ImmutableData(int Id, string Name); // Record — создали и забыли. Менять нельзя.

Вот тебе наглядный пример, как всё может посрать:

int counter = 0;
Parallel.For(0, 10000, i =>
{
    counter++; // Смотри-ка, операция же не атомарная! Прочитал, прибавил, записал.
});
Console.WriteLine(counter); // И получишь ты тут не 10000, а какую-нибудь случайную хуйню, типа 8765. Потому что потоки друг другу наступили на ноги.

И главное, запомни: Потокобезопасность — это не бесплатно. Каждый lock — это тормоза. Не надо оборачивать в него всё подряд, как в целлофановый пакет. Защищай только то, что реально является общим изменяемым состоянием, и выбирай инструмент потоньше. Не стреляй из пушки по воробьям, а то производительность твоего кода будет ниже плинтуса.