Атомики и memory ordering в Rust
Самая сеньорская часть блока. Спрашивают, что делает fetch_add, чем Relaxed отличается от SeqCst и какая пара нужна для публикации данных через флаг. Здесь проверяют, понимаешь ли ты модель памяти, а не только API.
Стержень: атомарность и порядок - разные свойства; Ordering управляет вторым, а не первым.
// Формулировки: «зачем Ordering?», «хватит ли Relaxed для счётчика?», «как опубликовать данные через флаг?»
Атомарность - не то же, что порядок
fetch_add выполняет чтение, изменение и запись как одну неделимую операцию: инкремент не потеряется, сколько бы потоков ни соревновалось. Возвращается старое значение - на этом строят выдачу уникальных идентификаторов.
Ordering отвечает за другое: за то, как эта операция упорядочена относительно остальных обращений к памяти. Relaxed не даёт никаких гарантий соседям - компилятор и процессор вправе переставлять другие операции вокруг него. Для счётчика, чьё значение читают в конце, этого достаточно, и проверено: восемь потоков по тысяче инкрементов с Relaxed дают ровно 8000.
// Ошибка не в том, что «Relaxed теряет инкременты», а в том, что он ничего не говорит о видимости соседних записей.
- read-modify-write
- неделимая операция чтения, изменения и записи
- Ordering
- требования к порядку видимости соседних операций
Release, Acquire и публикация
Типовой сценарий: поток записывает данные, потом ставит флаг ready. Чтобы читатель, увидевший флаг, гарантированно увидел и данные, нужна пара. Release на записи флага не даёт предыдущим записям уехать после него. Acquire на чтении не даёт последующим чтениям уехать до него. Вместе получается отношение «происходит раньше».
SeqCst сработает тоже, но добавит глобальный порядок для всех операций это дороже, особенно на слабых архитектурах вроде ARM. Его берут, когда рассуждать о более слабых гарантиях тяжело, и это законный выбор: понятность важнее пары процентов.
// compare_exchange обновляет значение, только если оно не изменилось с момента чтения. На нём строят безблокировочные структуры, и на нём же чаще всего ошибаются, забывая повторить попытку при неудаче.
- Release/Acquire
- пара, создающая отношение «происходит раньше»
- SeqCst
- самый строгий порядок: единый для всех операций
Как отвечать: «Хватит ли Ordering::Relaxed для счётчика?»
Для чистого счётчика - да. Атомарность у всех Ordering одинаковая: fetch_add с Relaxed не потеряет инкремент, сколько бы потоков ни писало. Relaxed не даёт другого - гарантий о порядке соседних операций с памятью. Поэтому если счётчик просто считает и его значение читают в конце, этого достаточно и это дешевле. Но как только атомик используется для публикации данных - записал буфер, потом поднял флаг, нужна пара Release на записи и Acquire на чтении, иначе читатель может увидеть флаг раньше самих данных.
Ты разделяешь атомарность и упорядочивание это ровно та граница, на которой сыпется большинство. Плюс даёшь критерий, когда Relaxed перестаёт годиться.
На чём валятся
- −Думают, что Relaxed может потерять инкремент.
- −Публикуют данные через флаг с Relaxed и получают невидимые для читателя записи.
- −Ставят SeqCst везде и не могут объяснить, что именно он гарантирует.
- −Путают местами Release и Acquire.
- −Строят на compare_exchange свою очередь вместо мьютекса, не имея на то оснований.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 12, остальные разбираются в тренажёре.
- Поток пишет данные, затем ставит флаг ready. Какая пара упорядочиваний нужна?A)Relaxed с обеих сторон: флаг всё равно атомаренB)Release при записи флага, Acquire при чтенииC)Acquire при записи, Release при чтенииD)SeqCst обязателен: другие варианты тут не работают
показать ответ и разбор
+B)Release при записи флага, Acquire при чтении// разбор: Release на записи флага не даёт предыдущим записям уехать после него, Acquire на чтении не даёт последующим чтениям уехать до него. Вместе получается отношение «происходит раньше»: увидел флаг — увидел и данные. Relaxed этого не обещает, а SeqCst сработает, но добавит лишний глобальный порядок и цену на слабых архитектурах.
- Когда атомик уместнее мьютекса?A)Когда защищаемое состояние — одно машинное слово и операция однаB)Когда потоков много: мьютекс на большом числе потоков не масштабируетсяC)Когда критическая секция длинная, но конкуренция низкаяD)Когда нужно защитить структуру целиком без копирования
показать ответ и разбор
+A)Когда защищаемое состояние — одно машинное слово и операция одна// разбор: Атомик закрывает узкий случай: счётчик, флаг, версия — одно слово и одна операция без блокировки. Как только нужно согласованно поменять два поля или пройти по коллекции, атомиков не хватает, а самодельная синхронизация на compare_exchange быстро становится источником трудноуловимых багов. Мьютекс в таких местах и понятнее, и обычно достаточно быстр.
- Зачем нужен compare_exchange?A)Заменить значение и одновременно заблокировать доступ другим потокамB)Сравнить два атомика и оставить большийC)Проверить, что тип атомика совпадает с ожидаемымD)Обновить значение, только если оно не изменилось с момента чтения
показать ответ и разбор
+D)Обновить значение, только если оно не изменилось с момента чтения// разбор: Это основа безблокировочных алгоритмов: читаем текущее, считаем новое, пытаемся записать при условии, что старое ещё на месте. Не получилось — значит кто-то опередил, и цикл повторяется. Отсюда две частые ловушки: забытый повтор при неудаче и предположение, будто неизменившееся значение означает отсутствие изменений вообще.
- Чем AtomicUsize отличается от обычного usize под мьютексом?A)Атомик и мьютекс отличаются лишь синтаксисом, внутри у них одинаковая реализацияB)Атомик даёт более точный результат при высокой конкуренции между потоками программыC)Атомик работает только с числами до 32 бит, а мьютекс — со значениями произвольного типаD)Атомик выполняет операцию инструкцией процессора, без захвата и освобождения блокировки
показать ответ и разбор
+D)Атомик выполняет операцию инструкцией процессора, без захвата и освобождения блокировки// разбор: Атомарная операция выполняется одной инструкцией с гарантией неделимости: ни один поток не увидит промежуточного состояния. Мьютекс же добавляет захват, возможный уход в ожидание и пробуждение — дороже, но зато защищает произвольно сложную секцию. Отсюда правило выбора: одно машинное слово и одна операция — атомик, всё остальное — блокировка.
- Что вернёт compare_exchange, если текущее значение не совпало с ожидаемым?A)Ok с прежним значением: операция считается успешной, но значение не меняетсяB)Панику, потому что несовпадение означает гонку между потоками программыC)Err с фактическим текущим значением — по нему строят следующий виток циклаD)Err без данных: узнать текущее значение можно только отдельным вызовом load
показать ответ и разбор
+C)Err с фактическим текущим значением — по нему строят следующий виток цикла// разбор: Успех даёт Ok со старым значением, неудача — Err с тем, что лежит сейчас. Это и делает возможным цикл CAS: прочитали текущее, посчитали новое, попытались записать, при неудаче взяли из Err свежее значение и повторили. Именно так и строят безблокировочные структуры, а для простых случаев есть готовый fetch_update, который этот цикл пишет за вас.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.