Почему код, изменяющий общую переменную через потоки, по-видимому НЕ страдает от состояния гонки?

Я использую 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;
}

Ответ 1

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

Ответ 2

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

В частности, в условиях гонки X86 и AMD64 в некоторых случаях редко возникают проблемы, так как многие из инструкций являются атомарными, а гарантии согласованности очень велики. Эти гарантии несколько снижаются в многопроцессорных системах, где префикс блокировки необходим для того, чтобы многие инструкции были атомарными.

Если на вашем компьютере приращение является атомарным, это, скорее всего, будет работать корректно, хотя в соответствии со стандартом языка это undefined Поведение.

В частности, я ожидаю, что в этом случае код может быть скомпилирован в атомную команду Fetch and Add (ADD или XADD в сборке X86), которая действительно является атомой в одиночном но на многопроцессорных системах это не гарантируется атомарным, и для этого потребуется блокировка. Если вы работаете в многопроцессорной системе, появится окно, в котором потоки могут вмешиваться и создавать неверные результаты.

В частности, я скомпилировал ваш код для сборки с помощью https://godbolt.org/ и foo() для компиляции:

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

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

Ответ 3

Я думаю, что это не так, если вы спали до или после 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.

Ответ 4

Он страдает от состояния гонки. Поместите usleep(1000); до u++; в foo, и каждый раз я вижу разные выходные данные (< 1000).

Ответ 5

  • Вероятный ответ на вопрос о том, почему состояние гонки не проявилось для вас, хотя оно существует, заключается в том, что foo() работает так быстро, по сравнению с тем временем, которое требуется для начала потока, чтобы каждый поток заканчивался до следующего может даже начаться. Но...

  • Даже с вашей исходной версией результат зависит от системы: я пробовал его по своему (на четырехъядерном) Macbook, и за десять прогонов я получил 1000 три раза, 999 шесть раз и 998 раз, Таким образом, гонка несколько редка, но отчетливо присутствует.

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

  • Re. предложение о добавлении сна - это помогает, но (а) фиксированное время сна оставляет потоки, все еще искаженные временем начала (при условии разрешения по таймеру), и (б) случайный сон распространяет их, когда мы хотим, чтобы сблизить их. Вместо этого я бы закодировал их, чтобы дождаться сигнала начала, поэтому я могу создать их все, прежде чем позволить им работать. С этой версией (с или без '-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;
    }