Для скорости все уровни переставляют операции:
- компилятор и 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.