Почему один поток может не увидеть изменение, сделанное другим?
- Что такое модель памяти и happens-before?
- Зачем нужен volatile?
- Что такое переупорядочивание инструкций?
Компилятор, JIT и процессор переупорядочивают и кэшируют операции с памятью — для одного потока результат тот же, для другого нет. Без синхронизации запись может стать видна поздно или в другом порядке.
- Модель памяти языка задаёт правила видимости: отношение happens-before
- happens-before дают: отпускание и захват одной блокировки, запись и чтение volatile, start и join потока
- volatile — видимость и порядок, но не атомарность составных операций
А ещё Классика: флаг остановки без volatile — цикл в другом потоке может не завершиться никогда
Подробно
Для скорости все уровни переставляют операции:
- компилятор и 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 переставляют независимо от процессора.
Проверьте себя
Один верный вариант. Подумайте, потом откройте ответ.
Показать варианты и ответ
- ✓Компилятор и процессор переупорядочивают и кэшируют операции; видимость гарантирует happens-before — блокировки, volatile, start и join
- ✗Запись в общую переменную сразу видна всем потокам, а проблемы бывают только из-за кэша браузера или ОС; volatile нужен лишь для атомарности count++
- ✗Переупорядочивание операций запрещено в современных процессорах
- ✗volatile делает составные операции атомарными
Связанные вопросы
Разбор собеседования с sotto
После собеседования sotto разбирает разговор: оценка, каждый вопрос — хорошо, частично или слабо, чего не хватило в ответе и над чем поработать к следующему разу. Первые 30 минут бесплатно.