Почему целочисленное присваивание для естественно выровненной переменной атомарно в x86?
Я читал эту статью об атомарных операциях, и она упоминает, что 32-разрядное целочисленное присваивание является атомарным на x86, если переменная естественно выровнена.
Почему естественное выравнивание обеспечивает атомарность?
5 ответов
"Естественное" выравнивание означает выравнивание по ширине собственного типа. Таким образом, загрузка / хранение никогда не будет разделена на какие-либо границы, более широкие, чем она сама (например, страница, строка кэша или даже более узкий размер чанка, используемый для передачи данных между разными кешами).
ЦП часто выполняют такие вещи, как доступ к кешу или передача строк кеша между ядрами в виде блоков размером 2, поэтому границы выравнивания, меньшие, чем строка кеша, имеют значение. (См. Комментарии @BeeOnRope ниже). См. Также Atomicity на x86 для получения дополнительной информации о том, как процессоры реализуют атомарные нагрузки или хранят внутри, и может ли num++ быть атомарным для 'int num'? для получения дополнительной информации о том, как атомные операции RMW, как atomic<int>::fetch_add()
/ lock xadd
реализуются внутри страны.
Во-первых, это предполагает, что int
обновляется одной инструкцией хранения, а не записывает разные байты отдельно. Это часть того, что std::atomic
гарантирует, но это не C или C++. Впрочем, обычно так и будет. X86-64 System V ABI не запрещает компиляторам осуществлять доступ к int
переменные не атомарные, даже если это требует int
быть 4B с выравниванием по умолчанию 4B. Например, x = a<<16 | b
мог компилироваться в два отдельных 16-битных хранилища, если компилятор хотел.
Гонки данных являются неопределенным поведением как в C, так и в C++, поэтому компиляторы могут и предполагают, что память не изменяется асинхронно. Для кода, который гарантированно не сломается, используйте C11 stdatomic или C++11 std:: atomic. В противном случае компилятор будет просто сохранять значение в регистре, а не перезагружать каждый раз, когда вы его читаете, например volatile
но с реальными гарантиями и официальной поддержкой от языкового стандарта.
До C++11 атомарные операции обычно выполнялись с volatile
или другие вещи, и здоровая доза "работает на компиляторах, которые нас интересуют", поэтому C++11 стал огромным шагом вперед. Теперь вам больше не нужно заботиться о том, что делает компилятор для простого int
; просто используйте atomic<int>
, Если вы найдете старых гидов, говорящих об атомности int
они, вероятно, предшествуют C++11.
std::atomic<int> shared; // shared variable (compiler ensures alignment)
int x; // local variable (compiler can keep it in a register)
x = shared.load(std::memory_order_relaxed);
shared.store(x, std::memory_order_relaxed);
// shared = x; // don't do that unless you actually need seq_cst, because MFENCE or XCHG is much slower than a simple store
Примечание: для atomic<T>
больше, чем процессор может сделать атомно (так .is_lock_free()
является ложным), см. Где находится блокировка для std::atomic?, int
а также int64_t
/ uint64_t
все же без блокировок на всех основных компиляторах x86.
Таким образом, нам просто нужно поговорить о поведении insn, как mov [shared], eax
,
TL;DR: ISA x86 гарантирует, что естественно-выровненные хранилища и нагрузки являются атомарными, до 64 бит в ширину. Таким образом, компиляторы могут использовать обычные хранилища / нагрузки, если они гарантируют, что std::atomic<T>
имеет естественное выравнивание.
(Но учтите что i386 gcc -m32
не в состоянии сделать это для C11 _Atomic
64-битные типы, выравнивая их только до 4B, поэтому atomic_llong
на самом деле не атомный. https://gcc.gnu.org/bugzilla/show_bug.cgi?id=65146). g++ -m32
с std::atomic
хорошо, по крайней мере, в g++5, потому что https://gcc.gnu.org/bugzilla/show_bug.cgi?id=65147 был исправлен в 2015 году путем изменения <atomic>
заголовок. Это не изменило поведение C11, хотя.)
IIRC, были системы SMP 386, но текущая семантика памяти не была установлена до 486. Вот почему в руководстве написано "486 и новее".
Из "Руководства разработчика программного обеспечения Intel® 64 и IA-32, том 3", с пометками, выделенными курсивом. (см. также вики-тег x86 для ссылок: текущие версии всех томов или прямая ссылка на страницу 256 документа vol3 от декабря 2015 г.)
В терминологии x86 "слово" - это два 8-битных байта. 32 бита являются двойным словом или DWORD.
Раздел 8.1.1 Гарантированные атомные операции
Процессор Intel486 (и более новые процессоры с тех пор) гарантирует, что следующие основные операции с памятью всегда будут выполняться атомарно:
- Чтение или запись байта
- Чтение или запись слова, выровненного по 16-битной границе
- Чтение или запись двойного слова, выровненного по 32-битной границе (это еще один способ сказать "естественное выравнивание")
Последний пункт, который я выделил, является ответом на ваш вопрос: это поведение является частью того, что требуется процессору, чтобы быть процессором x86 (то есть реализацией ISA).
В оставшейся части этого раздела приводятся дополнительные гарантии для новых процессоров Intel: Pentium расширяет эту гарантию до 64 бит.
Процессор Pentium (и более новые процессоры с тех пор) гарантирует, что следующие дополнительные операции с памятью всегда будут выполняться атомарно:
- Чтение или запись четырех слов, выровненных по 64-битной границе (например, загрузка / сохранение x87
double
, или жеcmpxchg8b
(который был новым в Pentium P5))- 16-битный доступ к некэшированным областям памяти, которые вписываются в 32-битную шину данных.
Далее в этом разделе указывается, что доступы, разделенные по строкам кэша (и границам страниц), не обязательно являются атомарными, и:
"Инструкция x87 или инструкция SSE, которая осуществляет доступ к данным, большим, чем четырехзначное слово, может быть реализована с использованием множественного доступа к памяти".
Руководство AMD согласуется с рекомендациями Intel о том, что согласованные 64-битные и более узкие загрузки / хранилища являются атомарными
Таким образом, целые числа, x87 и MMX/SSE загружают / сохраняют до 64 байт даже в 32-битном или 16-битном режиме (например, movq
, movsd
, movhps
, pinsrq
, extractps
и т. д.) являются атомарными, если данные выровнены. gcc -m32
использования movq xmm, [mem]
реализовать атомарные 64-битные нагрузки для таких вещей, как std::atomic<int64_t>
, Clang4.0 -m32
к сожалению использует lock cmpxchg8b
ошибка 33109.
На некоторых процессорах с внутренними путями данных 128b или 256b (между исполнительными блоками и L1 и между различными кэшами) векторные загрузки / хранилища 128b и даже 256b являются атомарными, но это не гарантируется никаким стандартом или легко запрашивается во время выполнения, к сожалению, для реализации компиляторов std::atomic<__int128>
или 16B структурирует.
Если вы хотите атомарный 128b во всех системах x86, вы должны использовать lock cmpxchg16b
(доступно только в 64-битном режиме). (И он не был доступен в процессорах первого поколения x86-64. Вам нужно использовать -mcx16
с gcc/clang, чтобы они его испустили.)
Даже процессоры, которые внутренне выполняют атомарные 128b загрузки / хранения, могут демонстрировать неатомарное поведение в системах с несколькими сокетами с протоколом когерентности, который работает в меньших порциях: например, AMD Opteron 2435 (K10) с потоками, работающими на отдельных сокетах, связанных с HyperTransport.
Руководства Intel и AMD расходятся для выравнивания доступа к кешируемой памяти. Общим подмножеством для всех процессоров x86 является правило AMD. Кэшируемый означает области памяти с обратной записью или записью, а не без кэширования или объединения с записью, как установлено в областях PAT или MTRR. Они не означают, что строка кэша уже должна быть горячей в кэше L1.
- Intel P6 и более поздние версии гарантируют атомарность для кэшируемых загрузок / хранилищ до 64 бит, если они находятся в пределах одной строки кэша (64B или 32B на очень старых процессорах, таких как PentiumIII).
AMD гарантирует атомарность для кэшируемых загрузок / хранилищ, которые помещаются в один блок с выравниванием 8B. Это имеет смысл, потому что мы знаем из теста 16B-магазина на Multi-Socket Opteron, что HyperTransport передает только в 8B-блоках и не блокируется во время передачи, чтобы предотвратить разрыв. (См. Выше). Похоже
lock cmpxchg16b
должны быть обработаны специально.Возможно, связано: AMD использует MOESI для совместного использования грязных строк кэша непосредственно между кэшами в разных ядрах, поэтому одно ядро может считывать данные из своей действительной копии строки кэша, пока обновления поступают из другого кэша.
Intel использует MESIF, который требует грязных данных для распространения в большой совместно используемый инклюзивный кэш L3, который выступает в качестве резервной копии для трафика когерентности. L3 включает теги для кэш-памяти L2/L1 на ядро, даже для линий, которые должны быть в недопустимом состоянии в L3 из-за того, что в кэш-памяти L1 на ядро присутствует M или E. Путь данных между L3 и кэш-памятью на ядро в Haswell/Skylake составляет всего 32B, поэтому он должен буферизовать или что-то еще, чтобы избежать записи в L3 из одного ядра между чтениями двух половин строки кэша, что может вызвать разрыв в граница 32B.
Соответствующие разделы руководств:
Процессоры семейства P6 (и более новые процессоры Intel с тех пор) гарантируют, что следующая дополнительная операция с памятью всегда будет выполняться атомарно:
- Нераспределенные 16-, 32- и 64-битные обращения к кешируемой памяти, которые помещаются в строку кеша.
AMD64 Manual 7.3.2 атомарность доступа
Кэшируемые, естественно выровненные одиночные загрузки или хранилища до четырех слов являются атомарными в любой модели процессора, так же как и выровненные нагрузки или хранилища меньше, чем четыре слова, которые полностью содержатся в естественно выровненном четырех словах.
Обратите внимание, что AMD гарантирует атомарность для любой нагрузки, меньшей qword, но Intel только для размеров степени 2. 32-битный защищенный режим и 64-битный длинный режим могут загружать 48 бит m16:32
как операнд памяти в cs:eip
с дальней call
или далеко jmp
, (И дальний вызов помещает вещи в стек.) IDK, если это считается как один 48-битный доступ или отдельный 16- и 32-битный.
Были попытки формализовать модель памяти x86, последняя из которых - документ x86-TSO (расширенная версия) от 2009 года (ссылка из раздела упорядочения памяти вики-тега x86). Это бесполезно, потому что они определяют некоторые символы, чтобы выразить вещи в своих собственных обозначениях, и я не пытался действительно читать это. IDK, если он описывает правила атомарности или касается только упорядочения памяти.
Атомное чтение-изменение-запись
я упомянул cmpxchg8b
, но я говорил только о том, что нагрузка и хранилище по отдельности являются атомарными (т. е. нет "разрыва", когда одна половина нагрузки поступает из одного хранилища, а другая половина - из другого хранилища).
Чтобы предотвратить изменение содержимого этой области памяти между загрузкой и хранилищем, необходимо lock
cmpxchg8b
так же, как вам нужно lock inc [mem]
чтобы все чтение-изменение-запись было атомарным. Также обратите внимание, что даже если cmpxchg8b
без lock
выполняет одну атомную загрузку (и, возможно, хранилище), в общем случае небезопасно использовать его как загрузку 64 байт с ожидаемым = желаемым. Если значение в памяти совпадает с ожидаемым, вы получите неатомарное чтение-изменение-запись этого местоположения.
lock
Префикс делает даже не выровненный доступ, который пересекает границы строки кэша или страницы, атомарным, но вы не можете использовать его с mov
сделать магазин без выравнивания или загрузить атомарный. Он может использоваться только с инструкциями чтения-изменения-записи назначения памяти, такими как add [mem], eax
,
(lock
подразумевается в xchg reg, [mem]
так что не используйте xchg
с mem для сохранения размера кода или количества команд, если производительность не имеет значения. Используйте его только тогда, когда вам нужен барьер памяти и / или атомарный обмен, или когда размер кода является единственным, что имеет значение, например, в загрузочном секторе.)
Смотрите также: Может ли num++ быть атомарным для 'int num'?
Зачем lock mov [mem], reg
не существует для атомарных не выровненных магазинов
Из руководства по insn (руководство Intel x86 vol2), cmpxchg
:
Эта инструкция может быть использована с
LOCK
префикс, позволяющий выполнить инструкцию атомарно. Чтобы упростить интерфейс с шиной процессора, операнд-адресат получает цикл записи без учета результата сравнения. Операнд-адресат записывается обратно, если сравнение не удается; в противном случае исходный операнд записывается в место назначения. (Процессор никогда не производит заблокированное чтение, не производя также заблокированную запись.)
Это конструктивное решение уменьшило сложность чипсета до того, как контроллер памяти был встроен в процессор. Это все еще может сделать это для lock
Инструкции ed для регионов MMIO, которые обращаются к шине PCI-express, а не к DRAM. Это было бы просто сбивающим с толку lock mov reg, [MMIO_PORT]
производить запись, а также чтение в отображенный в память регистр ввода / вывода.
Другое объяснение состоит в том, что не очень сложно убедиться, что ваши данные имеют естественное выравнивание, и lock store
будет работать ужасно по сравнению с просто убедиться, что ваши данные выровнены. Было бы глупо тратить транзисторы на что-то такое медленное, что не стоило бы использовать. Если вам это действительно нужно (и вы не против читать память), вы можете использовать xchg [mem], reg
(XCHG имеет неявный префикс LOCK), который даже медленнее, чем гипотетический lock mov
,
Используя lock
Префикс также является полным барьером памяти, поэтому накладывает на производительность не только атомарный RMW. то есть x86 не может делать расслабленный атомарный RMW (без очистки буфера хранилища). Другие МСА могут, поэтому используя .fetch_add(1, memory_order_relaxed)
может быть быстрее на не x86.
Интересный факт: раньше mfence
существовал, общая идиома была lock add dword [esp], 0
, который является неактивным, кроме помехи флагов и выполнения заблокированной операции. [esp]
почти всегда горячий в кеше L1 и не вызовет конфликта с любым другим ядром. Эта идиома может быть еще более эффективной, чем MFENCE, как отдельный барьер памяти, особенно на процессорах AMD.
xchg [mem], reg
это, вероятно, самый эффективный способ реализации хранилища последовательной согласованности, по сравнению с mov
+ mfence
на обоих Intel и AMD. mfence
на Skylake, по крайней мере, блокирует неправильное выполнение инструкций, не связанных с памятью, но xchg
и другие lock
Ed Ops нет. Компиляторы, кроме gcc, используют xchg
для магазинов, даже когда они не заботятся о чтении старого значения.
Мотивация для этого дизайнерского решения:
Без этого программное обеспечение должно было бы использовать 1-байтовые блокировки (или некоторый доступный атомарный тип) для защиты доступа к 32-битным целым числам, что крайне неэффективно по сравнению с общим атомарным доступом на чтение для чего-то вроде глобальной переменной временной метки, обновляемой прерыванием таймера., Это, вероятно, в основном бесплатно в кремнии, чтобы гарантировать выровненный доступ по ширине шины или меньше.
Чтобы блокировка вообще была возможной, требуется какой-то атомарный доступ. (На самом деле, я предполагаю, что аппаратное обеспечение могло бы обеспечить какой-то совершенно иной аппаратный механизм блокировки.) Для ЦП, который выполняет 32-битные передачи по своей внешней шине данных, просто имеет смысл иметь эту единицу атомарности.
Поскольку вы предложили вознаграждение, я предполагаю, что вы искали длинный ответ, который разбирался во всех интересных побочных темах. Дайте мне знать, если есть вещи, которые я не освещал, которые, по вашему мнению, сделали бы этот вопрос более ценным для будущих читателей.
Поскольку вы связали один вопрос, я настоятельно рекомендую прочитать больше постов Джеффа Прешинга в блоге. Они превосходны и помогли мне собрать воедино части из того, что я знал, в понимании упорядочения памяти в исходных текстах C/C++ и asm для различных аппаратных архитектур и как / когда сообщить компилятору, что вы хотите, если вы не ' пишу асм напрямую.
Если 32-разрядный или меньший объект естественным образом выровнен в "нормальной" части памяти, любой 80386 или совместимый процессор, кроме 80386sx, сможет прочитать или записать все 32 бита объекта за одну операцию. Хотя способность платформы делать что-либо быстрым и полезным способом не обязательно означает, что платформа иногда не будет делать это каким-то другим способом по какой-то причине, и хотя я считаю, что это возможно на многих, если не на всех процессорах x86, есть области памяти, к которым можно получить доступ только 8 или 16 бит за раз, я не думаю, что Intel когда-либо определяла какие-либо условия, когда запрос согласованного 32-битного доступа к "нормальной" области памяти заставил бы систему читать или записать часть значения без прочтения или записи всего, и я не думаю, что Intel намерена когда-либо определять такие вещи для "обычных" областей памяти.
Естественно выровненный означает, что адрес типа кратен размеру типа.
Например, байт может быть по любому адресу, короткий (при условии, что 16 бит) должен быть кратным 2, int (при условии, что 32 бита) должен быть кратным 4, а длинный (при условии, что 64 бита) должен быть кратным 8.
В случае, если вы обращаетесь к части данных, которые не выровнены естественным образом, ЦП либо вызовет ошибку, либо выполнит чтение / запись в память, но не как элементарная операция. Действие, которое выполняет процессор, будет зависеть от архитектуры.
Например, изображение у нас есть макет памяти ниже:
01234567
...XXXX.
а также
int *data = (int*)3;
Когда мы пытаемся читать *data
байты, составляющие значение, распределены по 2 блокам размера int, 1 байт находится в блоке 0-3, а 3 байта - в блоке 4-7. Теперь, просто потому что блоки логически находятся рядом друг с другом, это не значит, что они физически. Например, блок 0-3 может находиться в конце строки кэша процессора, в то время как блок 3-7 находится в файле страницы. Когда процессор переходит к блоку доступа 3-7, чтобы получить 3 байта, в которых он нуждается, он может видеть, что блок не находится в памяти, и сигнализирует о том, что ему требуется память, выгруженная в память. Это, вероятно, заблокирует вызывающий процесс, пока ОС страницы памяти обратно.
После того, как память была выгружена, но до того, как ваш процесс восстановлен, может прийти другой и написать Y
по адресу 4. Затем ваш процесс перенесен и процессор завершает чтение, но теперь он прочитал XYXX, а не ожидаемый вами XXXX.
Если бы вы спросили, почему это так, я бы сказал, что это хороший побочный продукт от проектирования архитектуры процессора.
В 486 году не было многоядерных процессоров или каналов QPI, поэтому атомарность на самом деле не является строгим требованием в то время (DMA может потребовать это?).
На x86 ширина данных составляет 32 бита (или 64 бита для x86_64), что означает, что процессор может считывать и записывать до ширины данных за один снимок. И шина памяти данных обычно равна или шире, чем это число. В сочетании с тем, что чтение / запись по выровненному адресу выполняется за один раз, естественно, ничто не мешает чтению / записи неатомарным. Вы получаете скорость / атомную одновременно.
Чтобы ответить на ваш первый вопрос, переменная выравнивается естественным образом, если она существует по адресу памяти, кратному ее размеру.
Если мы рассмотрим только - как в статье, которую вы связали - инструкции по присваиванию, то выравнивание гарантирует атомарность, потому что MOV (инструкция по присваиванию) является атомарной по конструкции для выровненных данных.
Другие виды инструкций, например, INC, должны быть LOCK ed (префикс x86, который предоставляет монопольный доступ к общей памяти для текущего процессора на время операции с префиксом), даже если данные выровнены, потому что они фактически выполняются через несколько шаги (= инструкции, а именно загрузить, вкл, хранить).