Що саме є std :: atomic?


174

Я розумію, що std::atomic<>це атомний об’єкт. Але атомний в якій мірі? Наскільки я розумію, операція може бути атомною. Що саме мається на увазі, роблячи об’єкт атомним? Наприклад, якщо одночасно є два потоки, виконуючи такий код:

a = a + 12;

Тоді ціла операція (скажімо add_twelve_to(int)) атомна? Або вносяться зміни в змінну атомну (так operator=())?


9
Вам потрібно використовувати щось на кшталт, a.fetch_add(12)якщо ви хочете атомну RMW.
Керрек СБ

Так, це я не розумію. Що мається на увазі під час створення об’єкта атомним. Якби був інтерфейс, він міг би просто зробити атомним з мютекс або монітором.

2
@AaryamanSagar це вирішує питання ефективності. Мутекси та монітори несуть обчислювальні витрати. Використання std::atomicдозволяє стандартній бібліотеці вирішити, що потрібно для досягнення атомності.
Дрю Дорман

1
@AaryamanSagar: std::atomic<T>тип, який дозволяє проводити атомні операції. Це не робить магічне ваше життя кращим, ви все ще повинні знати, що ви хочете з цим зробити. Це дуже специфічний випадок використання, і використання атомних операцій (на об'єкті), як правило, дуже тонке і їх потрібно продумати з нелокальної точки зору. Тож якщо ви вже не знаєте, що і чому ви хочете атомних операцій, тип, мабуть, не дуже корисний для вас.
Керрек СБ

Відповіді:


188

Кожна інстанція та повна спеціалізація std :: atomic <> являє собою тип, над яким одночасно можуть працювати різні потоки (їх екземпляри), не підвищуючи невизначеної поведінки:

Об'єкти атомних типів - єдині об'єкти C ++, що не містять перегонів даних; тобто, якщо один потік записує до атомного об'єкта, а інший читає з нього, поведінка чітко визначена.

Крім того, доступ до атомних об'єктів може встановлювати міжпотокову синхронізацію та впорядковувати неатомні пам’яті доступу, як зазначено в std::memory_order.

std::atomic<>операції обгортання, які в 11 разів до C ++ повинні були виконуватись, використовуючи (наприклад) зблоковані функції з MSVC або атомними бултинами у випадку GCC.

Крім того, std::atomic<>дає вам більше контролю, дозволяючи різні замовлення пам’яті, які задають обмеження синхронізації та впорядкування. Якщо ви хочете прочитати більше про атоміку C ++ 11 та модель пам'яті, ці посилання можуть бути корисними:

Зауважте, що для типових випадків використання ви, ймовірно, використовуєте перевантажені арифметичні оператори або інший їх набір :

std::atomic<long> value(0);
value++; //This is an atomic op
value += 5; //And so is this

Оскільки синтаксис оператора не дозволяє вказувати порядок пам'яті, ці операції виконуватимуться з std::memory_order_seq_cst, оскільки це порядок за замовчуванням для всіх атомних операцій на C ++ 11. Це гарантує послідовну послідовність (загальний глобальний порядок) між усіма атомними операціями.

Однак у деяких випадках це може не знадобитися (і нічого не приходить безкоштовно), тому ви можете скористатися більш чіткою формою:

std::atomic<long> value {0};
value.fetch_add(1, std::memory_order_relaxed); // Atomic, but there are no synchronization or ordering constraints
value.fetch_add(5, std::memory_order_release); // Atomic, performs 'release' operation

Тепер ваш приклад:

a = a + 12;

не буде оцінюватися до одного атомного оп: це призведе до a.load()(що є атомним), а потім додавання між цим значенням 12та a.store()(також атомним) кінцевим результатом. Як я вже зазначав, std::memory_order_seq_cstтут буде використовуватися.

Однак, якщо ви пишете a += 12, це буде атомна операція (як я вже зазначав раніше) і приблизно еквівалентна a.fetch_add(12, std::memory_order_seq_cst).

Щодо Вашого коментаря:

Черговий intмає атомні навантаження та магазини. Який сенс обгортати це atomic<>?

Ваше твердження справедливо лише для архітектур, які надають таку гарантію атомності для магазинів та / або вантажів. Є архітектури, які цього не роблять. Крім того, зазвичай потрібно, щоб операції над адресою, суміщеною word / dword, були атомною std::atomic<>- це те, що гарантовано є атомним на кожній платформі, без додаткових вимог. Крім того, це дозволяє писати такий код:

void* sharedData = nullptr;
std::atomic<int> ready_flag = 0;

// Thread 1
void produce()
{
    sharedData = generateData();
    ready_flag.store(1, std::memory_order_release);
}

// Thread 2
void consume()
{
    while (ready_flag.load(std::memory_order_acquire) == 0)
    {
        std::this_thread::yield();
    }

    assert(sharedData != nullptr); // will never trigger
    processData(sharedData);
}

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

  • store()до прапора виконується після того, sharedDataяк встановлено (ми припускаємо, що generateData()завжди повертає щось корисне, зокрема, ніколи не повертається NULL) і використовує std::memory_order_releaseпорядок:

memory_order_release

Операція зберігання з цим замовленням пам’яті виконує операцію випуску : жодне читання чи запис у поточному потоці не може бути упорядковано після цього зберігання. Усі записи в поточному потоці видно в інших потоках, які набувають однакову атомну змінну

  • sharedDataвикористовується після whileвиходу циклу, і, отже, після load()прапора поверне ненульове значення. load()використовує std::memory_order_acquireзамовлення:

std::memory_order_acquire

Операція завантаження з цим замовленням пам’яті виконує операцію придбання в ураженому місці пам'яті: жодне зчитування чи запис у поточному потоці не може бути упорядковано перед цим завантаженням. Усі записи в інших потоках, які випускають ту саму атомну змінну, видно в поточному потоці .

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


2
Чи є насправді архітектури, які не мають атомних навантажень і сховища для примітивів, таких як ints?

7
Йдеться не лише про атомність. мова йде також про впорядкованість, поведінку в багатоядерних системах тощо. Ви можете прочитати цю статтю .
Матеуш Гжежек

4
@AaryamanSagar Якщо я не помиляюся, навіть на x86 читання та записи є атомними ТІЛЬКИ, якщо вирівняні за межами слова.
в.шашенко

@MateuszGrzejek Я взяв посилання на атомний тип. Не могли б ви переконатися, що наступне все-таки гарантуватиме атомну роботу при призначенні об'єкта ideone.com/HpSwqo
xAditya3393

3
@TimMB Так, зазвичай, у вас буде (принаймні) дві ситуації, коли порядок виконання може бути змінений: (1) компілятор може змінити інструкції (наскільки це дозволяє стандарт), щоб забезпечити кращу продуктивність вихідного коду (на основі використання регістрів процесора, прогнозів тощо) та (2) ЦП може виконувати інструкції в іншому порядку, наприклад, мінімізувати кількість точок синхронізації кешу. Замовлення обмежень, передбачених std::atomic( std::memory_order), служить саме меті обмеження порядків, дозволених.
Матеуш Гжежек

20

Я розумію, що std::atomic<>робить об'єкт атомним.

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

a = a + 12;

std::atomic<>(Не вираження використовують шаблон , щоб) спростити це до однієї атомарної операції, натомість operator T() const volatile noexceptчлен робить атомну load()з a, а потім додають дванадцять, і operator=(T t) noexceptробить store(t).


Це я хотів запитати. Звичайний інт має атомні навантаження та магазини. Який сенс

8
@AaryamanSagar Просто модифікація нормального intне переносимо портативно, щоб зміни були видимими з інших потоків, а також читання не гарантувало, що ви бачите зміни інших потоків, а деякі речі, як-от my_int += 3, не гарантовано виконуватись атомним шляхом, якщо ви не використовуєте std::atomic<>- вони можуть включати витяг, потім додайте, а потім зберігайте послідовність, де інший потік, який намагається оновити те саме значення, може надходити після вилучення і перед сховищем, і клобувати оновлення вашого потоку.
Тоні Делрой

" Просто модифікація звичайного int не забезпечує портативне забезпечення видимості змін з інших потоків " Це гірше: будь-яка спроба виміряти видимість призведе до UB.
curiousguy

8

std::atomic існує тому, що багато ISA мають пряму апаратну підтримку для нього

Що говорить стандарт C ++ std::atomic, було проаналізовано в інших відповідях.

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

Основним висновком цього експерименту є те, що сучасні процесори мають пряму підтримку атомних цілочисельних операцій, наприклад, префікса LOCK у x86, і в std::atomicосновному існує як портативний інтерфейс для цих втручань: Що означає інструкція "замок" у складі x86? В архіві64 буде використано LDADD .

Ця підтримка дозволяє отримати більш швидкі альтернативи більш загальним методам, таким як std::mutex, який може зробити складніші секції з декількома інструкціями атомними, за рахунок того, що вони будуть повільнішими, ніж std::atomicчерез std::mutexте, що він робить futexсистемні дзвінки в Linux, але це набагато повільніше, ніж інструкції з користувальницьких даних std::atomic, див. також: Чи створює std :: mutex паркан?

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

main.cpp

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

size_t niters;

#if STD_ATOMIC
std::atomic_ulong global(0);
#else
uint64_t global = 0;
#endif

void threadMain() {
    for (size_t i = 0; i < niters; ++i) {
#if LOCK
        __asm__ __volatile__ (
            "lock incq %0;"
            : "+m" (global),
              "+g" (i) // to prevent loop unrolling
            :
            :
        );
#else
        __asm__ __volatile__ (
            ""
            : "+g" (i) // to prevent he loop from being optimized to a single add
            : "g" (global)
            :
        );
        global++;
#endif
    }
}

int main(int argc, char **argv) {
    size_t nthreads;
    if (argc > 1) {
        nthreads = std::stoull(argv[1], NULL, 0);
    } else {
        nthreads = 2;
    }
    if (argc > 2) {
        niters = std::stoull(argv[2], NULL, 0);
    } else {
        niters = 10;
    }
    std::vector<std::thread> threads(nthreads);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i] = std::thread(threadMain);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i].join();
    uint64_t expect = nthreads * niters;
    std::cout << "expect " << expect << std::endl;
    std::cout << "global " << global << std::endl;
}

GitHub вище за течією .

Складіть, запустіть і розберіть:

comon="-ggdb3 -O3 -std=c++11 -Wall -Wextra -pedantic main.cpp -pthread"
g++ -o main_fail.out                    $common
g++ -o main_std_atomic.out -DSTD_ATOMIC $common
g++ -o main_lock.out       -DLOCK       $common

./main_fail.out       4 100000
./main_std_atomic.out 4 100000
./main_lock.out       4 100000

gdb -batch -ex "disassemble threadMain" main_fail.out
gdb -batch -ex "disassemble threadMain" main_std_atomic.out
gdb -batch -ex "disassemble threadMain" main_lock.out

Надзвичайно вірогідний "неправильний" результат перегонів для main_fail.out:

expect 400000
global 100000

і детермінований "правильний" вихід інших:

expect 400000
global 400000

Розбирання main_fail.out:

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     mov    0x29b5(%rip),%rcx        # 0x5140 <niters>
   0x000000000000278b <+11>:    test   %rcx,%rcx
   0x000000000000278e <+14>:    je     0x27b4 <threadMain()+52>
   0x0000000000002790 <+16>:    mov    0x29a1(%rip),%rdx        # 0x5138 <global>
   0x0000000000002797 <+23>:    xor    %eax,%eax
   0x0000000000002799 <+25>:    nopl   0x0(%rax)
   0x00000000000027a0 <+32>:    add    $0x1,%rax
   0x00000000000027a4 <+36>:    add    $0x1,%rdx
   0x00000000000027a8 <+40>:    cmp    %rcx,%rax
   0x00000000000027ab <+43>:    jb     0x27a0 <threadMain()+32>
   0x00000000000027ad <+45>:    mov    %rdx,0x2984(%rip)        # 0x5138 <global>
   0x00000000000027b4 <+52>:    retq

Розбирання main_std_atomic.out:

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a6 <threadMain()+38>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock addq $0x1,0x299f(%rip)        # 0x5138 <global>
   0x0000000000002799 <+25>:    add    $0x1,%rax
   0x000000000000279d <+29>:    cmp    %rax,0x299c(%rip)        # 0x5140 <niters>
   0x00000000000027a4 <+36>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a6 <+38>:    retq   

Розбирання main_lock.out:

Dump of assembler code for function threadMain():
   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a5 <threadMain()+37>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock incq 0x29a0(%rip)        # 0x5138 <global>
   0x0000000000002798 <+24>:    add    $0x1,%rax
   0x000000000000279c <+28>:    cmp    %rax,0x299d(%rip)        # 0x5140 <niters>
   0x00000000000027a3 <+35>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a5 <+37>:    retq

Висновки:

  • безотомна версія зберігає глобальний реєстр і збільшує регістр.

    Тому, зрештою, дуже ймовірно, що чотири записи повертаються до глобальних з однаковим "неправильним" значенням 100000.

  • std::atomicкомпілює до lock addq. Префікс LOCK робить наступне incотримання, зміни та оновлення пам'яті атомним шляхом.

  • наш явний вбудований префікс LOCK компілюється майже до того ж std::atomic, що інакше, за винятком того, що наш incвикористовується замість add. Не впевнений, чому GCC обрав add, враховуючи, що наш INC генерував декодування на 1 байт менше.

ARMv8 може використовувати або LDAXR + STLXR, або LDADD в нових процесорах: Як запустити теми в звичайному C?

Тестовано в Ubuntu 19.10 AMD64, GCC 9.2.1, Lenovo ThinkPad P51.

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