Чи може num ++ бути атомним для 'int num'?


153

Загалом, для int num, num++(або ++num), в якості операції читання-модифікація-запис, це НЕ атомна . Але я часто бачу компілятори, наприклад GCC , генерують такий код для нього ( спробуйте тут ):

Введіть тут опис зображення

Оскільки рядок 5, якому відповідає num++одна інструкція, чи можна зробити висновок про те, що в даному випадку num++ є атомним ?

І якщо це так, чи означає це, що так згенерований num++може використовуватися в одночасних (багатопотокових) сценаріях без будь-якої небезпеки перебігу даних (тобто нам не потрібно робити це, наприклад, std::atomic<int>і накладати пов'язані з цим витрати, оскільки це атомний все одно)?

ОНОВЛЕННЯ

Зауважте, що це питання не є тим, що приріст є атомним (це не так, а це було і є початковою лінією питання). Це може бути в конкретних сценаріях, тобто, чи можна використовувати в одних випадках характер однієї інструкції, щоб уникнути накладних витрат lockпрефікса. І як згадується прийнята відповідь у розділі про однопроцесорні машини, а також ця відповідь , розмова в коментарях та інші пояснюють, вона може (хоча не з C або C ++).


65
Хто вам сказав, що addце атомне?
Слава

6
зважаючи на те, що однією з особливостей атоми є запобігання конкретних видів переупорядкування під час оптимізації, ні, незалежно від
атомності

19
Я також хотів би зазначити, що якщо це атома на вашій платформі, немає гарантії, що вона буде на іншій плтаформі. Будьте платформою незалежною та висловіть свій намір, використовуючи std::atomic<int>.
NathanOliver

8
Під час виконання цієї addінструкції інше ядро ​​могло вкрасти адресу пам'яті з кешу цього ядра та змінити її. На процесорі x86, addінструкції потрібен lockпрефікс, якщо адреса повинна бути заблокована в кеші протягом тривалості операції.
Девід Шварц

21
Можливо, що будь-яка операція буде "атомною". Все, що вам потрібно зробити, - це пощастить і ніколи не трапиться виконати щось, що виявить, що це не атомне. Атомний є цінним лише як гарантія . Зважаючи на те, що ви дивитесь на код складання, питання полягає в тому, чи саме ця архітектура надає вам гарантію і чи компілятор надає гарантію, що саме така реалізація на рівні складання вибирається.
Корт Аммон

Відповіді:


197

Це абсолютно те, що 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 для отримання додаткової інформації теги вікі для багатьох корисних посилань (включаючи посібники з 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

1
"[використовуючи окремі інструкції], які раніше були ефективнішими ... але сучасні процесори x86 знову обробляють операції RMW принаймні так само ефективно" - це все-таки більш ефективно в тому випадку, коли оновлене значення буде використано пізніше в тій же функції і є доступний безкоштовний реєстр для компілятора для його зберігання (і змінна, звичайно, не позначена мінливою). Це означає, що велика ймовірність того, що компілятор генерує одну операцію чи множину для операції, залежить від решти коду у функції, а не лише від одного питання, про який йде мова.
Periata Breatta

@PeriataBreatta: так, хороший момент. У ASM ви можете використовувати mov eax, 1 xadd [num], eax(без префікса блокування) для реалізації пост-інкременту num++, але це не те, що роблять компілятори.
Пітер Кордес

3
@ DavidC.Rankin: Якщо у вас є зміни, які ви хочете внести, не соромтеся. Я не хочу робити це CW, хоча. Це все ще моя робота (і мій безлад: P). Я приберу кілька після моєї гри Ultimate [фрісбі] :)
Пітер Кордес

1
Якщо це не вікі спільноти, то, можливо, посилання на відповідний вікі тегів. (і x86, і атомні теги?). Варто додаткового зв’язку, а не сподіваного повернення загальним пошуком SO (якби я краще знав, де він повинен вміститися в цьому відношенні, я б це зробив. Мені доведеться заглиблюватися далі в теги "Що не робити" wiki link)
David C. Rankin

1
Як завжди - чудова відповідь! Хороша відмінність між когерентністю та атомністю (де деякі інші помилялися)
Ліор

39

... а тепер давайте ввімкніть оптимізацію:

f():
        rep ret

Гаразд, давайте шанс:

void f(int& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

результат:

f(int&):
        mov     DWORD PTR [rdi], 0
        ret

інший потік спостереження (навіть ігнорування затримок синхронізації кешу) не має можливості спостерігати за окремими змінами.

порівняти з:

#include <atomic>

void f(std::atomic<int>& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

де результат:

f(std::atomic<int>&):
        mov     DWORD PTR [rdi], 0
        mfence
        lock add        DWORD PTR [rdi], 1
        lock sub        DWORD PTR [rdi], 1
        lock add        DWORD PTR [rdi], 6
        lock sub        DWORD PTR [rdi], 5
        lock sub        DWORD PTR [rdi], 1
        ret

Тепер кожна модифікація:

  1. спостерігається в іншій нитці та
  2. поважає подібні модифікації, що відбуваються в інших потоках.

атомність не просто на рівні інструкцій, вона включає весь конвеєр від процесора, через кеші, до пам'яті та назад.

Додаткова інформація

Щодо ефекту оптимізації оновлень std::atomics.

Стандарт c ++ має правило "як ніби", за допомогою якого компілятор може перевпорядкувати код і навіть переписати код за умови, що результат має такі самі спостережувані ефекти (включаючи побічні ефекти), як якщо б він просто виконав ваш код.

Правило як би консервативне, зокрема, що стосується атомів.

врахуйте:

void incdec(int& num) {
    ++num;
    --num;
}

Оскільки немає замків mutex, атома чи будь-яких інших конструкцій, які впливають на міжпотокові послідовності, я можу стверджувати, що компілятор може переписати цю функцію як NOP, наприклад:

void incdec(int&) {
    // nada
}

Це відбувається тому, що в моделі пам'яті c ++ немає можливості іншого потоку спостерігати за результатом приросту. Було б, звичайно , інакше , якби numбуло volatile(може вплинути на апаратне поведінку). Але в цьому випадку ця функція буде єдиною функцією, що модифікує цю пам'ять (інакше програма неправильно сформована).

Однак це вже інша гра з м'ячем:

void incdec(std::atomic<int>& num) {
    ++num;
    --num;
}

numє атомним. Зміни в ньому повинні спостерігатись за іншими потоками, які переглядають. Зміни самих цих потоків (наприклад, встановлення значення 100 між прирістком і зменшенням) матимуть дуже далекосяжний вплив на можливе значення числа.

Ось демонстрація:

#include <thread>
#include <atomic>

int main()
{
    for (int iter = 0 ; iter < 20 ; ++iter)
    {
        std::atomic<int> num = { 0 };
        std::thread t1([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                ++num;
                --num;
            }
        });
        std::thread t2([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                num = 100;
            }
        });
        
        t2.join();
        t1.join();
        std::cout << num << std::endl;
    }
}

вибірка вибірки:

99
99
99
99
99
100
99
99
100
100
100
100
99
99
100
99
99
100
100
99

5
Це не дозволяє пояснити, що неadd dword [rdi], 1 є атомним (без префікса). Навантаження є атомним, а магазин - атомним, але ніщо не зупиняє чергову нитку від зміни даних між вантажем і сховищем. Тож магазин може наступити на модифікацію, зроблену іншою ниткою. Див. Jfdube.wordpress.com/2011/11/30/understanding-atomic-operations . Крім того, безкоштовні статті Джеффа Прешінга є надзвичайно хорошими , і він згадує основну проблему RMW у цій статті вступу. lock
Пітер Кордес

3
Тут справді відбувається те, що ніхто не здійснив цю оптимізацію в gcc, оскільки це було б майже марно і, ймовірно, більш небезпечно, ніж корисно. (Принцип найменшого подиву. Може бути , хто - то це очікує тимчасовий стан , щоб бути видимими іноді і в порядку зі статистичної імовірнісна. Або вони будуть використовувати апаратні сторожові точки переривання від модифікації.) Потреби код безблокіровочного бути ретельно, тому оптимізувати не буде чого. Це може бути корисно шукати його і надрукувати попередження, щоб попередити кодер, що їх код може не означати те, що вони думають!
Пітер Кордес

2
Це, мабуть, причина для компіляторів цього не застосовувати (принцип найменшого здивування тощо). Зауважуючи, що було б можливо на практиці на реальному обладнання. Однак правила впорядкування пам’яті C ++ нічого не говорять про гарантію того, що навантаження однієї нитки «рівномірно» змішується з умовами інших потоків у абстрактній машині C ++. Я все ще думаю, що це було б законно, але вороже програміст.
Пітер Кордес

2
Продуманий експеримент: Розгляньте реалізацію C ++ на спільній багатозадачній системі. Він реалізує std :: thread, вставляючи точки доходу там, де це потрібно, щоб уникнути тупикових ситуацій, але не між кожною інструкцією. Я думаю, ви б заперечили, що щось у стандарті C ++ вимагає точки виходу між num++і num--. Якщо ви можете знайти розділ у стандарті, який цього вимагає, це вирішиться. Я впевнений, що це вимагає лише того, щоб жодні спостерігачі не могли побачити неправильне переупорядкування, що не потребує результату. Тож я думаю, що це лише питання якості реалізації.
Пітер Кордес

5
Заради остаточності я запитав у списку розсилки std обговорення. Це запитання виявило два документи, які, здається, одночасно співпадають з Пітером, і стосуються занепокоєнь, які я маю щодо таких оптимізацій: wg21.link/p0062 та wg21.link/n4455 Моя подяка Енді, який звернув це до мене.
Річард Ходжес

38

Без багатьох ускладнень така інструкція, як add DWORD PTR [rbp-4], 1дуже CISC-стиль.

Він виконує три операції: завантажує операнда з пам'яті, збільшує його, зберігає операнда назад у пам'ять.
Під час цих операцій процесор набуває та випускає шину двічі, між ними може бути придбаний будь-який інший агент, що порушує атомність.

AGENT 1          AGENT 2

load X              
inc C
                 load X
                 inc C
                 store X
store X

X збільшується лише один раз.


7
@LeoHeinsaar Для того, щоб це було так, кожному мікросхему пам'яті знадобиться власний арифметичний логічний блок (ALU). Фактично, потрібно, щоб кожен чіп пам'яті був процесором.
Річард Ходжес

6
@LeoHeinsaar: інструкції з призначення пам'яті - це операції зчитування-зміни-запису. Жоден архітектурний реєстр не змінюється, але процесор повинен зберігати дані внутрішньо, поки він надсилає їх через ALU. Фактичний файл реєстру - це лише невелика частина зберігання даних всередині навіть найпростішого процесора, із засувками, що містять виходи одного етапу, як входи для іншого етапу тощо.
Пітер Кордес,

@PeterCordes Ваш коментар - це саме відповідь, яку я шукав. Відповідь Маргарет змусила мене підозрювати, що щось подібне повинно тривати всередині.
Лев Хайнсаар

Цей коментар перетворився на повну відповідь, включаючи адресу C ++ частини питання.
Пітер Кордес

1
@PeterCordes Спасибі, дуже докладно і по всіх пунктах. Це, очевидно, перегони даних і, отже, невизначена поведінка за стандартом C ++, мені було просто цікаво, чи у випадках, коли згенерований код був таким, який я розміщував, можна припустити, що це може бути атомний і т.д. посібники дуже чітко визначають атомність щодо операцій з пам'яттю, а не нероздільність інструкцій, як я припускав: "Заблоковані операції є атомними щодо всіх інших операцій з пам'яттю та всіх зовнішньо видимих ​​подій".
Лев Хайнсаар

11

Інструкція додавання не є атомною. Він посилається на пам'ять, і два процесорних ядра можуть мати різний локальний кеш цієї пам'яті.

IIRC атомний варіант інструкції додати називається lock xadd


3
lock xaddреалізує C ++ std :: atomic fetch_add, повертаючи старе значення. Якщо цього вам не потрібно, компілятор буде використовувати звичайні інструкції призначення пам'яті з lockпрефіксом. lock addабо lock inc.
Пітер Кордес

1
add [mem], 1все ще не буде атомним на машині SMP без кешу, дивіться мої коментарі до інших відповідей.
Пітер Кордес

Дивіться мою відповідь для більш детальної інформації про те, як це не атомно. Також кінець моєї відповіді на це пов'язане питання .
Пітер Кордес

10

Оскільки рядок 5, який відповідає num ++, є однією інструкцією, чи можна зробити висновок, що num ++ в даному випадку є атомним?

Небезпечно робити висновки на основі породженої збірки "зворотної інженерії". Наприклад, ви, схоже, склали свій код із відключеною оптимізацією, інакше компілятор викинув би цю змінну або завантажив 1 безпосередньо на неї без виклику operator++. Оскільки згенерована збірка може істотно змінитися на основі прапорців оптимізації, цільового процесора тощо, ваш висновок базується на піску.

Крім того, ваше уявлення про те, що одна інструкція по збірці означає, що операція є атомною, також неправильна. Це addне буде атомним для багатопроцесорних систем, навіть в архітектурі x86.


9

Навіть якщо ваш компілятор завжди випускав це як атомну операцію, доступ numз будь-якого іншого потоку одночасно представляв би перегони даних відповідно до стандартів C ++ 11 і C ++ 14, і програма мала б не визначене поведінку.

Але це гірше. По-перше, як було зазначено, інструкція, що створюється компілятором при збільшенні змінної, може залежати від рівня оптимізації. По-друге, компілятор може переупорядкувати інші доступи до пам'яті, ++numякщо numвони не є атомними, наприклад

int main()
{
  std::unique_ptr<std::vector<int>> vec;
  int ready = 0;
  std::thread t{[&]
    {
       while (!ready);
       // use "vec" here
    });
  vec.reset(new std::vector<int>());
  ++ready;
  t.join();
}

Навіть якщо ми припускаємо оптимістично, що ++readyце "атомний", і що компілятор генерує цикл перевірки за необхідності (як я вже сказав, це UB, і тому компілятор вільний його видалити, замінити на нескінченний цикл тощо), компілятор може все-таки перемістити призначення вказівника або ще гірше ініціалізувати vectorточку до точки після операції збільшення, викликаючи хаос у новому потоці. На практиці я не здивувався б, якби оптимізуючий компілятор повністю видалив readyзмінну та цикл перевірки, оскільки це не впливає на поведінку, що спостерігається за мовними правилами (на відміну від ваших приватних надій).

Насправді, на минулорічній конференції Meeting C ++, я чув від двох розробників-компіляторів, що вони дуже радо впроваджують оптимізацію, яка робить наївно написані багатопотокові програми недоброзичливими, доки мовні правила дозволяють це, навіть якщо спостерігається незначне підвищення продуктивності. у правильно написаних програмах.

Нарешті, навіть якщо ви не піклувались про переносимість, і ваш компілятор був магічно приємним, процесор, який ви використовуєте, дуже ймовірно, що має суперскалічний тип CISC і порушить інструкції щодо мікрооперацій, переупорядкування та / або спекулятивно виконати їх, в межах, обмежених лише синхронізацією примітивів, таких як (в Intel) LOCKпрефікс або паркани пам'яті, щоб максимально збільшити кількість операцій в секунду.

Щоб коротко розповісти, природними обов'язками безпечного потокового програмування є:

  1. Ваш обов'язок полягає в тому, щоб написати код, що має чітко визначену поведінку відповідно до мовних правил (і зокрема мовної стандартної моделі пам'яті).
  2. Обов’язком вашого компілятора є створення машинного коду, який має таку ж чітко визначену (спостережувану) поведінку в моделі пам'яті цільової архітектури.
  3. Ваш обов'язок CPU полягає в тому, щоб виконати цей код таким чином, щоб спостережувана поведінка була сумісною з власною моделлю пам'яті архітектури.

Якщо ви хочете зробити це по-своєму, в деяких випадках це може спрацювати, але розумійте, що гарантія недійсна, і ви несете повну відповідальність за будь-які небажані результати. :-)

PS: Правильно написаний приклад:

int main()
{
  std::unique_ptr<std::vector<int>> vec;
  std::atomic<int> ready{0}; // NOTE the use of the std::atomic template
  std::thread t{[&]
    {
       while (!ready);
       // use "vec" here
    });
  vec.reset(new std::vector<int>());
  ++ready;
  t.join();
}

Це безпечно, оскільки:

  1. Перевірки readyне можна оптимізувати за мовними правилами.
  2. ++ready Відбувається, перед тим чеку , який бачить , readyяк не дорівнює нулю, і інші операції не можуть бути перерозподілені навколо цих операцій. Це тому, що ++readyі перевірка послідовно послідовна , що є ще одним терміном, описаним у моделі пам'яті C ++, і забороняє це специфічне упорядкування. Тому компілятор не повинен змінювати впорядкованість інструкцій, а також повинен сказати ЦП, що він не повинен, наприклад, відкладати запис vecна після після збільшення ready. Послідовно послідовна є найсильнішою гарантією щодо атоміки в мовному стандарті. Менші (і теоретично дешевші) гарантії доступні, наприклад, за допомогою інших методівstd::atomic<T>, але це, безумовно, лише для експертів, і розробники компілятора можуть не оптимізувати їх, оскільки вони рідко використовуються.

1
Якби компілятор не міг бачити всі способи використання ready, він, ймовірно, компілюється while (!ready);у щось подібне if(!ready) { while(true); }. Оновлено: ключова частина std :: atomic змінює семантику, щоб припустити асинхронну модифікацію в будь-якій точці. Як правило, це UB - це те, що дозволяє компіляторам піднімати вантажі та мивати сховища з циклів.
Пітер Кордес

9

На одноядерній машині x86 addінструкція, як правило, є атомною щодо іншого коду на ЦП 1 . Переривання не може розділити одну інструкцію по середині.

Виконання поза замовленням необхідне для збереження ілюзії інструкцій, що виконуються по черзі, для порядку в одному ядрі, тому будь-яка інструкція, що працює на одному і тому ж процесорі, відбудеться повністю до або повністю після додавання.

Сучасні системи x86 є багатоядерними, тому уніпроцесорний спеціальний корпус не застосовується.

Якщо націлений на невеликий вбудований ПК і не планує переміщувати код ні на що інше, атомний характер інструкції "додати" може бути використаний. З іншого боку, платформи, де операції по суті є атомними, стають дедалі дефіцитнішими.

(Це не допоможе вам , якщо ви пишете в C ++, хоча. Укладачі не має право вимагати , num++щоб зібрати в пам'яті призначення оного або XADD без з lockприставкою. Вони можуть вибрати для завантаження numв регістр і магазин приріст результату з окремою інструкцією і, ймовірно, це зробить, якщо ви використовуєте результат.)


Примітка 1: lockПрефікс існував навіть на оригінальній версії 8086, оскільки пристрої вводу / виводу працюють одночасно з процесором; драйвери одноядерної системи потребують lock addатомного збільшення значення в пам'яті пристрою, якщо пристрій також може змінити його або стосовно доступу до DMA.


Він навіть не є атомним: інший потік може одночасно оновлювати ту саму змінну, і береться лише одне оновлення.
fuz

1
Розглянемо багатоядерну систему. Звичайно, в одному ядрі інструкція є атомною, але вона не є атомною щодо всієї системи.
фуз

1
@FUZxxl: Якими були четвертий та п’ятий слова моєї відповіді?
supercat

1
@supercat Ваша відповідь є дуже оманливою, оскільки вона вважає лише рідкісний на сьогоднішній день випадком єдиного ядра та дає ОП помилкове почуття безпеки. Тому я прокоментував також розглянути багатоядерний випадок.
fuz

1
@FUZxxl: Я вніс зміни, щоб очистити потенційну плутанину для читачів, які не помітили, що це не говорить про звичайні сучасні багатоядерні процесори. (А також будьте більш конкретні щодо деяких речей, у яких суперкоти не були впевнені). До речі, все у цій відповіді вже є моїм, крім останнього речення про те, як платформи, де читати-змінювати-писати, є атомними "безкоштовно", рідкісні.
Пітер Кордес

7

Ще в той час, коли на комп'ютерах x86 був один процесор, використання однієї інструкції гарантувало, що переривання не розділять читання / модифікацію / запис, і якщо пам'ять також не буде використовуватися як DMA-буфер, вона фактично була атомною (і C ++ не згадував потоки в стандарті, тому це не було вирішено).

Коли на робочому столі клієнта було рідко наявність подвійного процесора (наприклад, Pentium Pro з двома розетками), я ефективно використовував це, щоб уникнути приставки LOCK на одноядерній машині та підвищити продуктивність.

Сьогодні це допоможе лише проти кількох потоків, які були встановлені на однакові спорідненості до процесора, тому потоки, про які ви переживаєте, вступатимуть у дію лише через проміжок часу, що закінчується, та запускаючи інший потік того ж процесора (ядра). Це нереально.

З сучасними процесорами x86 / x64, одна інструкція розбивається на кілька мікрооперацій, а також читання та запис пам'яті буферизовано. Тож різні потоки, що працюють на різних процесорах, не лише сприйматимуть це як атомарне, але й можуть побачити непослідовні результати щодо того, що він читає з пам'яті і що він передбачає, що інші потоки прочитали до того часу: вам потрібно додати огорожі пам'яті, щоб відновити здоровий поведінка.


1
Переривання ще не розщеплюються операції МРО, так що вони дійсно до цих пір синхронізувати один потік з обробників сигналів , які виконуються в тому ж потоці. Звичайно, це працює лише в тому випадку, якщо в ASM використовується одна інструкція, а не окреме завантаження / модифікація / зберігання. C ++ 11 може розкрити цю апаратну функціональність, але це не так (можливо, тому, що це було корисно лише в ядрах Uniprocessor для синхронізації з обробниками переривань, а не в просторі користувача з обробниками сигналів). Також архітектури не мають інструкцій з читання-зміни-запису пам'яті призначення. І все-таки він міг би просто зібрати, як розслаблений атомний RMW на не-x86
Пітер Кордес

Хоча як я пам’ятаю, використання префіксу Lock не було нерозумно дорогим, поки не з'явилися суперскалери. Тож не було підстав помічати це як уповільнення важливого коду в 486, навіть якщо він не був потрібен цій програмі.
JDługosz

Так вибачте! Я насправді не читав уважно. Я побачив початок абзацу з червоною оселедець про розшифровку упс, і не закінчив читати, щоб побачити, що ви насправді сказали. re: 486: Я думаю, що я читав, що найдавнішим SMP був якийсь Compaq 386, але його семантика впорядкування пам’яті не була такою ж, як зараз пише ISA x86. У поточних посібниках x86 можна навіть згадати SMP 486. Вони, звичайно, не були поширеними навіть у HPC (кластери Beowulf) до днів PPro / Athlon XP, хоча, я думаю.
Пітер Кордес

1
@PeterCordes Гаразд. Звичайно, якщо також не було спостерігачів DMA / пристроїв - не помістилося в область коментарів, щоб включити і цю. Дякую JDługosz за відмінне доповнення (відповідь, а також коментарі). Дійсно завершив дискусію.
Лео Хайнсаар

3
@Leo: Один ключовий момент, про який не згадувалося: процесори поза замовленням впорядковують речі внутрішньо, але золотим правилом є те, що для одного ядра вони зберігають ілюзію інструкцій, що виконуються одна за одною, в порядку. (І це включає переривання, які запускають контекстні комутатори). Цінності можуть бути електрично збережені в пам'яті не в порядку, але єдине ядро, яке все працює, відстежує все переупорядкування, яке він робить сам, щоб зберегти ілюзію. Ось чому вам не потрібен бар'єр пам’яті для еквівалента ASM, a = 1; b = a;щоб правильно завантажити 1, який ви тільки що зберегли.
Пітер Кордес

4

https://www.youtube.com/watch?v=31g0YE61PLQ (Це лише посилання на сцену "Ні" з "Офісу")

Чи згодні ви, що це може бути можливим результатом для програми:

вибірка вибірки:

100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100

Якщо так, то компілятор вільний зробити єдиний можливий вихід програми, у будь-який спосіб компілятор захоче. тобто головний (), який щойно виводить 100.

Це правило "як-ніби".

І незалежно від результату, ви можете думати синхронізацію потоків однаково - якщо потік A робить, num++; num--;а потік B читається numповторно, можливим дійсним переплетенням є те, що потік B ніколи не читає між num++і num--. Оскільки це перемежування дійсне, компілятор вільний зробити це єдино можливим переплетенням. І просто видаліть incr / decr повністю.

Тут є кілька цікавих наслідків:

while (working())
    progress++;  // atomic, global

(тобто уявіть, що деякі інші потоки оновлюють інтерфейс панелі прогресу на основі progress)

Чи може компілятор перетворити це на:

int local = 0;
while (working())
    local++;

progress += local;

ймовірно, це дійсно. Але, мабуть, не те, на що сподівався програміст :-(

Комітет досі працює над цим матеріалом. В даний час це "працює", оскільки компілятори не дуже оптимізують атоміку. Але це змінюється.

І навіть якщо progressвона також була мінливою, це все одно діятиме:

int local = 0;
while (working())
    local++;

while (local--)
    progress++;

: - /


Ця відповідь, здається, відповідає лише на побічне запитання, про яке ми з Річардом розмірковували. Ми врешті-решт вирішили це: виявляється, що так, стандарт C ++ дійсно дозволяє об'єднувати операції на volatileнеатомних об'єктах, коли він не порушує жодних інших правил. Два документи, що обговорюють стандарти, обговорюють саме це (посилання в коментарі Річарда ), один використовує той самий приклад протидії ходу. Тож це питання якості впровадження, поки C ++ не стандартизує способи запобігання.
Пітер Кордес

Так, моє "Ні" - це справді відповідь на весь аргумент. Якщо питання просто "чи може число ++ бути атомним на якомусь компіляторі / реалізації", відповідь точно. Наприклад, компілятор міг вирішити додати lockдо кожної операції. Або якась компілятор + однопроцесорна комбінація, де ні переупорядкування (тобто "добрі дні") все не є атомним. Але який сенс у цьому? Ти не можеш покластися на це. Якщо ви не знаєте, що це система, для якої ви пишете. (Вже тоді краще було б, щоб атомний <int> не додавав додаткових операційних можливостей у цій системі. Тож вам все одно слід написати стандартний код ...)
tony

1
Зауважте, що And just remove the incr/decr entirely.це не зовсім правильно. Це все ще операція придбання та випуску на num. На x86 num++;num--можна компілювати лише MFENCE, але точно не нічого. (Якщо тільки аналіз програми всієї програми компілятора не зможе довести, що ніщо не синхронізується з цією модифікацією num, і що не має значення, якщо деякі магазини з цього періоду затримуються до завантаження після цього.) Наприклад, якщо це було розблокування та повторне -lock-right-away use-case, у вас ще є два окремих критичних розділу (можливо, використовуючи mo_relaxed), не один великий.
Пітер Кордес

@PeterCordes ах так, погодився.
тоні

2

Так, але...

Атомне - це не те, що ти мав намір сказати. Ви, мабуть, запитуєте неправильну річ.

Приріст, безумовно, атомний . Якщо пам'ять не вирівняно (а оскільки ви залишили вирівнювання компілятору, це не так), воно обов'язково вирівнюється в межах одного рядка кешу. Не маючи спеціальних інструкцій потокового потоку без кешування, кожне записування проходить через кеш. Повні рядки кешу атомно читаються і записуються, ніколи нічого іншого.
Дані, що менші за кешлін, звичайно, також записуються атомно (оскільки оточуючий кеш-рядок є).

Це безпечно для ниток?

Це вже інше питання, і є як мінімум дві вагомі причини, щоб відповісти певним "Ні!" .

По-перше, існує можливість, що інше ядро ​​може мати копію цього рядка кешу в L1 (L2 і вище зазвичай є спільним, але L1, як правило, на ядро!), І одночасно змінює це значення. Звичайно, це теж відбувається атомно, але тепер у вас є два "правильних" (правильно, атомно, модифікованих) значення - яке з них є справді правильним?
Процесор, звичайно, розбереться якось. Але результат може бути не таким, якого ви очікуєте.

По-друге, є впорядкування пам’яті або висловлюється по-різному - перед гарантіями. Найголовніше в атомних інструкціях - це не стільки те, скільки вони атомні . Це замовлення.

У вас є можливість забезпечити гарантію, що все, що відбувається в пам'яті, буде реалізовано в певному гарантованому, чітко визначеному порядку, коли у вас є гарантія "раніше" Це впорядкування може бути настільки ж розслабленим (читати як: взагалі немає) або настільки строгим, як вам потрібно.

Наприклад, ви можете встановити вказівник на якийсь блок даних (скажімо, результати деякого обчислення), а потім атомним чином відпустити прапор "дані готові". Тепер, хто придбає цей прапор, спонукає думати, що покажчик дійсний. І дійсно, це завжди буде дійсним покажчиком, ніколи нічого іншого. Це тому, що запис у вказівник стався - до атомної операції.


2
Навантаження та запас - кожен атомний окремо, але вся операція читання-модифікація-запис у цілому, безумовно, не є атомною. Кеші є когерентними, тому ніколи не можуть містити суперечливі копії одного рядка ( en.wikipedia.org/wiki/MESI_protocol ). Інше ядро ​​навіть не може мати копію, доступну лише для читання, тоді як ця ядро ​​має її у зміненому стані. Що робить його неатомним, це те, що ядро, що виконує RMW, може втратити право власності на кеш-лінію між вантажем і магазином.
Пітер Кордес

2
Крім того, ні, цілі рядки кешу не завжди переносяться атомно. Дивіться цю відповідь , де експериментально продемонстровано, що Opteron з декількома розетками робить 16B SSE сховищами без атомів, передаючи лінії кешу в 8B фрагментах з гіперперенесенням, хоча вони є атомними для однотипних процесорів одного типу (тому що навантаження / обладнання обладнання має шлях 16В до кешу L1). x86 гарантує атомність лише для окремих вантажів або магазинів до 8В.
Пітер Кордес

Якщо залишити вирівнювання компілятору, це не означає, що пам'ять буде вирівняна на 4-байтовій межі. Компілятори можуть мати варіанти або прагми для зміни межі вирівнювання. Це корисно, наприклад, для роботи з щільно упакованими даними в мережевих потоках.
Дмитро Рубанович

2
Софісти, більше нічого. Ціле число з автоматичним зберіганням, яке не є частиною структури, як показано в прикладі, буде абсолютно позитивно вирівняно. Стверджувати що-небудь інше - це просто нерозумно. Лінії кешу, як і всі POD, мають розмір PoT (потужність двох) та вирівняні - для будь-якої неілюзорної архітектури у світі. Математика передбачає, що будь-який правильно вирівняний PoT вписується точно в один (ніколи більше) будь-який інший PoT такого ж розміру або більше. Тому моє твердження правильне.
Деймон

1
@Damon, приклад, наведений у запитанні, не згадує про структуру, але це не звужує питання лише до тих ситуацій, коли цілі числа не є частинами структур. ПДД, безумовно, можуть мати розмір PoT і не бути вирівняними PoT. Подивіться цю відповідь на приклади синтаксису: stackoverflow.com/a/11772340/1219722 . Тож навряд чи це "софістика", тому що задекларовані таким чином ПДД використовуються в мережевому коді зовсім небагато в реальному коді.
Дмитро Рубанович

2

Те, що вихід одного компілятора на конкретній архітектурі процесора з відключеними оптимізаціями (оскільки gcc навіть не компілюється ++під addчас оптимізації у швидкому та брудному прикладі ), схоже, передбачає збільшення цього способу, атомний, не означає, що це відповідає стандарту ( Ви б викликали невизначену поведінку при спробі доступу numв потоці), і в будь-якому випадку помиляєтеся, оскільки неadd є атомним у x86.

Зауважте, що атомія (з використанням lockпрефікса інструкції) відносно важка для x86 ( див. Цю відповідну відповідь ), але все ж на диво менша, ніж mutex, що не дуже доречно в цьому випадку використання.

Наступні результати взяті з кланг ++ 3,8 при компіляції з -Os.

Збільшення int за допомогою посилання "регулярним" способом:

void inc(int& x)
{
    ++x;
}

Це компілюється в:

inc(int&):
    incl    (%rdi)
    retq

Збільшення int, переданого посиланням, атомним шляхом:

#include <atomic>

void inc(std::atomic<int>& x)
{
    ++x;
}

Цей приклад, який не є набагато більш складним , ніж звичайним способом, просто отримує lockпрефікс додається до inclінструкції - але обережність, як було сказано раніше , це НЕ дешево. Тільки те, що збірка виглядає короткою, не означає, що вона швидка.

inc(std::atomic<int>&):
    lock            incl    (%rdi)
    retq

-2

Коли ваш компілятор використовує лише одну інструкцію для збільшення та ваша машина є однопотоковою, ваш код є безпечним. ^^


-3

Спробуйте скласти той самий код на машині, яка не є x86, і ви швидко побачите дуже різні результати складання.

Причина, як num++ видається, атомна полягає в тому, що на x86-машинах збільшення 32-бітного цілого числа насправді є атомним (якщо припустити, що не відбувається пошуку пам'яті). Але це не гарантується стандартом c ++, і це, мабуть, не відбудеться на машині, яка не використовує набір інструкцій x86. Таким чином, цей код не є безпечним для перегонів умовами перегонів.

Ви також не маєте надійної гарантії, що цей код захищений від Race Conditions навіть у архітектурі x86, оскільки x86 не встановлює навантаження та зберігає в пам'яті, якщо спеціально не доручено це робити. Отже, якщо кілька потоків намагалися оновити цю змінну одночасно, вони можуть закінчуватися збільшенням кешованих (застарілих) значень

Причина в тому, що ми маємо std::atomic<int>і так далі, полягає в тому, що коли ви працюєте з архітектурою, де атомність основних обчислень не гарантується, у вас є механізм, який змусить компілятор генерувати атомний код.


"це тому, що на машинах x86 збільшення 32-бітного цілого числа насправді є атомним." чи можете ви надати посилання на документацію, яка підтверджує це?
Слава

8
Це не атомно і на x86. Це одноядерний безпечний, але якщо є декілька ядер (і є), це зовсім не атомно.
Гарольд

Чи addсправді x86 гарантовано атомний? Я не був би здивований, якби збільшення реєстру було атомним, але це навряд чи корисно; щоб зробити приріст реєстру видимим іншим потоком, він повинен бути в пам'яті, що вимагає додаткових інструкцій щодо завантаження та зберігання, видаляючи атомність. Я розумію, що lockдля цих інструкцій існує префікс; Єдина корисна атомна addзастосовується до вимкненої пам'яті і використовує lockпрефікс для забезпечення блокування рядка кешу протягом тривалості операції .
ShadowRanger

@Slava @Harold @ShadowRanger Я оновив відповідь. addє атомним, але я зрозумів, що це не означає, що код безпечний для расових умов, оскільки зміни не стають глобально видимими відразу.
Xirema

3
@Xirema, що робить його "не атомним" за визначенням, хоча
Гарольд
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.