Атомность 32-битного чтения на многоядерном процессоре
(Примечание: я добавил теги к этому вопросу, исходя из того, что, по моему мнению, будут люди, которые могут помочь, поэтому, пожалуйста, не кричите:))
В моем 64-битном проекте VS 2017 у меня есть значение длиной 32 бита m_lClosed
, Когда я хочу обновить это, я использую один из Interlocked
семейство функций.
Рассмотрим этот код, выполняющийся в потоке #1
LONG lRet = InterlockedCompareExchange(&m_lClosed, 1, 0); // Set m_lClosed to 1 provided it's currently 0
Теперь рассмотрим этот код, выполняющийся в потоке № 2:
if (m_lClosed) // Do something
Я понимаю, что на одном процессоре это не будет проблемой, потому что обновление является атомарным, а чтение - атомарным (см. MSDN), поэтому приоритет потока не может оставить переменную в частично обновленном состоянии. Но на многоядерном процессоре у нас действительно могут быть оба этих куска кода, выполняемые параллельно, если каждый поток находится на отдельном процессоре. В этом примере я не думаю, что это было бы проблемой, но все же кажется неправильным тестировать что-то, что находится в процессе возможного обновления.
Эта веб-страница говорит мне, что атомарность на нескольких процессорах достигается с помощью LOCK
инструкция по сборке, предотвращающая доступ других процессоров к этой памяти. Это звучит как то, что мне нужно, но язык ассемблера, сгенерированный для теста if выше, просто
cmp dword ptr [l],0
... нет LOCK
инструкция в поле зрения.
Как в таком случае мы должны обеспечить атомарность чтения?
РЕДАКТИРОВАТЬ 24/4/18
Во-первых, спасибо за интерес, который вызвал этот вопрос. Я показываю ниже фактического кода; Я специально старался сосредоточиться на атомарности всего этого, но было бы лучше, если бы я показал все это с первой минуты.
Во-вторых, проект, в котором живет реальный код, является проектом VS2005; следовательно нет доступа к атомам C++11. Вот почему я не добавил тег C++11 к этому вопросу. Я использую VS2017 с "пустым" проектом, чтобы избавить меня от необходимости создавать огромный VS2005 каждый раз, когда я делаю изменения во время обучения. Плюс, это лучшая IDE.
Правильно, поэтому настоящий код находится на сервере, управляемом IOCP, и вся эта атомарность заключается в обработке закрытого сокета:
class CConnection
{
//...
DWORD PostWSARecv()
{
if (!m_lClosed)
return ::WSARecv(...);
else
return WSAESHUTDOWN;
}
bool SetClosed()
{
LONG lRet = InterlockedCompareExchange(&m_lClosed, 1, 0); // Set m_lClosed to 1 provided it's currently 0
// If the swap was carried out, the return value is the old value of m_lClosed, which should be 0.
return lRet == 0;
}
SOCKET m_sock;
LONG m_lClosed;
};
Звонящий позвонит SetClosed()
; если он вернет истину, то вызовет ::closesocket()
и т.д. Пожалуйста, не спрашивайте, почему это так, просто так:)
Рассмотрим, что произойдет, если один поток закроет сокет, а другой попытается опубликовать WSARecv()
, Вы можете подумать, что WSARecv()
потерпит неудачу (сокет все-таки закрыт!); однако, что если новое соединение будет установлено с тем же дескриптором сокета, что и тот, который мы только что закрыли - тогда мы будем публиковать WSARecv()
что будет успешно, но это было бы фатально для моей логики программы, так как мы сейчас связываем совершенно другую связь с этим объектом CConnection. Следовательно, у меня есть if (!m_lClosed)
тестовое задание. Вы можете утверждать, что я не должен обрабатывать одно и то же соединение в нескольких потоках, но это не главное в этом вопросе:)
Вот почему мне нужно проверить m_lClosed
прежде чем я сделаю WSARecv()
вызов.
Теперь ясно, я только устанавливаю m_lClosed
до 1, поэтому разорванное чтение / запись на самом деле не является проблемой, но это принцип, который меня беспокоит. Что если я установлю m_lClosed
на 2147483647, а затем проверить на 2147483647? В этом случае разрыв чтения / записи будет более проблематичным.
4 ответа
Хорошо, так как оказывается, что это действительно не нужно; Этот ответ подробно объясняет, почему нам не нужно использовать какие-либо взаимосвязанные операции для простого чтения / записи (но мы делаем для чтения-изменения-записи).
It really depends on your compiler and the CPU you are running on.
x86 CPUs will atomically read 32-bit values without the LOCK
prefix if the memory address is properly aligned. However, you most likely will need some sort of memory barrier to control the CPUs out-of-order execution if the variable is used as a lock/count of some other related data. Data that is not aligned might not be read atomically, especially if the value straddles a page boundary.
If you are not hand coding assembly you also need to worry about the compilers reordering optimizations.
Any variable marked as volatile
will have ordering constraints in the compiler (and possibly the generated machine code) when compiling with Visual C++:
Встроенные функции компилятора _ReadBarrier, _WriteBarrier и _ReadWriteBarrier предотвращают только переупорядочивание компилятора. В Visual Studio 2003 упорядочены изменчивые и изменчивые ссылки; компилятор не будет переупорядочивать изменчивый доступ к переменным. В Visual Studio 2005 компилятор также использует семантику получения для операций чтения с изменчивыми переменными и семантику выпуска для операций записи с изменчивыми переменными (если поддерживается процессором).
Улучшения, характерные для нестабильных ключевых слов Microsoft:
Когда используется параметр компилятора / volatile: ms - по умолчанию, когда используются архитектуры, отличные от ARM, - компилятор генерирует дополнительный код для поддержания порядка среди ссылок на изменяемые объекты в дополнение к сохранению порядка ссылок на другие глобальные объекты. Особенно:
Запись в энергозависимый объект (также известный как энергозависимая запись) имеет семантику Release; то есть ссылка на глобальный или статический объект, который происходит перед записью в энергозависимый объект в последовательности команд, будет происходить до этой энергозависимой записи в скомпилированном двоичном файле.
Чтение летучего объекта (также известного как volatile read) имеет семантику Acquire; то есть ссылка на глобальный или статический объект, который происходит после чтения энергозависимой памяти в последовательности команд, будет происходить после этого энергозависимого чтения в скомпилированном двоичном файле.
Это позволяет использовать энергозависимые объекты для блокировок и выпусков памяти в многопоточных приложениях.
Для архитектур, отличных от ARM, если не указана опция компилятора / volatile, компилятор работает так, как если бы / volatile было указано: ms; поэтому для архитектур, отличных от ARM, мы настоятельно рекомендуем вам указать / volatile: iso и использовать явные примитивы синхронизации и встроенные функции компилятора, когда вы имеете дело с памятью, которая совместно используется потоками.
Microsoft предоставляет встроенные функции компилятора для большинства функций Interlocked*, и они будут компилироваться в нечто вроде LOCK XADD ...
вместо вызова функции.
До недавнего времени в C/C++ не было поддержки атомарных операций или потоков в целом, но это изменилось в C11/C++11, где была добавлена атомарная поддержка. С использованием <atomic>
header и его типы / функции / классы переносят ответственность за выравнивание и переупорядочение на компилятор, так что вам не нужно об этом беспокоиться. Вы все еще должны сделать выбор относительно барьеров памяти, и это определяет машинный код, сгенерированный компилятором. С расслабленным порядком памяти load
атомная операция, скорее всего, в конечном итоге будет простой MOV
инструкция по x86. Более строгий порядок памяти может добавить забор и, возможно, LOCK
префикс, если компилятор определяет, что это требуется целевой платформе.
В C++11 несинхронизированный доступ к неатомарному объекту (например, m_lClosed
) неопределенное поведение.
Стандарт предоставляет все средства, необходимые для правильного написания; вам не нужны непереносимые функции, такие как InterlockedCompareExchange
, Вместо этого просто определите вашу переменную как atomic
:
std::atomic<bool> m_lClosed{false};
// Writer thread...
bool expected = false;
m_lClosed.compare_exhange_strong(expected, true);
// Reader...
if (m_lClosed.load()) { /* ... */ }
Этого более чем достаточно (это вызывает последовательную согласованность, которая может быть дорогой). В некоторых случаях может быть возможно сгенерировать немного более быстрый код, ослабив порядок памяти для атомарных операций, но я бы не стал беспокоиться об этом.
Как я уже писал здесь, этот вопрос никогда не касался защиты критической части кода, он был просто о предотвращении разорванных операций чтения / записи. user3386109 разместил здесь комментарий, который я в итоге использовал, но отказался публиковать его в качестве ответа здесь. Таким образом, я предоставляю решение, которое в конечном итоге использовал для полноты этого вопроса; возможно, это поможет кому-то в будущем.
Следующее показывает атомную установку и тестирование m_lClosed
:
long m_lClosed = 0;
Тема 1
// Set flag to closed
if (InterlockedCompareExchange(&m_lClosed, 1, 0) == 0)
cout << "Closed OK!\n";
Тема 2
Этот код заменяет if (!m_lClosed)
if (InterlockedCompareExchange(&m_lClosed, 0, 0) == 0)
cout << "Not closed!";