Я сталкивался с C ++ 03 некоторым кодом, который принимает эту форму:
struct Foo {
int a;
int b;
CRITICAL_SECTION cs;
}
// DoFoo::Foo foo_;
void DoFoo::Foolish()
{
if( foo_.a == 4 )
{
PerformSomeTask();
EnterCriticalSection(&foo_.cs);
foo_.b = 7;
LeaveCriticalSection(&foo_.cs);
}
}
Читает ли foo_.a
нужно быть защищенным? например.:
void DoFoo::Foolish()
{
EnterCriticalSection(&foo_.cs);
int a = foo_.a;
LeaveCriticalSection(&foo_.cs);
if( a == 4 )
{
PerformSomeTask();
EnterCriticalSection(&foo_.cs);
foo_.b = 7;
LeaveCriticalSection(&foo_.cs);
}
}
Если так, то почему?
Пожалуйста, предположите, что целые 32-битные выровнены. Платформа ARM.
Технически да, но нет на многих платформах. Во-первых, давайте предположим, что int
32 бита (что довольно распространено, но не почти универсально).
Возможно, что два слова (16-битные части) 32-битные int
будет прочитано или написано отдельно. В некоторых системах они будут прочитаны отдельно, если int
не выровнен правильно
Представьте себе систему, в которой вы можете выполнять только 32-битные выровненные 32-битные операции чтения и записи (и 16-битные выровненные 16-битные операции чтения и записи) и int
что пересекает такую границу. Первоначально int
равен нулю (т.е. 0x00000000
)
Один поток пишет 0xBAADF00D
к int
другой читает «одновременно».
Пишущий поток сначала пишет 0xBAAD
к высокому слову int
, Затем читатель поток читает все int
(и высокий и низкий) получение 0xBAAD0000
— это состояние, что int
никогда не был введен нарочно!
Автор темы затем пишет низкое слово 0xF00D
,
Как уже отмечалось, на некоторых платформах все 32-битные операции чтения / записи являются атомарными, так что это не проблема. Однако есть и другие проблемы.
Большая часть кода блокировки / разблокировки содержит инструкции для компилятора, чтобы предотвратить изменение порядка блокировки. Без этого предотвращения переупорядочения компилятор может свободно переупорядочивать объекты, пока он ведет себя «как если бы» в однопоточном контексте, он работал бы таким образом. Так что, если вы читаете a
затем b
в коде компилятор может читать b
прежде чем он читает a
до тех пор, пока он не видит возможности в потоке b
должны быть изменены в этом интервале.
Поэтому, возможно, код, который вы читаете, использует эти блокировки, чтобы убедиться, что чтение переменной происходит в порядке, записанном в коде.
Другие вопросы поднимаются в комментариях ниже, но я не чувствую себя компетентным для их решения: проблемы с кешем и видимость.
Смотря на этот кажется, что у arm довольно расслабленная модель памяти, поэтому вам нужна форма барьера памяти, чтобы гарантировать, что записи в одном потоке видны, когда вы ожидаете их в другом потоке. Поэтому то, что вы делаете, или использование std :: atomic, вероятно, необходимо на вашей платформе. Если вы не примете это во внимание, вы можете увидеть обновления не по порядку в разных темах, которые могут нарушить ваш пример.
Я думаю, что вы можете использовать C ++ 11, чтобы гарантировать, что целочисленные чтения являются атомарными, используя (например) std::atomic<int>
,
Стандарт C ++ говорит, что существует гонка данных, если один поток записывает в переменную одновременно с тем, как другой поток читает из этой переменной, или если два потока записывают в одну и ту же переменную одновременно. Далее говорится, что гонка данных приводит к неопределенному поведению. Итак, формально вы должен синхронизировать эти чтения и записи.
Существует три отдельных проблемы, когда один поток читает данные, которые были записаны другим потоком. Во-первых, есть разрыв: если запись требует более одного цикла шины, переключение потока может произойти в середине операции, и другой поток может увидеть половинное значение записи; есть аналогичная проблема, если для чтения требуется более одного цикла шины. Во-вторых, есть наглядность: каждый процессор имеет свою собственную локальную копию данных, над которыми он работал в последнее время, и запись в кэш одного процессора не обязательно обновляет кэш другого процессора. В-третьих, есть оптимизация компилятора, которая переупорядочивает чтение и запись таким образом, чтобы это было нормально в рамках одного потока, но сломало бы многопоточный код. Потокобезопасный код имеет дело с все трое проблемы. Это работа примитивов синхронизации: мьютексы, условные переменные и атомика.
Хотя целочисленная операция чтения / записи действительно, скорее всего, будет атомарной, оптимизация компилятора и кэш процессора все равно будут вызывать проблемы, если вы не сделаете это правильно.
Для объяснения — компилятор обычно предполагает, что код является однопоточным, и делает много оптимизаций, основанных на этом. Например, это может изменить порядок инструкций. Или, если он видит, что переменная только записывается и никогда не читается, он может полностью ее оптимизировать.
Процессор также кеширует это целое число, поэтому, если один поток записывает его, другой может не увидеть его намного позже.
Есть две вещи, которые вы можете сделать. Одним из них является завершение в критическом разделе, как в исходном коде. Другой — пометить переменную как Видимо, это неправильно.volatile
, Это будет сигнализировать компилятору о том, что эта переменная будет доступна нескольким потокам и отключит ряд оптимизаций, а также разместит специальные инструкции синхронизации кеша (также называемые «барьерами памяти») вокруг доступа к переменной (или, как я понимаю).
Добавлено: Также, как отмечено в другом ответе, Windows имеет Interlocked
API, которые можно использовать, чтобы избежать этих проблем дляvolatile
переменные.