«псевдоатомные» операции в C ++ - PullRequest
12 голосов
/ 02 мая 2010

Итак, я знаю, что в C ++ нет ничего атомарного. Но я пытаюсь выяснить, есть ли какие-то «псевдоатомные» предположения, которые я могу сделать. Причина в том, что я хочу избегать использования мьютексов в некоторых простых ситуациях, когда мне нужны только очень слабые гарантии.

1) Предположим, у меня есть глобально определенный летучий бул b, который изначально я установил истину. Затем я запускаю поток, который выполняет цикл

while(b) doSomething();

Тем временем в другом потоке я выполняю b = true.

Можно ли предположить, что первый поток продолжит выполнение? Другими словами, если b начинается как true, и первый поток проверяет значение b одновременно с тем, как второй поток назначает b = true, могу ли я предположить, что первый поток будет считать значение b как true? Или возможно, что в некоторой промежуточной точке присваивания b = true значение b может быть прочитано как ложное?

2) Теперь предположим, что b изначально ложно. Затем первый поток выполняет

bool b1=b;
bool b2=b;
if(b1 && !b2) bad();

в то время как второй поток выполняет b = true. Могу ли я предположить, что bad () никогда не вызывается?

3) Как насчет int или других встроенных типов: предположим, у меня есть volatile int i, которое изначально (скажем) 7, а затем я назначаю i = 7. Могу ли я предположить, что в любое время во время этой операции из любого потока значение i будет равно 7?

4) У меня volatile int i = 7, и затем я выполняю i ++ из некоторого потока, а все остальные потоки только читают значение i. Могу ли я предположить, что у меня никогда не будет никакого значения ни в одном потоке, кроме 7 или 8?

5) У меня volatile int i, из одного потока я выполняю i = 7, а из другого - i = 8. После этого я гарантированно буду 7 или 8 (или любые два значения, которые я выбрал для назначения)?

Ответы [ 7 ]

14 голосов
/ 02 мая 2010

В стандарте C ++ нет потоков, и Потоки не могут быть реализованы в виде библиотеки .

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

Тем не менее, в поточных реализациях, которые я использовал:

(1) да, вы можете предположить, что не относящиеся к делу значения не записываются в переменные. В противном случае вся модель памяти уходит в окно. Но будьте осторожны, когда вы говорите, что «другая нить» никогда не устанавливает b в false, это означает, что где угодно, когда угодно. Если это произойдет, то эта запись может быть переупорядочена для выполнения во время вашего цикла.

(2) нет, компилятор может переупорядочить присваивания b1 и b2, поэтому возможно, что b1 в конечном итоге окажется true, а b2 false. В таком простом случае я не знаю, почему он будет переупорядочен, но в более сложных случаях могут быть очень веские причины.

[Edit: упс, к тому времени, как я добрался до ответа (2), я забыл, что b было изменчивым. Чтения из энергозависимой переменной не будут переупорядочены, извините, так что да, в типичной реализации многопоточности (если есть такая вещь), вы можете предположить, что вы не получите b1 true и b2 false.]

(3) так же, как 1. volatile в общем случае не имеет ничего общего с многопоточностью. Однако в некоторых реализациях (Windows) это довольно увлекательно и может фактически подразумевать барьеры памяти.

(4) в архитектуре, в которой запись int является атомарной, да, хотя volatile не имеет к этому никакого отношения. Смотри также ...

(5) внимательно проверьте документы. Вероятно, да, и снова volatile не имеет значения, потому что почти на всех архитектурах int записи являются атомарными. Но если int запись не является атомарной, то нет (и нет для предыдущего вопроса), даже если она нестабильна, вы в принципе могли бы получить другое значение. Однако, учитывая эти значения 7 и 8, мы говорим о довольно странной архитектуре для байта, содержащего соответствующие биты, которые должны быть записаны в два этапа, но с другими значениями вы могли бы более правдоподобно получить частичную запись.

В качестве более правдоподобного примера, предположим, что по какой-то странной причине у вас есть 16-битное int на платформе, где только 8-битные записи являются атомарными. Странно, но законно, и, поскольку int должно быть не менее 16 бит, вы можете увидеть, как это могло произойти. Предположим далее, что ваше начальное значение равно 255. Тогда приращение может быть юридически реализовано как:

  • прочитать старое значение
  • приращение в регистре
  • запишите старший байт результата
  • записать младший значащий байт результата.

Поток только для чтения, который прерывал инкрементный поток между третьим и четвертым шагами этого, мог видеть значение 511. Если записи в другом порядке, он мог видеть 0.

Несовместимое значение может остаться навсегда, если один поток пишет 255, другой поток одновременно записывает 256, и записи чередуются. Невозможно на многих архитектурах, но чтобы знать, что этого не произойдет, нужно знать хотя бы кое-что об архитектуре. Ничто в стандарте C ++ не запрещает этого, потому что стандарт C ++ говорит о том, что выполнение прерывается сигналом, но в остальном не имеет понятия прерывания выполнения другой частью программы, а также понятия одновременного выполнения. Вот почему потоки - это не просто еще одна библиотека - добавление потоков в корне меняет модель выполнения C ++. Требуется, чтобы реализация работала по-другому, поскольку в конечном итоге вы обнаружите, например, используете ли вы потоки в gcc и забыли указать -pthreads.

То же самое может произойти на платформе, где выровненные int записи являются атомарными, но не выровненные int записи разрешены и не атомарны.Например, IIRC на x86, невыровненные записи int не гарантируются атомарными, если они пересекают границу строки кэша.Компиляторы x86 не будут неправильно выравнивать объявленную переменную int, по этой и другим причинам.Но если вы играете в игры со структурной упаковкой, вы, вероятно, можете привести пример.

Итак: практически любая реализация даст вам необходимые гарантии, но может сделать это довольно сложным способом.

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

6 голосов
/ 13 мая 2010

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

Рассмотрим пример, взятый из этого поста :

volatile int ready;       
int message[100];      

void foo(int i) 
{      
    message[i/10] = 42;      
    ready = 1;      
}

На -O2 и выше, последние версии GCC и Intel C / C ++ (не знают о VC ++) вначале сохранят хранилище до ready, поэтому его можно перекрывать вычислениями i/10 (volatile вас не спасет!):

    leaq    _message(%rip), %rax
    movl    $1, _ready(%rip)      ; <-- whoa Nelly!
    movq    %rsp, %rbp
    sarl    $2, %edx
    subl    %edi, %edx
    movslq  %edx,%rdx
    movl    $42, (%rax,%rdx,4)

Это не ошибка, это оптимизатор, использующий конвейерную обработку ЦП. Если другой поток ожидает ready, прежде чем получить доступ к содержимому message, тогда у вас неприятная и неясная раса.

Используйте барьеры компилятора, чтобы удостовериться, что ваше намерение выполнено. Примером, который также использует относительно сильное упорядочение x86, являются обертки выпуска / потребления, найденные в очереди единственного производителя Дмитрия Вьюкова , размещенной здесь :

// load with 'consume' (data-dependent) memory ordering 
// NOTE: x86 specific, other platforms may need additional memory barriers
template<typename T> 
T load_consume(T const* addr) 
{  
  T v = *const_cast<T const volatile*>(addr); 
  __asm__ __volatile__ ("" ::: "memory"); // compiler barrier 
  return v; 
} 

// store with 'release' memory ordering 
// NOTE: x86 specific, other platforms may need additional memory barriers
template<typename T> 
void store_release(T* addr, T v) 
{ 
  __asm__ __volatile__ ("" ::: "memory"); // compiler barrier 
  *const_cast<T volatile*>(addr) = v; 
} 

Я полагаю, что если вы собираетесь заняться параллельным доступом к памяти, используйте библиотеку, которая позаботится об этих деталях. Пока мы все ждем n2145 и std::atomic, проверьте Thread Building Blocks 'tbb::atomic или предстоящие boost::atomic.

Помимо правильности, эти библиотеки могут упростить ваш код и уточнить ваши намерения:

// thread 1
std::atomic<int> foo;  // or tbb::atomic, boost::atomic, etc
foo.store(1, std::memory_order_release);

// thread 2
int tmp = foo.load(std::memory_order_acquire);

При использовании явного упорядочения памяти, взаимосвязь foo между потоками ясна.

2 голосов
/ 10 июля 2013

Возможно, этот поток древний, но стандарт C ++ 11 ДОЛЖЕН иметь библиотеку потоков, а также обширную атомарную библиотеку для атомарных операций. Цель специально для поддержки параллелизма и предотвращения гонок данных. Соответствующий заголовок: атомный

1 голос
/ 02 мая 2010

Обычно это очень, очень плохая идея, чтобы зависеть от этого, так как вы можете получить плохие вещи и только одну архитектуру. Наилучшим решением будет использование гарантированного атомарного API, например, Windows Interlocked api.

0 голосов
/ 03 сентября 2013

Мой ответ будет разочаровывающим: Нет, Нет, Нет, Нет и Нет.

1-4) Компилятору разрешено делать ВСЕ, что угодно, с переменной, в которую он записывает. Он может хранить временные значения в нем до тех пор, пока не выполнит то, что будет делать то же самое, что и поток, выполняющийся в вакууме. НИЧЕГО действительно

5) Нет, нет гарантии. Если переменная не является атомарной, и вы записываете в нее в одном потоке, а читаете или записываете в нее в другом, это случай гонки. Спецификация объявляет такие случаи гонки неопределенным поведением, и абсолютно все идет. При этом вам будет сложно найти компилятор, который не дает вам 7 или 8, но для компилятора законно давать вам что-то еще.

Я всегда ссылаюсь на это очень комичное объяснение случаев расы.

http://software.intel.com/en-us/blogs/2013/01/06/benign-data-races-what-could-possibly-go-wrong

0 голосов
/ 02 мая 2010

Volatile в C ++ не играет той же роли, что и в Java. Все случаи - неопределенное поведение, как сказал Стив. В некоторых случаях может быть хорошо для компилятора, для конкретной архитектуры процессора и для многопоточной системы, но переключение флагов оптимизации может привести к тому, что ваша программа будет вести себя по-другому, поскольку компиляторы C ++ 03 не знают о потоках.

C ++ 0x определяет правила, которые избегают условий гонки и операций, которые помогают вам справиться с этим, но, насколько известно, еще нет компилятора, который реализует все части стандарта, относящиеся к этому предмету.

0 голосов
/ 02 мая 2010

Если ваша реализация C ++ предоставляет библиотеку атомарных операций, указанную n2145 или какой-либо ее вариант, вы, вероятно, можете положиться на нее. В противном случае вы не можете вообще полагаться на «что-нибудь» об атомарности на уровне языка, поскольку многозадачность любого рода (и, следовательно, атомарность, которая имеет дело с многозадачностью) не определена существующим стандартом C ++.

...