"псевдоатомные" операции в С++

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

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

while(b) doSomething();

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

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

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

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

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

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

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

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

Ответ 1

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Ответ 2

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

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

volatile int ready;       
int message[100];      

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

В -O2 и выше, последние версии GCC и Intel C/С++ (не знают о VС++) сначала сделают хранилище 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, являются обертки релиза/потребления, найденные в очереди Single-Consumer Single-Consumer Дмитрия Вьюкова размещенной здесь

// 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 проверяем блокирование потоков темы 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 взаимосвязь между потоками понятна.

Ответ 3

Может быть, эта ветка древняя, но в стандарте С++ 11 есть библиотека потоков, а также обширная атомная библиотека для атомных операций. Цель специально предназначена для поддержки concurrency и предотвращения сбоев данных. Соответствующий заголовок атомный

Ответ 4

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

Ответ 5

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

Ответ 6

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

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

Ответ 7

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

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

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

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

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