Це абсолютно те, що C ++ визначає як перегони даних, що спричиняє не визначене поведінку, навіть якщо один компілятор трапив код, який зробив те, на що ви сподівалися на деякій цільовій машині. Потрібно використовувати std::atomic
для надійних результатів, але ви можете використовувати їх, memory_order_relaxed
якщо ви не переймаєтесь упорядкуванням. Нижче див. Приклад коду та виводу ASM за допомогою fetch_add
.
По-перше, частина мовної частини питання:
Оскільки num ++ - це одна інструкція ( add dword [num], 1
), чи можна зробити висновок, що num ++ є атомним у цьому випадку?
Інструкції з призначення пам’яті (крім чистих магазинів) - це операції читання-зміни-запису, які відбуваються в декілька внутрішніх кроків . Жоден архітектурний реєстр не змінюється, але процесор повинен зберігати дані внутрішньо, поки він надсилає їх через ALU . Фактичний файл реєстру - це лише незначна частина зберігання даних всередині навіть найпростішого процесора, із засувками, що містять виходи однієї стадії, як входи для іншого етапу тощо, тощо.
Операції з пам'яттю з інших процесорів можуть стати глобально видимими між завантаженням і зберіганням. Тобто дві нитки, що працюють add dword [num], 1
в циклі, наступали б на магазини один одного. (Дивіться відповідь @ Маргарет для приємної схеми). Після кроку по 40 кб від кожного з двох потоків лічильник може піднятися на ~ 60 к (не 80 к) на реальному багатоядерному x86.
"Атомний", з грецького слова, що означає нероздільний, означає, що жоден спостерігач не може бачити операцію як окремий крок. Відбуття миттєво фізично / електрично для всіх бітів одночасно є лише одним із способів досягти цього для завантаження чи зберігання, але це неможливо навіть для операції ALU. Я детальніше розповідав про чисті навантаження та чисті магазини у відповіді на Atomicity на x86 , в той час як ця відповідь зосереджена на читанні-зміні-записі.
The lock
Префікс може бути застосований до багатьох читання-модифікація-запис (призначення пам'яті) інструкції , щоб вся операція атомних по відношенню до всіх можливих спостерігачам в системі (інших ядер і пристроїв DMA, а НЕ осцилограф підключений до висновків процесора). Саме тому воно існує. (Див. Також це питання ).
Так і lock add dword [num], 1
є атомний . Ядро центрального процесора, що виконує цю інструкцію, збереже кеш-рядок, закріплений у зміненому стані, у його приватному кеш-пам'яті L1 з моменту, коли завантаження зчитує дані з кеша, доки магазин не поверне результат у кеш. Це заважає будь-якому іншому кешу в системі не мати копії рядка кешу в будь-якій точці від завантаження до зберігання, відповідно до правил протоколу когерентності кешу MESI (або його версій MOESI / MESIF, використовуваних багатоядерними AMD / Процесори Intel відповідно). Таким чином, операції іншими ядрами відбуваються ні до, ні після, а не під час.
Без того lock
префікса інше ядро може взяти право власності на кеш-рядок і змінити його після нашого завантаження, але перед нашим магазином, щоб інший магазин став глобально помітним між навантаженням і сховищем. Кілька інших відповідей помилково стверджують, що без lock
вас не будуть конфліктуючі копії тієї ж лінії кешу. Це ніколи не може статися в системі з когерентними кешами.
(Якщо lock
інструкція ed працює на пам'яті, що охоплює дві лінії кешу, потрібно набагато більше роботи, щоб переконатися, що зміни обох частин об'єкта залишаються атомними, оскільки вони поширюються на всіх спостерігачів, тому жоден спостерігач не може бачити розривів. ЦП може бути доведеться заблокувати всю шину пам'яті, поки дані не потраплять у пам'ять. Не переконайте атомні змінні!)
Зауважте, що lock
префікс також перетворює інструкцію в повний бар'єр пам’яті (наприклад, MFENCE ), зупиняючи весь час переупорядкування часу і, таким чином, надаючи послідовну послідовність. (Дивіться чудову публікацію в блозі Джеффа Прешінга . Його інші публікації теж чудові і чітко пояснюють багато хороших речей про програмування без блокування , від x86 та інших деталей обладнання до правил C ++.)
На однопроцесорній машині або в однопотоковому процесі одна інструкція RMW насправді є атомною без lock
префікса. Єдиний спосіб доступу іншого коду до спільної змінної - це процесор зробити контекстний перемикач, що не може статися в середині інструкції. Таким чином, звичайна dec dword [num]
може синхронізуватися між однопотоковою програмою та її обробниками сигналів або у багатопотоковій програмі, що працює на одноядерній машині. Дивіться другу половину моєї відповіді на інше питання та коментарі під ним, де я пояснюю це більш докладно.
Назад до C ++:
Це абсолютно неправдиво використовувати, num++
не повідомляючи компілятору, що він вам потрібен для компіляції в одну реалізацію читання-зміна-запис:
;; Valid compiler output for num++
mov eax, [num]
inc eax
mov [num], eax
Це дуже ймовірно, якщо ви використовуєте значення num
пізніше: компілятор буде зберігати його в реєстрі після збільшення. Тож навіть якщо ви перевірите якnum++
компілюється самостійно, зміна навколишнього коду може вплинути на нього.
(Якщо значення пізніше inc dword [num]
не потрібне; сучасні процесори x86 запускають інструкцію RMW для призначення пам'яті як мінімум так само ефективно, як і використання трьох окремих інструкцій. Приємний факт: gcc -O3 -m32 -mtune=i586
насправді випромінюватиме це , тому що суперскалярний конвеєр (Pentium) P5 не став не декодуйте складні інструкції для кількох простих мікрооперацій, як це робить P6 та пізніші мікроархітектури таблицях інструкцій / Посібнику з мікроархітектури Agner Fog для отримання додаткової інформаціїx86 теги вікі для багатьох корисних посилань (включаючи посібники з ISA x86 по ISA, які у вільному доступі у форматі PDF).
Не плутайте цільову модель пам'яті (x86) з моделлю пам'яті C ++
Повторне упорядкування дозволено для компіляції . Інша частина того, що ви отримуєте з std :: atomic, - це контроль за переупорядкуванням за час компіляції, щоб переконатися, що вашnum++
стає глобально видимим лише після якоїсь іншої операції.
Класичний приклад: зберігання деяких даних у буфер, щоб переглянути інший потік, а потім встановити прапор. Навіть незважаючи на те, що x86 придбає магазини завантаження / випуску безкоштовно, ви все одно повинні сказати компілятору не змінювати порядок, використовуючи flag.store(1, std::memory_order_release);
.
Ви можете очікувати, що цей код синхронізується з іншими потоками:
// flag is just a plain int global, not std::atomic<int>.
flag--; // This isn't a real lock, but pretend it's somehow meaningful.
modify_a_data_structure(&foo); // doesn't look at flag, and the compilers knows this. (Assume it can see the function def). Otherwise the usual don't-break-single-threaded-code rules come into play!
flag++;
Але це не буде. Компілятор може безкоштовно переміщувати flag++
по всьому виклику функції (якщо він вбудовує функцію або знає, що вона не дивиться flag
). Тоді він може повністю оптимізувати модифікацію, тому що flag
вона не є рівною volatile
. (І ні, C ++ volatile
не є корисною заміною std :: atomic. Std :: atomic примушує компілятор припускати, що значення в пам'яті можуть бути змінені асинхронно аналогічно volatile
, але є набагато більше, ніж це. Також, volatile std::atomic<int> foo
це не те саме std::atomic<int> foo
, що було обговорено з @ Richard Hodges.)
Визначення перегонів даних на неатомних змінних як Undefined Behavior - це те, що дозволяє компілятору все-таки піднімати навантаження та занурювати сховища з циклів, і багато інших оптимізацій для пам’яті, на яку можуть посилатися кілька потоків. (Дивіться цей блог LLVM, щоб отримати докладнішу інформацію про те, як UB дозволяє оптимізувати компілятор.)
Як я вже згадував, префікс x86lock
є повним бар'єром пам’яті, тому використання num.fetch_add(1, std::memory_order_relaxed);
генерує той самий код на x86 як num++
(за замовчуванням послідовна послідовність), але він може бути набагато ефективнішим для інших архітектур (наприклад, ARM). Навіть на x86, розслаблений дозволяє більше упорядкувати час компіляції.
Це те, що GCC насправді виконує на x86, для кількох функцій, що працюють на std::atomic
глобальній змінній.
Дивіться джерело + код мови збірки, добре відформатований на провіднику компілятора Godbolt . Ви можете вибрати інші цільові архітектури, включаючи ARM, MIPS та PowerPC, щоб побачити, який код мови збірки ви отримуєте з атоміки для цих цілей.
#include <atomic>
std::atomic<int> num;
void inc_relaxed() {
num.fetch_add(1, std::memory_order_relaxed);
}
int load_num() { return num; } // Even seq_cst loads are free on x86
void store_num(int val){ num = val; }
void store_num_release(int val){
num.store(val, std::memory_order_release);
}
// Can the compiler collapse multiple atomic operations into one? No, it can't.
# g++ 6.2 -O3, targeting x86-64 System V calling convention. (First argument in edi/rdi)
inc_relaxed():
lock add DWORD PTR num[rip], 1 #### Even relaxed RMWs need a lock. There's no way to request just a single-instruction RMW with no lock, for synchronizing between a program and signal handler for example. :/ There is atomic_signal_fence for ordering, but nothing for RMW.
ret
inc_seq_cst():
lock add DWORD PTR num[rip], 1
ret
load_num():
mov eax, DWORD PTR num[rip]
ret
store_num(int):
mov DWORD PTR num[rip], edi
mfence ##### seq_cst stores need an mfence
ret
store_num_release(int):
mov DWORD PTR num[rip], edi
ret ##### Release and weaker doesn't.
store_num_relaxed(int):
mov DWORD PTR num[rip], edi
ret
Зверніть увагу на необхідність MFENCE (повного бар'єру) після зберігання послідовних послідовностей. x86 сильно впорядковано в цілому, але перенастроювання StoreLoad дозволено. Наявність буфера зберігання є важливим для хорошої роботи на конвеєрному процесорі поза замовленням. Перепорядкування пам'яті Джеффа Прешінга, що потрапив у Закон, показує наслідки не використання MFENCE, з реальним кодом, щоб показати перепорядкування, що відбувається на реальному обладнання.
Re: обговорення в коментарях до відповіді @Richard Hodges про компілятори, що об'єднують std :: атомні num++; num-=2;
операції в одну num--;
інструкцію :
Окреме запитання щодо цієї ж теми: Чому компілятори не зливають зайве std :: atomic? , де моя відповідь повторює багато того, що я написав нижче.
Поточні компілятори насправді цього не роблять (поки), але не тому, що їм це не дозволено. C ++ WG21 / P0062R1: Коли компілятори повинні оптимізувати атоми? обговорює сподівання багатьох програмістів на те, що компілятори не будуть робити "дивовижних" оптимізацій, і що стандарт може зробити, щоб дати програмістам контроль. N4455 обговорює багато прикладів речей, які можна оптимізувати, включаючи цей. Він вказує, що вбудовування та постійне розповсюдження можуть вводити такі речі, fetch_or(0)
які, можливо, можуть перетворитись на просто load()
(але все-таки придбати та випустити семантику), навіть коли в первинному джерелі не було явно надлишкових атомних операцій.
Справжні причини, з яких компілятори цього не роблять (поки), це: (1) ніхто не написав складний код, який би дозволив компілятору зробити це безпечно (ніколи не помиляючись), і (2) він потенційно порушує принцип найменшого сюрприз . Код без блокування досить важкий, щоб писати в першу чергу правильно. Тому не будьте випадкові у використанні атомної зброї: вони недешеві і не дуже оптимізують. std::shared_ptr<T>
Однак не завжди легко уникнути зайвих атомних операцій , оскільки не існує атомної його версії (хоча один із відповідей тут дає простий спосіб визначити shared_ptr_unsynchronized<T>
для gcc).
Повернення до num++; num-=2;
компіляції, як ніби num--
: Укладачі дозволяють це робити, якщо num
це не так volatile std::atomic<int>
. Якщо можливе переупорядкування, правило-asf дозволяє компілятору вирішувати під час компіляції, що це завжди відбувається таким чином. Ніщо не гарантує, що спостерігач міг побачити проміжні значення ( num++
результат).
Тобто, якщо впорядкування, коли між цими операціями нічого не стає видимим, сумісне з вимогами впорядкування джерела (згідно з правилами C ++ для абстрактної машини, а не цільової архітектури), компілятор може видавати одиницю lock dec dword [num]
замість lock inc dword [num]
/ lock sub dword [num], 2
.
num++; num--
не може зникнути, тому що вона все ще має зв'язок із синхронізацією з іншими потоками, на які переглядає num
, і це є набуття завантаження, і випуск-сховище, що забороняє впорядковувати інші операції в цьому потоці. Для x86 це може бути здатне компілювати до MFENCE, а не lock add dword [num], 0
(тобто num += 0
).
Як обговорювалося в PR0062 , більш агресивне злиття не сусідніх атомних ОПС під час компіляції може бути поганим (наприклад, лічильник прогресу оновлюється лише один раз в кінці замість кожної ітерації), але він також може сприяти продуктивності без зниження (наприклад, пропуск атомний inc / dec посилань підраховується, коли копія а shared_ptr
створюється та знищується, якщо компілятор може довести, що інший shared_ptr
об’єкт існує протягом усієї тривалості життя тимчасового.)
Навіть num++; num--
злиття може зашкодити справедливості реалізації блокування, коли одна нитка розблокується та повторно заблокується. Якщо він ніколи фактично не випускається в ASM, навіть апаратні арбітражні механізми не дадуть іншій нитці шансу схопити замок у цій точці.
З поточними gcc6.2 та clang3.9 ви все одно отримуєте окремі lock
операції редагування навіть memory_order_relaxed
у найбільш очевидно оптимізованому випадку. ( Провідник компілятора Godbolt, щоб ви могли бачити, чи відрізняються останні версії.)
void multiple_ops_relaxed(std::atomic<unsigned int>& num) {
num.fetch_add( 1, std::memory_order_relaxed);
num.fetch_add(-1, std::memory_order_relaxed);
num.fetch_add( 6, std::memory_order_relaxed);
num.fetch_add(-5, std::memory_order_relaxed);
//num.fetch_add(-1, std::memory_order_relaxed);
}
multiple_ops_relaxed(std::atomic<unsigned int>&):
lock add DWORD PTR [rdi], 1
lock sub DWORD PTR [rdi], 1
lock add DWORD PTR [rdi], 6
lock sub DWORD PTR [rdi], 5
ret
add
це атомне?