Чому код, що мутує спільну змінну в потоках, очевидно НЕ страждає від стану перегонів?


107

Я використовую Cygwin GCC і запускаю цей код:

#include <iostream>
#include <thread>
#include <vector>
using namespace std;

unsigned u = 0;

void foo()
{
    u++;
}

int main()
{
    vector<thread> threads;
    for(int i = 0; i < 1000; i++) {
        threads.push_back (thread (foo));
    }
    for (auto& t : threads) t.join();

    cout << u << endl;
    return 0;
}

Зібраний з лінії: g++ -Wall -fexceptions -g -std=c++14 -c main.cpp -o main.o.

Він друкує 1000, що правильно. Однак я очікував меншої кількості через тему, що перезаписала раніше збільшене значення. Чому цей код не страждає від взаємного доступу?

Мій тестовий апарат має 4 ядра, і я не ставлю обмежень на програму, про яку я знаю.

Проблема зберігається під час заміни вмісту спільного доступу fooчимось складнішим, наприклад

if (u % 3 == 0) {
    u += 4;
} else {
    u -= 1;
}

66
Процесорні процесори Intel мають дивовижну внутрішню логіку "збиття" для збереження сумісності з дуже ранніми процесорами x86, які використовуються в системах SMP (наприклад, подвійні машини Pentium Pro). На багатьох пристроях x86 практично неможливо багато умов відмов, до яких ми навчаємо. Так що скажіть, ядро ​​йде uназад до пам'яті. Процесор насправді буде робити дивовижні речі, як-от зауважити, що лінія пам'яті для пам'яті uвідсутня в кеші процесора, і вона перезапустить операцію збільшення. Ось чому перехід від x86 до інших архітектур може стати досвідом відкриття!
Девід Шварц

1
Можливо, все-таки занадто швидко. Вам потрібно додати код, щоб переконатися, що потік виходить перед тим, як щось робити, щоб забезпечити запуску інших потоків до його завершення.
Роб К

1
Як було зазначено деінде, код потоку настільки короткий, що він може бути виконаний перед чергою наступного потоку. Як щодо 10 потоків, які розміщують u ++ у циклі 100 лічильників. І невелика затримка до початку запуску циклу (або глобального прапора "йти", щоб запустити їх все одночасно)
RufusVS

5
Насправді, нерестування програми в циклі неодноразово показує, що вона порушується: щось на зразок while true; do res=$(./a.out); if [[ $res != 1000 ]]; then echo $res; break; fi; done;друкує 999 або 998 на моїй системі.
Даніель Каміль Козар

Відповіді:


266

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


51
Це дійсно змінило результат очікуваним чином.
мафу

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

Нещодавно у нас виникла ця проблема із C #. Код майже ніколи не виходить з ладу, але нещодавнє додавання API-дзвінка між ними вводило достатньо затримки, щоб воно послідовно змінювалося.
Обсидіан Фенікс

@MatthieuM. Хіба Microsoft не має автоматизованого інструменту, який саме це робить, як метод виявлення перегонових умов та забезпечення їх надійного відтворення?
Мейсон Уілер

1
@MasonWheeler: Я працюю виключно на Linux, тому ... dunno :(
Matthieu M.

59

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

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

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

Зокрема, я очікую, що в цьому випадку код може бути складений до атомної інструкції Fetch and Add (ADD або XADD у складі X86), яка справді є атомною в однопроцесорних системах, однак для багатопроцесорних систем це не гарантовано є атомним і замком потрібно було б зробити так. Якщо ви працюєте в багатопроцесорній системі, з'явиться вікно, де потоки можуть втручатися і давати неправильні результати.

Зокрема, я зібрав ваш код до складання за допомогою https://godbolt.org/ і foo()компілює:

foo():
        add     DWORD PTR u[rip], 1
        ret

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


41
Важливо пам’ятати, що «біг за призначенням» - допустимий результат не визначеної поведінки.
Марк

3
Як ви вказали, ця інструкція не є атомною на машині SMP (якою є всі сучасні системи). Навіть inc [u]не є атомним. LOCKПрефікс потрібно , щоб зробити інструкції дійсно атомарної. ОП просто пощастило. Нагадаємо, що навіть якщо ви говорите процесору "додати слово до слова за цією адресою", процесор все одно повинен отримувати, збільшувати, зберігати це значення, а інший процесор може робити те ж саме одночасно, що призводить до неправильного результату.
Джонатан Райнхарт

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

3
Я вважаю це твердженням трохи meh "Особливо на гоночних умовах X86 та AMD64 машини в деяких випадках рідко викликають проблеми, оскільки багато інструкцій є атомними, а гарантії на узгодженість дуже високі". У абзаці слід почати робити чітке припущення, що ви зосереджені на одному ядрі. Незважаючи на це, багатоядерні архітектури сьогодні фактично є стандартними у споживчих пристроях, я вважаю це наріжним випадком для пояснення останнього, а не першого.
Патрік Трентін

3
О, безумовно. x86 має безліч зворотної сумісності ... інше, щоб переконатися, що неправильно написаний код працює наскільки це можливо. Це було дійсно великою справою, коли Pentium Pro представив виконання поза замовленнями. Intel хотіла переконатися, що встановлена ​​база коду працює без необхідності перекомпілювати спеціально для їх нового чіпа. x86 почався як ядро ​​CISC, але внутрішньо перетворився на ядро ​​RISC, хоча він все ще представляє і багато в чому поводиться як CISC з точки зору програміста. Детальніше дивіться у відповіді Пітера Кордеса тут .
Коді Грей

20

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

void foo()
{
    unsigned i = u;
    for (int s=0;s<10000;s++);
    u = i+1;
}

результат: 694


До речі: Я також спробував

if (u % 2) {
    u += 2;
} else {
    u -= 1;
}

і це давало мені більшість разів 1997, але іноді 1995.


1
Я б очікував, що на будь-якому розпливчастому розумному компіляторі вся функція буде оптимізована під одне і те ж. Я здивований, що не було. Дякую за цікавий результат.
Vality

Це точно правильно. Багато тисяч інструкцій потрібно виконати, перш ніж наступний потік почне виконувати відповідну крихітну функцію. Коли ви робите час виконання функції ближче до накладного створення потоку, ви бачите вплив стану гонки.
Джонатан Райнхарт

@Vality: Я також очікував, що він видалить помилковий цикл for-opt в оптимізації O3. Це не так?
користувач21820

Як else u -= 1коли-небудь можна було стратити? Навіть у паралельному середовищі цінність ніколи не повинна відповідати %2, чи не так?
мафу

2
з виводу, схоже else u -= 1, виконується один раз, перший раз викликається foo (), коли u == 0. Решта 999 разів u непарно і u += 2виконується, в результаті чого u = -1 + 999 * 2 = 1997; тобто правильний вихід. Умови гонки іноді призводять до того, що один із + = 2 буде перезаписаний паралельною ниткою, і ви отримаєте 1995 рік.
Люк

7

Він страждає від перегонів. Покладіть usleep(1000);перед тим u++;в fooі я бачу різний висновок (<1000) кожен раз.


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

  2. Навіть у вашій оригінальній версії результат змінюється залежно від системи: я спробував це по-своєму (чотириядерний) Macbook, і за десять запускав я отримав 1000 тричі, 999 шість разів і 998 раз. Тож гонка дещо рідкісна, але явно присутня.

  3. Ви компілювали '-g', у якого є можливість зникнути помилки. Я перекомпілював ваш код, як і раніше незмінний, але без '-g', і гонка стала набагато більш вираженою: я отримав 1000 раз, 999 тричі, 998 двічі, 997 двічі, 996 один раз і 992 раз.

  4. Re. пропозиція про додавання сну - це допомагає, але (a) фіксований час сну залишає нитки все ще перекошеними до часу запуску (за умови дозволу таймера), і (b) випадковий сон поширює їх, коли ми хочемо потягніть їх ближче один до одного. Натомість я б зашифрував їх, щоб дочекатися стартового сигналу, тому я можу створити їх усі, перш ніж дозволити їм працювати. З цією версією (з або без '-g') я отримую результати в усьому місці, аж до 974 і не вище 998:

    #include <iostream>
    #include <thread>
    #include <vector>
    using namespace std;
    
    unsigned u = 0;
    bool start = false;
    
    void foo()
    {
        while (!start) {
            std::this_thread::yield();
        }
        u++;
    }
    
    int main()
    {
        vector<thread> threads;
        for(int i = 0; i < 1000; i++) {
            threads.push_back (thread (foo));
        }
        start = true;
        for (auto& t : threads) t.join();
    
        cout << u << endl;
        return 0;
    }

Просто записка. -gПрапор не якимось - яким чином «роблять помилки зникають.» -gПрапор на обох GNU і брязкіт компіляторів просто додає символи налагодження в скомпільований двійковий файл. Це дозволяє запускати діагностичні інструменти, такі як GDB і Memcheck, у своїх програмах з певним людським результатом. Наприклад, коли Memcheck працює над програмою з витоком пам’яті, він не повідомляє вам номер рядка, якщо програма не була побудована за допомогою -gпрапора.
MS-DDOS

Зрозуміло, помилки, що ховаються від налагоджувача, зазвичай є більшою мірою оптимізацією компілятора; Я повинен був спробувати, і сказав, «використовуючи -O2 замість з -g». Але це означає, що якщо ви ніколи не мали радості полювати на клопа, який проявився б лише тоді, коли складено без нього -g , вважайте себе щасливим. Це може трапитися з деякими дуже неприємними помилковими помилками. Я вже бачив його, хоча і НЕ в останній час , і я міг повірити , що може бути , це було примхою старого пропрієтарного компілятора, тому я вірю, умовно, про сучасних версіях GNU і Clang.
квітня

-gне заважає вам використовувати оптимізації. напр., gcc -O3 -gробить те саме, що і як gcc -O3, але з метаданими налагодження. gdb скаже "оптимізовано", якщо ви спробуєте надрукувати деякі змінні. -gможе, можливо, змінити відносні місця розташування деяких речей у пам'яті, якщо будь-який із доданих ним матеріалів є частиною .textрозділу. Це, безумовно, займає місце в файлі об'єкта, але я думаю, що після його посилання все закінчується на одному кінці текстового сегмента (а не розділу) або взагалі не є частиною сегмента. Можливо, це може вплинути на те, де відображаються речі для динамічних бібліотек.
Пітер Кордес
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.