Достаточны ли функции блокировки мьютекса без энергозависимости?
Мы с коллегой пишем программное обеспечение для различных платформ, работающих на x86, x64, Itanium, PowerPC и других 10-летних серверных процессорах.
Мы только что обсудили, достаточно ли самих мьютекс-функций, таких как pthread_mutex_lock() ... pthread_mutex_unlock(), или защищенная переменная должна быть энергозависимой.
int foo::bar()
{
//...
//code which may or may not access _protected.
pthread_mutex_lock(m);
int ret = _protected;
pthread_mutex_unlock(m);
return ret;
}
Моя задача - кэширование. Может ли компилятор поместить копию _protected в стек или в регистр и использовать это устаревшее значение в присваивании? Если нет, что мешает этому случиться? Варианты этой модели уязвимы?
Я предполагаю, что компилятор на самом деле не понимает, что pthread_mutex_lock () является специальной функцией, поэтому мы просто защищены точками последовательности?
Большое спасибо
Обновление: Хорошо, я вижу тенденцию с ответами, объясняющими, почему изменчиво плохо. Я уважаю эти ответы, но статьи на эту тему легко найти в Интернете. Что я не могу найти в Интернете, и причина, по которой я задаю этот вопрос, - это то, как я защищен без волатильности. Если приведенный выше код верен, как он неуязвим для кеширования?
7 ответов
Если приведенный выше код верен, как он неуязвим для кеширования?
До C++0x это не так. И это не указано в C. Итак, это действительно зависит от компилятора. В общем, если компилятор не гарантирует, что он будет соблюдать ограничения порядка доступа к памяти для функций или операций, которые включают в себя несколько потоков, вы не сможете писать многопоточный безопасный код с этим компилятором. См., Что Hans J Boehm не может быть реализован как библиотека.
Что касается абстракций, которые ваш компилятор должен поддерживать для потокаобезопасного кода, то запись в Википедии о барьерах памяти является довольно хорошей отправной точкой.
(Что касается того, почему люди предложили volatile
, некоторые компиляторы лечат volatile
в качестве барьера памяти для компилятора. Это определенно не стандартно.)
Ваша библиотека потоков должна включать соответствующие барьеры ЦП и компилятора при блокировке и разблокировке мьютекса. Для GCC, а memory
clobber в операторе asm действует как барьер компилятора.
На самом деле, есть две вещи, которые защищают ваш код от кеширования (компилятора):
- Вы вызываете не чистую внешнюю функцию (
pthread_mutex_*()
), что означает, что компилятор не знает, что эта функция не изменяет ваши глобальные переменные, поэтому он должен перезагрузить их. - Как я сказал,
pthread_mutex_*()
включает в себя барьер компилятора, например: на glibc/x86pthread_mutex_lock()
в конечном итоге вызывает макросlll_lock()
, который имеетmemory
clobber, заставляя компилятор перезагружать переменные.
Самый простой ответ volatile
не нужен для многопоточности вообще.
Длинный ответ заключается в том, что точки последовательности, такие как критические секции, зависят от платформы, как и любое используемое вами решение для многопоточности, поэтому большая часть безопасности потоков также зависит от платформы.
C++0x имеет концепцию потоков и безопасности потоков, но текущий стандарт не имеет и, следовательно, volatile
иногда ошибочно идентифицируется как нечто, предотвращающее переупорядочение операций и доступ к памяти для многопоточного программирования, когда оно никогда не предназначалось и не может быть надежно использовано таким образом.
Единственная вещь volatile
должен использоваться в C++, чтобы разрешить доступ к отображенным в память устройствам, разрешить использование переменных между setjmp
а также longjmp
и разрешить использование sig_atomic_t
переменные в обработчиках сигналов. Само ключевое слово не делает переменную атомарной.
Хорошие новости в C++0x у нас будет конструкция STL std::atomic
который может быть использован для гарантии атомарных операций и потоковых конструкций для переменных. Пока ваш выбранный компилятор не поддержит его, вам, возможно, придется обратиться к библиотеке наддува или использовать некоторый ассемблерный код для создания ваших собственных объектов для предоставления атомарных переменных.
PS Большая путаница вызвана тем, что Java и.NET фактически применяют многопоточную семантику с ключевым словом. volatile
C++, однако, следует примеру C, где это не так.
Ключевое слово volatile является подсказкой компилятору, что переменная может измениться вне логики программы, такой как аппаратный регистр с отображением в памяти, который может измениться как часть подпрограммы обработки прерывания. Это препятствует тому, чтобы компилятор предполагал, что кэшированное значение всегда корректно, и обычно вынуждает чтение памяти для получения значения. Такое использование предшествует многопоточности примерно на пару десятилетий. Я видел, что он также используется с переменными, управляемыми сигналами, но я не уверен, что использование было правильным.
Переменные, защищаемые мьютексами, гарантированно будут правильными при чтении или записи разными потоками. API потоков необходим для обеспечения согласованности таких представлений переменных. Этот доступ является частью вашей логики программы, и ключевое слово volatile здесь не имеет значения.
За исключением простейшего алгоритма спин-блокировки, код мьютекса довольно сложен: хороший оптимизированный код блокировки / разблокировки мьютекса содержит такой код, который трудно понять даже отличному программисту. Он использует специальные инструкции сравнения и установки, управляет не только состоянием разблокировки / блокировки, но также и очередью ожидания, дополнительно использует системные вызовы для перехода в состояние ожидания (для блокировки) или пробуждения других потоков (для разблокировки).
Среднестатистический компилятор не может декодировать и "понимать" весь этот сложный код (опять же, за исключением простой блокировки спина), так что даже для компилятора, не знающего, что такое мьютекс и как он связан Что касается синхронизации, то на практике компилятор не может оптимизировать что-либо вокруг такого кода.
Это если код был "встроенным", или доступным для анализа с целью межмодульной оптимизации, или если доступна глобальная оптимизация.
Я предполагаю, что компилятор на самом деле не понимает, что pthread_mutex_lock() - это специальная функция, так что мы просто защищены точками последовательности?
Компилятор не знает, что он делает, поэтому не пытается оптимизировать его.
Как это "особенное"? Он непрозрачный и считается таковым. Среди непрозрачных функций он не особенный.
Нет семантической разницы с произвольной непрозрачной функцией, которая может получить доступ к любому другому объекту.
Меня беспокоит кеширование. Может ли компилятор разместить копию _protected в стеке или регистре и использовать это устаревшее значение в назначении?
Да, в коде, который действует с объектами прозрачно и напрямую, используя имя переменной или указатели таким образом, чтобы компилятор мог следовать. Не в коде, который может использовать произвольные указатели для косвенного использования переменных.
Так что да между вызовами непрозрачных функций. Не поперек.
А также для переменных, которые могут использоваться только в функции, по имени: для локальных переменных, у которых нет ни адреса, ни ссылки на них (так что компилятор не может отслеживать все дальнейшие использования). Их действительно можно "кэшировать" по произвольным вызовам, включая блокировку / разблокировку.
Если нет, то что этому мешает? Уязвимы ли вариации этого паттерна?
Непрозрачность функций. Без встраивания. Код сборки. Системные вызовы. Сложность кода. Все, что заставляет компиляторы выходить из строя и думать, что "это сложная штука, просто обращается к ней".
Позиция компилятора по умолчанию всегда такая: "давайте выполнять тупо, я все равно не понимаю, что делается", а не "я оптимизирую это / давайте перепишем алгоритм, который мне лучше знаком". Большая часть кода не оптимизирована сложным нелокальным образом.
Теперь предположим абсолютное худшее (с нашей точки зрения, что компилятор должен отказаться, что является абсолютным лучшим с точки зрения алгоритма оптимизации):
- функция является "встроенной" (= доступна для встраивания) (либо включается глобальная оптимизация, либо все функции морально "встроены");
- в этом примитиве синхронизации (блокировка или разблокировка) не требуется никакого барьера памяти (как в однопроцессорной системе с разделением времени и в многопроцессорной системе с жестким упорядочением), поэтому он не содержит такой вещи;
- не используются специальные инструкции (например, сравнение и установка) (например, для блокировки спина операция разблокировки представляет собой простую запись);
- нет никакой системы вызова, чтобы приостановить или будить темы (не требуется в спин - блокировок);
тогда у нас может возникнуть проблема, поскольку компилятор может оптимизировать вызов функции. Это легко исправляется путем вставки барьера компилятора, такого как пустой оператор asm с "clobber" для других доступных переменных. Это означает, что компилятор просто предполагает, что все, что может быть доступно вызываемой функции, "затерто".
или должна ли защищенная переменная быть изменчивой.
Вы можете сделать его нестабильным по той же причине, по которой вы делаете что-то нестабильным: чтобы иметь возможность получить доступ к переменной в отладчике, чтобы переменная с плавающей запятой не имела неправильный тип данных во время выполнения и т. Д.
Сделать его изменчивым на самом деле даже не решит проблему, описанную выше, поскольку volatile - это, по сути, операция памяти в абстрактной машине, которая имеет семантику операции ввода-вывода и, как таковая, упорядочивается только в отношении
- реальный ввод-вывод, такой как iostream
- системные вызовы
- другие нестабильные операции
- asm memory clobbers (но тогда побочный эффект памяти не переупорядочивается вокруг них)
- вызовы внешних функций (как они могли бы сделать одно из указанных выше)
Энергозависимость не упорядочена по отношению к побочным эффектам энергонезависимой памяти. Это делает volatile практически бесполезным (бесполезным для практического использования) для написания поточно-безопасного кода даже в самом конкретном случае, когда volatile априори помогло бы, когда не требуется никакого ограждения памяти: при программировании потоковых примитивов в системе разделения времени на одиночный процессор. (Это может быть одним из наименее понятных аспектов C или C++.)
Таким образом, в то время как volatile действительно предотвращает "кеширование", volatile даже не предотвращает переупорядочение компилятором операции блокировки / разблокировки, если все общие переменные не являются изменчивыми.
Примитивы блокировки/синхронизации гарантируют, что данные не кэшируются в регистрах/кеше ЦП, что означает, что данные распространяются в память. Если два потока обращаются/изменяют данные с блокировками in, гарантируется, что данные считываются из памяти и записываются в память. В этом случае нам не нужна volatile.
Но в случае, когда у вас есть код с двойными проверками, компилятор может оптимизировать код и удалить избыточный код, чтобы предотвратить это, нам нужен volatile.
Пример: см. пример одноэлементного шаблона
https://en.m.wikipedia.org/wiki/Singleton_pattern#Lazy_initialization
Почему кто-то пишет такой код? Ответ: Отсутствие блокировки повышает производительность.
PS: Это мой первый пост о переполнении стека.
Нет, если объект, который вы блокируете, является изменчивым, например: если значение, которое он представляет, зависит от чего-то постороннего для программы (состояние оборудования). НЕ ДОЛЖЕН использоваться для обозначения любого поведения, являющегося результатом выполнения программы. Если это на самом деле
volatile
что я лично сделал бы, так это заблокировал значение указателя/адреса вместо базового объекта. например:
volatile int i = 0;
// ... Later in a thread
// ... Code that may not access anything without a lock
std::uintptr_t ptr_to_lock = &i;
some_lock(ptr_to_lock);
// use i
release_some_lock(ptr_to_lock);
Обратите внимание, что это работает только в том случае, если ВЕСЬ код, когда-либо использующий объект в потоке, блокирует один и тот же адрес. Так что помните об этом при использовании потоков с некоторой переменной, которая является частью API.