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


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

5 ответов:

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

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

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

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

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

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

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

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

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

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

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

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

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

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