Я слышал, что i ++ не потокобезопасен, ++ я потокобезопасен? - PullRequest
88 голосов
/ 25 марта 2009

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

Тем не менее, мне интересно о ++ я. Насколько я могу судить, это сводится к одной инструкции сборки, такой как 'add r1, r1, 1', и, поскольку это всего лишь одна команда, она будет бесперебойной при переключении контекста.

Кто-нибудь может уточнить? Я предполагаю, что используется платформа x86.

Ответы [ 15 ]

154 голосов
/ 25 марта 2009

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

Вполне возможно, что ++i может скомпилироваться в произвольную последовательность, такую ​​как:

load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory

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

lock         ; disable task switching (interrupts)
load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory
unlock       ; enable task switching (interrupts)

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

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

Такие вещи, как Java synchronized и pthread_mutex_lock() (доступные для C / C ++ в некоторых операционных системах) - это то, что вам нужно изучить (a) .


(a) Этот вопрос задавался до того, как были завершены стандарты C11 и C ++ 11. В этих итерациях появилась поддержка потоков в спецификациях языка, включая атомарные типы данных (хотя они и потоки в целом необязательны, по крайней мере в C).

42 голосов
/ 25 марта 2009

Вы не можете сделать общее утверждение относительно ++ i или i ++. Зачем? Попробуйте увеличить 64-разрядное целое число в 32-разрядной системе. Если базовый компьютер не имеет инструкции «загрузить, увеличить, сохранить», то для увеличения этого значения потребуется несколько инструкций, любая из которых может быть прервана переключателем контекста потока.

Кроме того, ++i не всегда «добавляет единицу к значению». В языке, подобном C, увеличение указателя фактически добавляет размер объекта, на который указывает. То есть, если i является указателем на 32-байтовую структуру, ++i добавляет 32 байта. В то время как почти все платформы имеют атомарную инструкцию «увеличить значение по адресу в памяти», не все имеют атомарную инструкцию «добавить произвольное значение в значение по адресу в памяти».

14 голосов
/ 25 марта 2009

Они оба небезопасны.

CPU не может выполнять математику напрямую с памятью. Он делает это косвенно, загружая значение из памяти и делая математику с регистрами ЦП.

я * ++ 1005 *

register int a1, a2;

a1 = *(&i) ; // One cpu instruction: LOAD from memory location identified by i;
a2 = a1;
a1 += 1; 
*(&i) = a1; 
return a2; // 4 cpu instructions

++ я

register int a1;

a1 = *(&i) ; 
a1 += 1; 
*(&i) = a1; 
return a1; // 3 cpu instructions

В обоих случаях существует состояние гонки, которое приводит к непредсказуемому значению i.

Например, предположим, что есть два параллельных потока ++ i, каждый из которых использует регистр a1, b1 соответственно. И с переключением контекста выполняется следующим образом:

register int a1, b1;

a1 = *(&i);
a1 += 1;
b1 = *(&i);
b1 += 1;
*(&i) = a1;
*(&i) = b1;

В результате я не становлюсь i + 2, он становится i + 1, что неверно.

Чтобы исправить это, современные ЦП предоставляют какие-то команды LOCK, UNLOCK cpu в течение интервала, когда переключение контекста отключено.

В Win32 используйте InterlockedIncrement (), чтобы сделать i ++ для обеспечения безопасности потоков. Это намного быстрее, чем полагаться на мьютекс.

11 голосов
/ 25 марта 2009

Если вы разделяете даже int между потоками в многоядерной среде, вам нужны соответствующие барьеры памяти. Это может означать использование взаимосвязанных инструкций (см., Например, InterlockedIncrement в win32) или использование языка (или компилятора), который дает определенные поточно-ориентированные гарантии. С переупорядочением команд на уровне ЦП и кешированием и другими проблемами, если у вас нет таких гарантий, не думайте, что что-то общее для потоков безопасно.

Редактировать: Одна вещь, которую вы можете предположить в большинстве архитектур, - это то, что если вы имеете дело с правильно выровненными одиночными словами, вы не получите единственное слово, содержащее комбинацию двух значений, которые были объединены. Если две записи происходят поверх друг друга, одна победит, а другая будет отброшена. Если вы осторожны, вы можете воспользоваться этим и увидеть, что ++ i или i ++ являются поточно-ориентированными в ситуации с одним записывающим / множественным читателем.

8 голосов
/ 25 марта 2009

Если вы хотите атомарный прирост в C ++, вы можете использовать библиотеки C ++ 0x (тип данных std::atomic) или что-то вроде TBB.

Было время, когда руководящие принципы кодирования GNU говорили, что обновление типов данных, которые вписываются в одно слово, "обычно безопасно", но этот совет неправильный для машин SMP, неправильный для некоторых архитектур, и неправильный при использовании оптимизирующий компилятор.


Чтобы уточнить комментарий «обновление типа одного слова»:

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

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

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

ПРИМЕЧАНИЕ В моем первоначальном ответе также говорилось, что 64-битная архитектура Intel была повреждена при работе с 64-битными данными. Это неправда, поэтому я отредактировал ответ, но моя редакция утверждала, что чипы PowerPC были сломаны. Это верно при чтении непосредственных значений (т. Е. Констант) в регистры (см. Два раздела под названием «Загрузка указателей» в листинге 2 и листинге 4). Но есть инструкция для загрузки данных из памяти за один цикл (lmw), поэтому я удалил эту часть своего ответа.

4 голосов
/ 25 марта 2009

В x86 / Windows в C / C ++ не следует предполагать, что она поточно-ориентированная. Вы должны использовать InterlockedIncrement () и InterlockedDecrement () , если вам требуются атомарные операции.

3 голосов
/ 04 августа 2010

Даже если она сводится к одной инструкции сборки, увеличивая значение непосредственно в памяти, она все равно не является поточно-ориентированной.

При увеличении значения в памяти аппаратное обеспечение выполняет операцию «чтение-изменение-запись»: оно считывает значение из памяти, увеличивает его и записывает обратно в память. Аппаратное обеспечение x86 не может увеличиваться непосредственно в памяти; ОЗУ (и кэши) могут только читать и хранить значения, но не изменять их.

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

Есть способ избежать этой проблемы; Процессоры x86 (и большинство многоядерных процессоров, которые вы найдете) способны обнаруживать такого рода конфликты на аппаратном уровне и упорядочивать его, так что вся последовательность чтения-изменения-записи выглядит атомарной. Однако, поскольку это очень дорого, это делается только по запросу кода, на x86 обычно через префикс LOCK. Другие архитектуры могут делать это другими способами с похожими результатами; например, связанное с нагрузкой / условное хранение и атомарное сравнение-и-замена (последние процессоры x86 также имеют этот последний).

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

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

3 голосов
/ 25 марта 2009

Если ваш язык программирования ничего не говорит о потоках, но работает на многопоточной платформе, как может любая языковая конструкция быть потоко-безопасной?

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

Существуют библиотеки, которые абстрагируют специфику платформы, и будущий стандарт C ++ адаптировал свою модель памяти для работы с потоками (и, следовательно, может гарантировать безопасность потоков).

2 голосов
/ 25 марта 2009

Никогда не предполагайте, что приращение будет компилироваться до атомарной операции. Используйте InterlockedIncrement или другие подобные функции на вашей целевой платформе.

Edit: я только что посмотрел этот конкретный вопрос, и инкремент на X86 является атомарным на однопроцессорных системах, но не на многопроцессорных системах. Использование префикса блокировки может сделать его атомарным, но гораздо проще переносить его, просто используя InterlockedIncrement.

1 голос
/ 08 февраля 2013

Согласно этому уроку сборки на x86, вы можете атомарно добавить регистр в область памяти , поэтому потенциально ваш код может атомарно выполнить '++ i' ou 'i ++' , Но, как сказано в другом посте, C ansi не применяет атомарность к операциям '++', поэтому вы не можете быть уверены в том, что сгенерирует ваш компилятор.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...