Получить доступ

Почему один поток может не увидеть изменение, сделанное другим?

MiddleКонкурентность
Ещё спрашивают так:
  • Что такое модель памяти и happens-before?
  • Зачем нужен volatile?
  • Что такое переупорядочивание инструкций?
Коротко

Компилятор, JIT и процессор переупорядочивают и кэшируют операции с памятью — для одного потока результат тот же, для другого нет. Без синхронизации запись может стать видна поздно или в другом порядке.

  • Модель памяти языка задаёт правила видимости: отношение happens-before
  • happens-before дают: отпускание и захват одной блокировки, запись и чтение volatile, start и join потока
  • volatile — видимость и порядок, но не атомарность составных операций

А ещё Классика: флаг остановки без volatile — цикл в другом потоке может не завершиться никогда

Так подсказку видит пользователь sotto на живом собеседовании — за пару секунд, пока вы думаете вслух.

Подробно

Для скорости все уровни переставляют операции:

  • компилятор и JIT выносят чтение переменной из цикла, переставляют независимые присваивания;
  • процессор выполняет инструкции не по порядку, держит записи в буфере записи до сброса в кэш;
  • у каждого ядра свои кэши; протоколы когерентности их согласуют, но порядок, в котором другие ядра видят записи, на ARM и POWER слабее, чем на x86.

Внутри одного потока это незаметно — результат как при последовательном выполнении. Но другой поток может увидеть записи в другом порядке или не увидеть совсем.

Модель памяти языка (Java Memory Model, модель C++11, Go) говорит, когда запись одного потока гарантированно видна в другом. В Java это отношение happens-before: если действие A happens-before B, то B видит результат A. Его создают:

  • отпускание монитора и последующий захват того же монитора;
  • запись в volatile-поле и последующее чтение этого поля;
  • Thread.start() и действия запущенного потока; действия потока и join() его завершения;
  • запись final-полей в конструкторе и чтение после публикации объекта;
  • операции из java.util.concurrent: положить в очередь — взять из неё.

Отношение транзитивно: всё, что поток сделал до записи в volatile-флаг, видно потоку, прочитавшему этот флаг.

volatile гарантирует видимость и запрещает переупорядочивание вокруг себя, но volatile int count; count++ — всё ещё гонка: чтение и запись отдельные. Для счётчиков — атомарные классы или блокировка.

Классические ошибки:

  • флаг running без volatile: JIT поднимет чтение из цикла, и while (running) не остановится;
  • double-checked locking без volatile: другой поток может получить ссылку на объект до того, как отработал конструктор;
  • публикация объекта через обычное поле — другой поток видит недоинициализированные поля. Безопасная публикация: final-поля, volatile, потокобезопасная коллекция, статический инициализатор.

В Python с GIL такие эффекты почти не встречаются, но гонки остаются; в Go модель памяти похожа на Java: синхронизация через каналы и sync.

Пример

class Worker implements Runnable {
    private volatile boolean running = true;   // без volatile цикл может не завершиться
    public void stop() { running = false; }
    public void run() {
        while (running) { doWork(); }
    }
}

Что хотят услышать

  • Источники проблемы: переупорядочивание и кэши.
  • Модель памяти и happens-before с примерами отношений.
  • Что даёт volatile и чего не даёт.
  • Флаг остановки и double-checked locking — примеры.

Частые ошибки

  • «volatile делает операции атомарными».
  • «На x86 это не проблема» — компилятор и JIT переставляют независимо от процессора.

Связанные вопросы

Закрытая бета

Подсказки на собеседовании — в реальном времени

sotto слышит вопрос интервьюера и показывает короткий ответ и план, как в блоке «Коротко». Оставьте контакт — пришлём доступ и промокод.

  • Промокод на 2 часа — сверх пробных 30 минут
  • Программа для Windows — скачать сразу, войти — через Яндекс ID или VK ID
  • Прямая связь с разработчиком: что улучшить — сделаем