Многопоточная программа зависла в оптимизированном режиме, но нормально работает в -O0

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.

Sz Ppeter
источник
Комментарии не для расширенного обсуждения; этот разговор был перенесен в чат .
Самуэль Лью

Ответы:

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 в один и вывести нагрузку из цикла.
  • Переменная может быть кэширована для ядра процессора. (В реальной жизни, процессоры имеют когерентные кэша . Это не проблема, но стандарт C ++ ослаблен достаточно , чтобы покрыть гипотетические C ++ реализации на некогерентной общую памяти , где atomic<bool>с memory_order_relaxedмагазином / нагрузка будет работать, но там , где volatileне будет. Использование энергозависимым для этого будет UB, даже если он работает на реальных реализациях C ++.)

Чтобы этого не случилось, компилятору должно быть явно сказано не делать этого.


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

Шефф
источник
4
Я взглянул на него func()и подумал: «Я мог бы оптимизировать это». Оптимизатор не заботится о потоках, он обнаружит бесконечный цикл и с радостью превратит его в «while (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). Фактически, вы можете заменить все атомы на «простую» схему value + mutex; это все равно будет работать и будет медленнее. atomic<T>разрешено использовать внутренний мьютекс; только atomic_flagгарантировано без блокировки.
Erlkoenig
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 при п OT е каче к этикетке , .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, которое на единицу меньше, чем должно быть.

Более сложная система со структурами данных может привести к повреждению данных или неправильному выполнению.

Baldrickk
источник
3
C ++ 11 делает потоки и модель памяти с поддержкой потоков частью самого языка. Это означает, что компиляторы не могут изобрести записи даже в не atomicпеременные в коде, который не записывает эти переменные. Например, он if (cond) foo=1;не может быть преобразован в asm, это похоже на foo = cond ? 1 : foo;то, что загрузка + хранилище (не атомарный RMW) может перейти на запись из другого потока. Компиляторы уже избегали подобных вещей, потому что они хотели быть полезными для написания многопоточных программ, но C ++ 11 официально a[1]a[2]
Питер Кордес
2
Но да, кроме того , что завышение о том , как составители не знают потоки вообще , ваш ответ является правильным. Data-race UB - это то, что позволяет поднимать множество неатомарных переменных, включая глобальные, и другие агрессивные оптимизации, которые мы хотим для однопоточного кода. Программирование MCU - оптимизация C ++ O2 обрывается, когда цикл на электронике. SE - моя версия этого объяснения.
Питер Кордес
1
@PeterCordes: Одним из преимуществ использования GC в Java является то, что память для объектов не будет рециркулироваться без промежуточного глобального барьера памяти между старым и новым использованием, что означает, что любое ядро, которое проверяет объект, всегда будет видеть какое-то значение, которое оно имеет состоится через некоторое время после того, как ссылка была впервые опубликована. Хотя глобальные барьеры памяти могут быть очень дорогими, если их часто использовать, они могут значительно снизить потребность в барьерах памяти в других местах, даже если они используются экономно.
суперкат
1
Да, я знал, что это то, что вы пытались сказать, но я не думаю, что ваша формулировка на 100% означает это. Сказать оптимизатор "полностью игнорирует их". не совсем верно: хорошо известно, что истинное игнорирование потоков при оптимизации может включать такие вещи, как загрузка слова / изменение байта в хранилище слов / слов, что на практике приводило к ошибкам, когда доступ одного потока к символу или битовому полю переходил на написать в соседний член структуры. См. 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нагрузку и магазины; Там нет необходимости заказа в отношении. другие переменные в любом потоке. Я не уверен, что предложение @ Дэвислора staticимеет смысл, хотя; если бы у вас было несколько потоков с подсчетом вращений, вам не нужно останавливать их все с одним и тем же флагом. Вы действительно хотите написать инициализацию finishedтак, чтобы компилировать это просто для инициализации, а не для атомарного хранилища. (Как вы делаете с finished = false;синтаксисом инициализатора C ++ 17 по умолчанию. Godbolt.org/z/EjoKgq ).
Питер Кордес
@PeterCordes Помещение флага в объект позволяет, как вы говорите, иметь более одного значения для разных пулов потоков. Однако оригинальный дизайн имел единый флаг для всех потоков.
Дэвислор