Атомики и порядки памяти
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Атомики и порядки памяти
Прошлый урок гонял
Mutexради одного+= 1: чтобы прибавить единицу к общему счётчику, поток брал замок, двигал число, отпускал замок, а все остальные ждали в очереди. Для одного числа это перебор. Замок сериализует потоки целиком, хотя нам нужна неделимость ровно одной операции. Сегодня спускаемся на уровень ниже замка, к атомарным типам, где+= 1становится одной неразрывной инструкцией процессора без всякой очереди. Но за эту скорость берут плату, и плата называется порядком памяти. Это самый аккуратный урок блока: тут легко написать код, который проходит тесты на твоей машине и ломается на чужой архитектуре. Разберём, что именно гарантирует каждыйOrderingи почему.
Идея
Атомарная операция это операция, которую процессор выполняет неделимо: ни один поток не застанет её на полпути. AtomicU64::fetch_add(1) читает значение, прибавляет единицу и записывает обратно за один шаг, который нельзя разорвать. Та самая гонка на счётчике из урока про потоки, где два потока теряли прибавку, тут невозможна: либо ты сделал прибавку целиком, либо ещё не начинал.
Казалось бы, проблема решена: бери атомики, и многопоточность безопасна. Но атомарность одной ячейки это только половина дела. Вторая половина это порядок, в котором поток видит чужие записи в разные ячейки. Современный процессор и компилятор переставляют операции ради скорости: запись, которую ты в коде сделал первой, другой поток может увидеть второй. Внутри одного потока это незаметно, иллюзия последовательности сохраняется. Но другой поток видит твою память как угодно переупорядоченной, если ты явно не потребовал иного.
Вот это требование и есть порядок памяти. Каждая атомарная операция в Rust принимает аргумент Ordering, и он управляет не самой операцией, а тем, что разрешено переставлять вокруг неё. Слабый порядок быстрый, но почти ничего не обещает про соседние данные. Сильный порядок медленнее, но синхронизирует потоки. Весь урок про то, как выбрать правильный.
Атомарный счётчик и Relaxed
Начнём со случая, где всё просто. Счётчик запросов: много потоков прибавляют, кто-то иногда читает итог.
use std::sync::atomic::{AtomicU64, Ordering};
static HITS: AtomicU64 = AtomicU64::new(0);
fn on_request() {
HITS.fetch_add(1, Ordering::Relaxed);
}
fn report() -> u64 {
HITS.load(Ordering::Relaxed)
}
Здесь стоит Relaxed, и это правильный выбор, а не лень. Relaxed обещает ровно две вещи. Первое: операция атомарна, прибавка не потеряется. Второе: у каждой отдельной ячейки есть единый total modification order, общий для всех потоков порядок её изменений. Поэтому сумма независимых прибавок всегда сходится правильно, в каком бы порядке потоки ни работали.
Чего Relaxed не даёт: он ничего не говорит про другие переменные. Если бы счётчик был связан с каким-то соседним данным («увеличил счётчик, значит буфер уже заполнен»), Relaxed эту связь не гарантировал бы. Но у чистого счётчика такой связи нет: важно лишь, чтобы прибавки не терялись, а в каком порядке потоки увидят промежуточные значения, неважно. Это и есть случай для Relaxed.
Где Relaxed ломается
Теперь случай, где связь между переменными есть. Один поток готовит данные и поднимает флаг готовности, другой ждёт флаг и читает данные. Наивно на Relaxed:
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
static DATA: AtomicU64 = AtomicU64::new(0);
static READY: AtomicBool = AtomicBool::new(false);
// поток-производитель
fn produce() {
DATA.store(42, Ordering::Relaxed);
READY.store(true, Ordering::Relaxed);
}
// поток-потребитель
fn consume() -> u64 {
while !READY.load(Ordering::Relaxed) {} // ждём флаг
DATA.load(Ordering::Relaxed) // может вернуть 0!
}
Логика читается как «сначала записали 42, потом подняли флаг, значит увидевший флаг увидит и 42». Но Relaxed этого не обещает. Две записи идут в разные ячейки, и процессор вправе сделать их видимыми в обратном порядке. Потребитель может увидеть READY == true, а DATA всё ещё 0, потому что публикация флага обогнала публикацию данных. Каждая ячейка атомарна по отдельности, но согласованности между ними нет. На x86 этот баг может не воспроизвестись никогда, а на ARM с его более слабой моделью памяти всплыть сразу. Это и есть ловушка, о которой предупреждали в начале.
Release и Acquire наводят порядок
Чтобы связать запись данных с поднятием флага, нужна пара сильнее Relaxed. Release на записи и Acquire на чтении работают в паре:
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
static DATA: AtomicU64 = AtomicU64::new(0);
static READY: AtomicBool = AtomicBool::new(false);
fn produce() {
DATA.store(42, Ordering::Relaxed);
READY.store(true, Ordering::Release); // запечатали всё, что выше
}
fn consume() -> u64 {
while !READY.load(Ordering::Acquire) {} // увидели флаг -> увидим и данные
DATA.load(Ordering::Relaxed) // теперь гарантированно 42
}
Прочитай это как договор. Release-запись флага запечатывает в себя всё, что поток сделал до неё, включая запись DATA = 42. Acquire-чтение, которое увидело именно это значение флага, распечатывает посылку: всё, что было до release-записи, теперь гарантированно видно. Запись DATA больше не может обогнать или отстать, она привязана к флагу. Обрати внимание: само DATA осталось Relaxed, синхронизацию несёт пара на флаге. Это типичный приём: тяжёлые данные пишут и читают расслабленно, а порядок наводит один release-acquire на управляющей ячейке.
happens-before: что это значит формально
То, что мы получили, называется отношением happens-before. Это единственная гарантия, на которую можно опираться в многопотоке: если операция A happens-before операции B, то всё, что сделала A, видно в B. Всё, что не связано отношением happens-before, может наблюдаться в любом порядке, и полагаться на «случайный» порядок нельзя.
Внутри одного потока happens-before это просто порядок строк сверху вниз. Между потоками его создаёт ровно одна конструкция: release-запись happens-before то acquire-чтение, которое прочитало записанное значение. В нашем примере: DATA = 42 happens-before READY.store(Release) (один поток, порядок строк), READY.store(Release) happens-before READY.load(Acquire) (release-acquire пара), а READY.load(Acquire) happens-before DATA.load (снова один поток). Цепочка сомкнулась, и DATA = 42 happens-before DATA.load. Значение увидено.
Из этого растут release-acquire цепочки: happens-before транзитивно, поэтому синхронизацию можно протянуть через несколько потоков, передавая эстафету от acquire к следующему release.
fetch-операции, AcqRel и SeqCst
fetch_add и compare_exchange это операции «прочитать и записать» одновременно. Им мало одного направления: чтение хочет Acquire, запись хочет Release. Для таких случаев есть AcqRel: read-часть это Acquire, write-часть это Release. Его берут, когда атомарная операция и забирает чужую публикацию, и сама публикует.
И есть самый сильный порядок, SeqCst: вдобавок к release-acquire он навязывает единый глобальный порядок всех SeqCst-операций, на котором сходятся все потоки. С ним проще рассуждать, потому что всё происходит «как будто по очереди», и поэтому новички ставят его везде. Но он самый дорогой, и в подавляющем большинстве задач честный release-acquire и точнее выражает намерение, и быстрее. Правило Мары Бос простое: если кажется, что нужен SeqCst, скорее всего тебе нужна правильно расставленная пара release-acquire, а SeqCst это признак того, что рассуждение ещё не доведено до конца.
compare_exchange: кирпич lock-free
Последняя операция, которую стоит увидеть сейчас, это compare_exchange. Она сравнивает ячейку с ожидаемым значением и, только если оно совпало, записывает новое. Это основа всех алгоритмов без замков: прочитал текущее, вычислил новое, попробовал подменить, при неудаче (кто-то успел вперёд) перечитал и повторил. Классический CAS-цикл:
use std::sync::atomic::{AtomicU64, Ordering};
// атомарно поднять «максимум»: записать value, если оно больше текущего
fn fetch_max(cell: &AtomicU64, value: u64) {
let mut current = cell.load(Ordering::Relaxed);
loop {
if value <= current {
return; // наш не больше, делать нечего
}
match cell.compare_exchange_weak(current, value, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => return, // подмена удалась
Err(actual) => current = actual, // кто-то опередил, перечитали и пробуем снова
}
}
}
Этот цикл «читай и пробуй, пока не выйдет» и есть сердце lock-free. К нему вернёмся в следующем уроке, где соберём на атомиках спинлок, и в уроке про lock-free, где CAS понесёт целые структуры данных.
Практика
Вернёмся к счётчику из прошлого урока и снимем с него Mutex: на атомиках он и проще, и быстрее. Заодно обоснуй, почему Relaxed тут достаточно.
Что унести из урока
Атомики это две разные гарантии, которые легко спутать. Первая: атомарность одной операции, прибавка не рвётся пополам, и её даёт даже Relaxed. Вторая: порядок, в котором поток видит записи в разные ячейки, и его Relaxed не даёт вовсе. Связать запись данных с публикацией флага умеет только пара release-acquire: release запечатывает всё, что было до записи, acquire это распечатывает, и между ними возникает happens-before, единственное отношение, на которое можно опираться. AcqRel нужен операциям чтения-записи, SeqCst это дорогой глобальный порядок, который почти всегда заменяется честной парой release-acquire. А compare_exchange в цикле это кирпич, из которого сложены и замки, и структуры без замков.
Дальше построим из атомиков настоящие примитивы: спинлок на AtomicBool, мьютекс поверх futex, condvar. Тогда release-acquire перестанет быть теорией и станет инструментом, которым ты управляешь руками.