Програма багатопотокової роботи застрягла в оптимізованому режимі, але працює нормально в -00


68

Я написав прості багатопотокові програми так:

static bool finished = false;

int func()
{
    size_t i = 0;
    while (!finished)
        ++i;
    return i;
}

int main()
{
    auto result=std::async(std::launch::async, func);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    finished=true;
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Він поводиться нормально в режимі налагодження у Visual studio або -O0в gc c і друкує результат через 1секунди. Але він застряг і нічого не друкує в режимі випуску або -O1 -O2 -O3.


Коментарі не для розширеного обговорення; ця розмова була переміщена до чату .
Самуель Liew

Відповіді:


100

UB Це стосується двох потоків, що мають доступ до неатомної, незахищеної змінної finished. Ви можете зробити finishedтипstd::atomic<bool> щоб виправити це.

Моє виправлення:

#include <iostream>
#include <future>
#include <atomic>

static std::atomic<bool> finished = false;

int func()
{
    size_t i = 0;
    while (!finished)
        ++i;
    return i;
}

int main()
{
    auto result=std::async(std::launch::async, func);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    finished=true;
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Вихід:

result =1023045342
main thread id=140147660588864

Демонстрація демо на колиру


Хтось може подумати: "Це bool- певно, один шматочок. Як це може бути неатомним? ' (Я це робив, коли почав із багатопотокової передачі.)

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

Створення boolнеохоронних, не атомних може спричинити додаткові проблеми:

  • Компілятор може вирішити оптимізувати змінну в регістр або навіть декілька доступів CSE в один і підняти навантаження з циклу.
  • Змінна може бути кешована для ядра CPU. (У реальному житті процесори мають когерентні кеші . Це не реальна проблема, але стандарт C ++ є досить вільним, щоб охопити гіпотетичні C ++ реалізації на некогерентній спільній пам'яті, де atomic<bool>зmemory_order_relaxed магазином / завантаженням не працювало б, а де volatileні. нестабільним для цього буде UB, хоча це працює на практиці в реальній реалізації C ++.)

Щоб цього не сталося, компілятору потрібно сказати прямо не робити.


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


4
Я переглянув func()і подумав: "Я міг би це оптимізувати" Оптимізатор взагалі не піклується про нитки, і виявить нескінченний цикл, і з радістю перетворить його на "в той час (True)" Якщо ми подивимося на godbolt .org / z / Tl44iN ми можемо це бачити. Якщо закінчено, Trueвоно повертається. Якщо немає, то він переходить в безумовний перехід назад до себе (нескінченний цикл) на етикетці.L5
Baldrickk


2
@val: volatileв C ++ 11 немає жодних причин зловживати, оскільки ви можете отримати ідентичний asm з atomic<T>і std::memory_order_relaxed. Це працює, хоча на реальному апаратному забезпеченні: кеші є когерентними, тому інструкція щодо завантаження не може продовжувати читати чергове значення, коли магазин на іншому ядрі зобов’язується кешувати там. (MESI)
Пітер Кордес

5
@PeterCordes Використання volatileвсе ще є UB. Ви дійсно ніколи не повинні припускати щось, що напевно і очевидно, UB є безпечним лише тому, що ви не можете придумати спосіб, як він може піти не так, і це спрацювало, коли ви спробували це. Це змусило людей спалювати знову і знову.
Девід Шварц

2
@Damon Mutexes мають випуск / набуття семантики. Компілятор не може оптимізувати читання геть , якщо м'ютекс був замкнений раніше, тому захисту finishedз std::mutexроботою (без volatileабо atomic). Насправді ви можете замінити всі атоми на "просту" схему значення + мютекс; воно все одно працюватиме і просто буде повільніше. atomic<T>дозволяється використовувати внутрішній мютекс; atomic_flagгарантується лише без блокування.
Ерлконіг

42

Відповідь Шеффа описує, як виправити код. Я думав, що додам трохи інформації про те, що насправді відбувається в цій справі.

Я склав ваш код на godbolt, використовуючи рівень оптимізації 1 ( -O1). Ваша функція складається так:

func():
  cmp BYTE PTR finished[rip], 0
  jne .L4
.L5:
  jmp .L5
.L4:
  mov eax, 0
  ret

Отже, що тут відбувається? По-перше, ми маємо порівняння: cmp BYTE PTR finished[rip], 0- це перевіряє, чи finishedє помилковим чи ні.

Якщо це НЕ брехня (ака правда) , ми повинні вийти з циклу при першому запуску. Це досягнутоjne .L4 що j umps, коли n ot e qual позначає, .L4де значення i( 0) зберігається в реєстрі для подальшого використання і функція повертається.

Якщо це є хибним , проте, ми переходимо до

.L5:
  jmp .L5

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

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

То чому це сталося?

Що стосується оптимізатора, нитки знаходяться поза його компетенцією. Він передбачає, що інші потоки не читають і не записують змінні одночасно (тому що це UB-перегони даних). Вам потрібно сказати, що він не може оптимізувати доступ. Ось тут і приходить відповідь Шеффа. Я не буду намагатися його повторювати.

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

Оптимізований код надає два кодові шляхи, які будуть результатом введення функції з постійним значенням bool; або він працює циклом нескінченно, або цикл ніколи не виконується.

на -O0компілятор (як очікувалося) НЕ оптимізує тіло циклу і порівняння відстань:

func():
  push rbp
  mov rbp, rsp
  mov QWORD PTR [rbp-8], 0
.L148:
  movzx eax, BYTE PTR finished[rip]
  test al, al
  jne .L147
  add QWORD PTR [rbp-8], 1
  jmp .L148
.L147:
  mov rax, QWORD PTR [rbp-8]
  pop rbp
  ret

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

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


3
C ++ 11 робить нитки та модель пам'яті, що усвідомлює потоки, частиною самої мови. Це означає, що компілятори не можуть вигадувати записи навіть до atomicзмінних у коді, які не записують ці змінні. наприклад, if (cond) foo=1;не можна перетворити на ASM, як це foo = cond ? 1 : foo;тому, що це load + store (не атомна RMW) може наступити на запис з іншого потоку. Компілятори вже уникали подібного, тому що вони хотіли бути корисними для написання багатопотокових програм, але C ++ 11 зробив офіційним повідомлення про те, що компілятори не повинні порушувати код, де пишуть 2 теми, a[1]іa[2]
Peter Cordes

2
Але так, крім того , що завищення про те , як укладачі не знають потоки взагалі , ваша відповідь є правильним. UB-перегони даних - це те, що дозволяє підняти навантаження неатомних змінних, включаючи глобальні, та інші агресивні оптимізації, які ми хочемо для однопотокового коду. Програмування MCU - оптимізація C ++ O2 перервана під час циклу на електроніці.SE - моя версія цього пояснення.
Пітер Кордес

1
@PeterCordes: Однією з переваг Java, що використовує GC, є те, що пам'ять для об’єктів не буде перероблена без втручається глобального бар'єру пам’яті між старим та новим використанням, а це означає, що будь-яке ядро, що вивчає об’єкт, завжди побачить певне значення, яке він має відбувся через деякий час після публікації посилання. Хоча глобальні бар'єри пам’яті можуть бути дуже дорогими, якщо вони використовуються часто, вони можуть значно зменшити потребу в бар’єрах пам’яті в інших місцях, навіть коли вони використовуються досить щадно.
Supercat

1
Так, я знав, що це ви намагаєтесь сказати, але я не думаю, що ваше формулювання на 100% означає це. Сказавши оптимізатор "повністю ігнорує їх". не зовсім правильно: загальновідомо, що істинне ігнорування нарізки при оптимізації може включати такі речі, як завантаження / зміна байтів у магазині слів / слів, що на практиці спричинило помилки, коли доступ однієї нитки до char чи bitfield крокує на a написати до суміжного члена структури. Див. Lwn.net/Articles/478657 для повної історії, і як тільки модель пам'яті C11 / C ++ 11 робить таку оптимізацію незаконною, а не просто небажаною на практиці.
Пітер Кордес

1
Ні, це добре .. Дякую @PeterCordes. Я вдячний за покращення.
Baldrickk

5

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

Ось приклад:

class ST {
public:
    int func()
    {
        size_t i = 0;
        while (!finished)
            ++i;
        return i;
    }
    void setFinished(bool val)
    {
        finished = val;
    }
private:
    std::atomic<bool> finished = false;
};

int main()
{
    ST st;
    auto result=std::async(std::launch::async, &ST::func, std::ref(st));
    std::this_thread::sleep_for(std::chrono::seconds(1));
    st.setFinished(true);
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Наживо на wandbox


1
Можна також оголосити finishedяк staticфункціональний блок. Він все одно буде ініціалізований лише один раз, і якщо він буде ініціалізований до постійної, це не потребує блокування.
Девіслор

Доступ до finishedможе також використовувати дешевший std::memory_order_relaxedвантаж і магазини; не потрібно замовляти wrt. інші змінні в будь-якому потоці. Я не впевнений, що пропозиція @ Девіслора staticмає сенс; якби у вас було кілька потоків підрахунку спину, вам не потрібно було б зупиняти їх одним і тим же прапором. Ви хочете написати ініціалізацію finishedтаким чином, що компілюється лише для ініціалізації, а не для зберігання атомів. (Як і у випадку з finished = false;ініціалізатором за замовчуванням синтаксис C ++ 17. Godbolt.org/z/EjoKgq ).
Пітер Кордес

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