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

Я написал следующие простые многопоточные программы:

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.

3 ответа

Решение

Два потока, обращающийся неатомарный, неохраняемый переменной 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

Живая демонстрация на coliru


Кто-то может подумать: "Это bool- наверное, немного. Как это может быть неатомарно? (Я делал это, когда сам начинал с многопоточности.)

Но учтите, что отсутствие разрывов - не единственное, что std::atomicдает тебе. Он также делает четко определенным одновременный доступ для чтения и записи из нескольких потоков, не позволяя компилятору предполагать, что при повторном чтении переменной всегда будет отображаться одно и то же значение.

Создание bool неохраняемые, неатомарные могут вызвать дополнительные проблемы:

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

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


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

Ответ Шеффа описывает, как исправить ваш код. Я подумал, что добавлю немного информации о том, что на самом деле происходит в этом случае.

Я скомпилировал ваш код на 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 при п ВЗ е каче на этикетке.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это на единицу не так, как должно быть.

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

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

Вот пример:

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

Другие вопросы по тегам