Спинлокпримитив синхронизации, в котором ядро в цикле проверяет и захватывает блокировку, не переходя в состояние ожидания на паре LDAXR/STXR выглядит безупречно: загрузили слово блокировки эксклюзивно, проверили, записали эксклюзивно, повторили при неудаче. Но на реальном железе — Raspberry Pi 5 с Cortex-A76 — эта конструкция падает с Data Abortисключением, возникающим при выполнении инструкции загрузки или сохранения прямо на инструкции эксклюзивной загрузки. Разберём, как устроен механизм эксклюзивного доступа, почему он зависит от атрибутов памяти и при каких условиях перестаёт работать.
Зачем нужны эксклюзивные доступы
Пары LDXR/STXR и LDAXR/STXR реализуют атомарное чтение-изменение-запись. Работают они в связке: STXR завершается успешно только если с момента выполнения LDXR/LDAXR по тому же адресу не наблюдалось других записей. Именно этот условный успех и даёт атомарность — без него нельзя построить ни спинлок, ни CASоперацию сравнения с обменом, атомарно заменяющую значение по адресу, если оно совпало с ожидаемым.
Атомарность обеспечивают эксклюзивные мониторыаппаратные механизмы, отслеживающие, что между эксклюзивной загрузкой и эксклюзивной записью по адресу не было чужих записей. У каждого ядра есть собственный локальный монитор, а все ядра имеют доступ к глобальному. Каждый монитор находится в одном из двух состояний — openсостояние, в котором монитор не удерживает резервирование по адресу или exclusiveсостояние, в котором монитор удерживает резервирование по адресу эксклюзивной загрузки. При выполнении load exclusiveэксклюзивной загрузки, которая переводит монитор в состояние exclusive и запоминает адрес монитор переводится в exclusive и запоминает адрес загрузки. При выполнении store exclusiveэксклюзивной записи, которая проходит только если все мониторы в состоянии exclusive и адрес совпадает мониторы проверяются: успех возможен, только если все они в состоянии exclusive и адрес совпадает.
Спинлок на этой основе строится как цикл: эксклюзивная загрузка слова, проверка занятости, эксклюзивная запись единицы и повтор при неудаче. Разблокировка выполняется обычной store-release инструкцией, записывающей ноль.
ldaxr %w0, [%2]
cbnz %w0, 1b
mov %w0, #1
stxr %w1, %w0, [%2]
cbnz %w1, 1bСлово блокировки 32-битное, отсюда w-регистры: 0 означает разблокировано, 1 — заблокировано. Буква «a» в ldaxr — acquire-семантика: она гарантирует, что все обращения к памяти после загрузки не переупорядочиваются перед ней. Разблокировка — stlr wzr, [%0], запись нуля.
Где живёт монитор: shareability
Выбор монитора зависит от shareabilityнастройки памяти, определяющей, в каком домене и для каких наблюдателей операции с памятью должны наблюдаться завершёнными за конечное время и без явного обслуживания кэша. Она бывает Non-Shareableнастройка, при которой операции с памятью наблюдаются завершёнными только для самого ядра, Inner-Shareableнастройка, при которой операции с памятью наблюдаются завершёнными для всех наблюдателей внутри кластера ядер или Outer-Shareableнастройка, при которой операции с памятью наблюдаются завершёнными для наблюдателей за пределами кластера, во всей системе.
Эксклюзивные обращения к памяти, помеченной как Non-shareable, затрагивают только локальный монитор. Обращения к shareable-памяти (Inner или Outer) задействуют и локальный, и глобальный мониторы. Локальный и глобальный мониторы могут разделять логику: один из подходов — реализовать глобальный монитор для кэшируемых мест комбинацией локальных мониторов ядер и логики когерентности кэшей.
В мониторах хранится не полный адрес, а только старшие биты — ERGExclusive Reservation Granule, гранула резервирования, внутри которой два разных адреса выглядят для монитора как одна запись. На типичной системе ERG совпадает с размером строки кэша.
Требование Arm к памяти
Архитектурно атомарность эксклюзивных инструкций гарантирована для Normal memory со следующими атрибутами:
Inner Shareable, Inner Write-Back, Outer Write-Back Normal memory
with Read allocation hints and Write allocation hints and not transientАналогичная гарантия есть для Outer Shareable с теми же Write-Back атрибутами. А вот память типа Device, Non-cacheable или трактуемая как Non-cacheable в реализации с аппаратной когерентностью кэшей может не поддерживать атомарные инструкции.
Это и есть корень проблемы: эксклюзивная операция к shareable-памяти обращается к глобальному монитору, а тот может быть реализован через логику когерентности кэшей. Если кэшируемость отключена, глобальный монитор не может выполнить резервирование — и вместо тихой потери атомарности возникает аппаратно генерируемый Data Abort.
Наблюдаемый сбой
На Raspberry Pi 5 исключение оказалось Data Abort с ECException Class — поле в регистре ESR, которое указывает, каким типом события вызвано исключение = 37 (0x25) — класс, означающий, что оно возникло в результате инструкции загрузки или сохранения. Исключение пришло с адреса 0x84474. По этому адресу находится ldaxr w0, [x1] — именно на ней и остановился CPU.
Инструкция лежит внутри конструктора SpinLockGuard, непосредственно перед ней стоит str x1, [x0], сохраняющая указатель на блокировку. В дампе регистров на момент исключения x0 содержал 0x2bcf88, x30 — 0x85554.
Замена эксклюзивных операций на обычные ldar и str устранила исключение. Это прямо указывает на то, что блокируются именно эксклюзивные операции, а не доступ к памяти как таковой.
Почему адрес блокировки оказался некэшируемым
Причина — в конфигурации MMU. Глобальный бит кэшируемости данных SCTLR.C на всех ядрах был равен своему значению по умолчанию 0, что отключает кэш данных. При выключенном кэше все загрузки и сохранения в кэшируемую память трактуются как некэшируемые и некогерентные с кэшами этого и других ядер.
Но одного глобального бита мало. Даже при SCTLR.C = 1 атрибут страницы должен быть Write-Back. Cortex-A76 считает страницу кэшируемой только если и Inner, и Outer атрибуты — Write-Back; во всех остальных случаях страница понижается до некэшируемой Normal memory.
Эксперимент с разными конфигурациями MAIRрегистр, задающий атрибуты памяти для записей таблицы трансляции для памяти, на которую ссылается ldaxr, это подтвердил: исключение возникает для Write-Through Transientатрибут памяти, при котором запись идёт сразу в память, а данные помечены как временные и могут быть вытеснены в любой момент, Non-cacheableатрибут памяти, при котором данные не кэшируются вовсе и Write-Through Non-transientатрибут памяти, при котором запись идёт сразу в память, а данные помечены как постоянные и не вытесняются самопроизвольно, но не для Write-Back Transientатрибут памяти, при котором запись сначала попадает в кэш, а данные помечены как временные и Write-Back Non-transientатрибут памяти, при котором запись сначала попадает в кэш, а данные помечены как постоянные. Write-Back варианты (0b01110111, 0b11111111) исключения не дают.
Итог: load exclusive на shareable-памяти не работает, пока кэшируемость не включена одновременно глобально (SCTLR.C = 1) и локально (атрибут Write-Back в записи таблицы трансляции).
Пограничные случаи
Что произойдёт, если два ядра одновременно выполнят LDAXR по одному адресу? Оба переведут свои мониторы в exclusive и запомнят адрес. Но при STXR проверяется, что все мониторы в состоянии exclusive и адрес совпадает. После успешного STXR мониторы перестают быть в exclusive, и второй STXR проверку не проходит. Поэтому ровно один из двух завершится успешно.
Отдельно стоит помнить про ERG: два адреса внутри одной гранулы выглядят для монитора как одна запись. На типичной системе гранула совпадает со строкой кэша, так что ложные срабатывания возможны для соседних данных.
Что касается событий, сбрасывающих монитор — прерываний, контекстных свитчей, чужих записей, вытеснения строки кэша — в разобранном материале они не перечислены. Описаны только состояния монитора и условия успеха STXR.
Компромиссы базового спинлока
Цикл ожидания в спинлоке устроен так: загрузка эксклюзивом читает слово блокировки, и если оно не равно нулю, управление возвращается к началу цикла. Ядро в цикле повторно выполняет загрузку и проверку, не переходя в низкопотребляющее состояние.
Отсюда две проблемы. Производительность невысока — ядра тратят циклы на busy-waiting. Справедливость не обеспечивается: ядро может быть заморено, если другие ядра всегда успевают захватить блокировку первыми.
В разобранном материале подход к решению проблемы производительности обозначен как будущая работа.
Что из этого следует на практике
Диагностику подобных сбоев стоит вести от архитектурных требований к атрибутам памяти. Порядок такой: вспомнить гарантированно атомарные комбинации атрибутов и случаи, когда атомарные инструкции могут не поддерживаться; проверить фактический тип памяти и её атрибуты; проверить глобальный бит SCTLR.C; затем изолировать вклад локальных атрибутов, меняя MAIR-паттерн для адреса ldaxr и наблюдая, возникает ли исключение.
Ключевой вывод для переносимости: на конкретной реализации и конфигурации Raspberry Pi 5 с Cortex-A76 операция load exclusive по shareable-памяти не работает без кэшируемости — и глобально, и локально. При этом возникает несколько слоёв, делающих картину запутанной: QEMU говорит одно, Arm ARM — другое, реализация Cortex-A76 — третье.
Поймать эту проблему в QEMU не удалось: QEMU ещё не поддерживает плату rpi5, а кэшируемость в QEMU вообще не моделируется — константа для SCTLR_C нигде не используется. Поэтому, хотя в QEMU всё работает, не стоит считать, что всё идеально, пока не протестируешь на реальном оборудовании. При написании низкоуровневых примитивов синхронизации опираться нужно на проверку на реальном железе, а не только на эмуляцию.