2017-01-23 5 views
105

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

процессоров Intel имеет некоторую удивительную внутреннюю «сбить» логику, чтобы сохранить совместимость с очень ранними процессорами x86, используемыми в SMP-системах (например, с двумя процессорами Pentium Pro). Множество условий отказа, которые мы изучаем, практически никогда не происходит на машинах x86. Так сказать, ядро ​​идет, чтобы написать 'u' обратно в память. ЦП действительно будет делать удивительные вещи, например, заметить, что строка памяти для 'u' не находится в кеше ЦП, и она перезапустит операцию увеличения. Вот почему переход от x86 к другим архитектурам может быть впечатляющим открытием! –

+1

Возможно, все еще слишком быстро. Вам нужно добавить код, чтобы гарантировать, что поток выйдет до того, как он сделает что-нибудь, чтобы гарантировать, что другие потоки будут запущены до его завершения. –

+1

Как уже упоминалось в другом месте, код потока настолько короток, что он может быть выполнен до того, как будет запущен следующий поток. Как насчет 10 потоков, которые помещают u ++ в цикл 100 циклов. И короткая задержка внутри до начала цикла (или глобальный флаг «go», чтобы запустить их все одновременно). – RufusVS

ответ

267

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

+51

Это действительно изменило результат ожидаемым образом. – mafu

+49

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

+0

Недавно у нас была эта проблема с C#. Код почти никогда не выходил из строя, но недавнее добавление вызова API между ними завело достаточно задержки, чтобы он постоянно менялся. –

59

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

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

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

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

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

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

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

+0

Но только при компиляции с '-O3'. ОП мог рассказать нам, как он скомпилировал его. –

+0

@AlBundy Он также создает ту же самую сборку для '-O2' и' -O1', только '-O0' заставляет ее производить совершенно разные (и иронически более запутывающие) выходные данные. – Vality

+41

Важно помнить, что «запуск по назначению» является допустимым результатом неопределенного поведения. – Mark

7

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

20

Я думаю, что это не так, если вы спали до или после u++. Скорее, операция u++ переводит на код, который является - по сравнению с накладными расходами на нерестовые потоки, которые вызывают foo - очень быстро выполняется так, что вряд ли его перехватят.Тем не менее, если вы "продлить" операцию u++, то условие гонки будет гораздо больше шансов:

void foo() 
{ 
    unsigned i = u; 
    for (int s=0;s<10000;s++); 
    u = i+1; 
} 

результат: 694


BTW: Я также попытался

if (u % 2) { 
    u += 2; 
} else { 
    u -= 1; 
} 

и это давало мне чаще всего 1997, но иногда 1995.

+1

Я ожидал бы от любого смутного разумного компилятора, что вся функция будет оптимизирована для того же самого. Я удивлен, что это не так. Спасибо за интересный результат. – Vality

+0

Это точно. Многие тысячи инструкций должны выполняться до того, как следующий поток начнет выполнение крошечной функции. Когда вы выполняете время выполнения функции ближе к накладным расходам потоков, вы видите влияние состояния гонки. –

+0

@Vality: Я также ожидал, что он удалит ложную петлю под оптимизацией O3. Это не так? – user21820

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

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

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

  4. 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; 
    } 
    
+0

Просто примечание. Флаг '-g' никоим образом не" устраняет ошибки ". Флаг '-g' как для компиляторов GNU, так и для Clang просто добавляет отладочные символы в скомпилированный двоичный файл.Это позволяет запускать диагностические инструменты, такие как GDB и Memcheck, в ваших программах с некоторым удобочитаемым человеком. Например, когда Memcheck запускается через программу с утечкой памяти, он не укажет номер строки, если программа не была построена с использованием флага '-g'. –

+0

Конечно, ошибки, скрывающиеся от отладчика, чаще всего являются вопросом оптимизации компилятора; Я должен был попробовать и сказал: «используя« -O2'_instead_ of -g ». Но это говорит о том, что если вы никогда не испытывали радости от поиска ошибки, которая проявлялась бы _ только когда компилируется _without_ '-g', считайте себя удачливым. Это происходит, с некоторыми из самых скверных тонких псевдонимов. Я видел это, хотя и не совсем недавно, и я мог подумать, что это была причуда старого проприетарного компилятора, поэтому я, конечно же, буду вам верить в современные версии GNU и Clang. – dgould

+0

'-g' не останавливает вас от использования оптимизаций. например 'gcc -O3 -g' делает то же самое, что и' gcc -O3', но с метаданными отладки. Однако gdb будет «оптимизирован», если вы попытаетесь напечатать некоторые переменные. '-g' может, возможно, изменить относительные местоположения некоторых вещей в памяти, если какой-либо материал, который он добавляет, является частью раздела' .text'. Это определенно занимает пространство в объектном файле, но я думаю, что после связывания все это заканчивается на одном конце текстового сегмента (не раздел) или вообще не является частью сегмента. Возможно, это повлияет на то, где вещи отображаются для динамических библиотек. –

Смежные вопросы