Рекурсивная блокировка (мьютекс) против нерекурсивной блокировки (мьютекс)
POSIX позволяет мьютексам быть рекурсивными. Это означает, что один и тот же поток может блокировать один и тот же мьютекс дважды и не блокируется. Конечно, он также должен разблокировать его дважды, иначе никакой другой поток не сможет получить мьютекс. Не все системы, поддерживающие pthreads, также поддерживают рекурсивные мьютексы, но если они хотят соответствовать POSIX, они должны это делать.
Другие API (более высокоуровневые API) также обычно предлагают мьютексы, часто называемые Locks. Некоторые системы / языки (например, Cocoa Objective-C) предлагают как рекурсивные, так и нерекурсивные мьютексы. Некоторые языки также предлагают только один или другой. Например, в Java мьютексы всегда рекурсивны (один и тот же поток может дважды "синхронизироваться" с одним и тем же объектом). В зависимости от того, какую другую функциональность потоков они предлагают, отсутствие рекурсивных мьютексов может быть проблемой, так как их можно легко написать самостоятельно (я уже сам реализовал рекурсивные мьютексы на основе более простых операций мьютекс / условие).
Чего я не понимаю: для чего нужны нерекурсивные мьютексы? Зачем мне нужен тупик потока, если он дважды блокирует один и тот же мьютекс? Даже языки высокого уровня, которые могут этого избежать (например, тестирование, если это приведет к взаимоблокировке, и выдача исключения, если это произойдет), обычно этого не делают. Вместо этого они позволят зацепить нить.
Разве это только в тех случаях, когда я случайно блокирую его дважды и разблокирую только один раз, а в случае рекурсивного мьютекса будет сложнее найти проблему, поэтому вместо этого я немедленно захожу в тупик, чтобы увидеть, где появляется неправильная блокировка? Но нельзя ли сделать то же самое с возвращением счетчика блокировки при разблокировке и в ситуации, когда я уверен, что снял последнюю блокировку, а счетчик не равен нулю, я могу выдать исключение или зарегистрировать проблему? Или есть какой-то другой, более полезный вариант использования нерекурсивных мьютексов, который я не вижу? Или это просто производительность, так как нерекурсивный мьютекс может быть немного быстрее рекурсивного? Тем не менее, я проверил это, и разница действительно не так велика.
6 ответов
Разница между рекурсивным и нерекурсивным мьютексом связана с владением. В случае рекурсивного мьютекса ядро должно отслеживать поток, который фактически получил мьютекс в первый раз, чтобы он мог обнаружить разницу между рекурсией и другим потоком, который должен вместо этого блокироваться. Как указал другой ответ, возникает вопрос о дополнительных издержках, связанных как с памятью для хранения этого контекста, так и с циклами, необходимыми для его поддержки.
Однако здесь есть и другие соображения.
Поскольку рекурсивный мьютекс имеет чувство собственности, поток, который захватывает мьютекс, должен быть тем же потоком, который освобождает мьютекс. В случае нерекурсивных мьютексов нет смысла владения, и любой поток обычно может освободить мьютекс независимо от того, какой поток первоначально захватил мьютекс. Во многих случаях этот тип "мьютекса" на самом деле является скорее действием семафора, когда вы не обязательно используете мьютекс в качестве устройства исключения, но используете его в качестве устройства синхронизации или сигнализации между двумя или более потоками.
Другим свойством, которое имеет чувство принадлежности к мьютексу, является способность поддерживать приоритетное наследование. Поскольку ядро может отслеживать поток, владеющий мьютексом, а также идентификатором всех блокировщиков, в системе с приоритетным потоком становится возможным повысить приоритет потока, которому в настоящее время принадлежит мьютекс, до приоритета потока с наивысшим приоритетом. который в настоящее время блокирует мьютекс. Такое наследование предотвращает проблему инверсии приоритетов, которая может возникнуть в таких случаях. (Обратите внимание, что не все системы поддерживают наследование приоритетов на таких мьютексах, но это еще одна особенность, которая становится возможной через понятие принадлежности).
Если вы ссылаетесь на классическое ядро ОСРВ VxWorks, они определяют три механизма:
- mutex - поддерживает рекурсию и, необязательно, приоритетное наследование
- бинарный семафор - нет рекурсии, нет наследования, простое исключение, берущий и дающий не должен быть одним потоком, доступен широковещательный выпуск
- счетный семафор - без рекурсии или наследования, действует как согласованный счетчик ресурсов из любого желаемого начального счетчика, блок только для потоков, где чистый счетчик против ресурса равен нулю.
Опять же, это несколько варьируется в зависимости от платформы - особенно то, что они называют этими вещами, но это должно отражать концепции и различные механизмы в игре.
Ответ не в эффективности. Невозвратные взаимные исключения приводят к лучшему коду.
Пример: A::foo() получает блокировку. Затем он вызывает B::bar(). Это работало нормально, когда ты это написал. Но через некоторое время кто-то меняет B:: bar () на вызов A::baz(), который также получает блокировку.
Ну, если у вас нет рекурсивных мьютексов, это тупики. Если они у вас есть, они запускаются, но могут сломаться. A::foo (), возможно, оставил объект в несовместимом состоянии перед вызовом bar (), предполагая, что baz () не может быть запущен, потому что он также получает мьютекс. Но это, вероятно, не должно бежать! Человек, который написал A:: foo (), предположил, что никто не может вызвать A:: baz () одновременно - вот и вся причина того, что оба этих метода получили блокировку.
Правильная ментальная модель для использования мьютексов: мьютекс защищает инвариант. Когда мьютекс удерживается, инвариант может измениться, но перед освобождением мьютекса инвариант восстанавливается. Повторно входящие блокировки опасны, потому что во второй раз, когда вы приобретаете блокировку, вы не можете быть уверены, что инвариант больше верен.
Если вы довольны повторяющимися блокировками, это только потому, что вам раньше не приходилось отлаживать такую проблему. Между прочим, в Java есть не реентерабельные блокировки в java.util.concurrent.locks.
Как написал сам Дэйв Бутенхоф:
"Самая большая из всех больших проблем с рекурсивными мьютексами заключается в том, что они побуждают вас полностью потерять отслеживание вашей схемы блокировки и области действия. Это смертельно. Зло. Это" пожиратель потоков ". Вы удерживаете блокировки в течение максимально короткого возможного времени. Период. Всегда. Если вы звоните что-то с блокировкой, удерживаемой просто потому, что вы не знаете, удерживается ли она, или потому, что вы не знаете, нужен ли вызываемый мьютекс, то вы держите его слишком долго. нацелив ружье на ваше приложение и нажимая на курок. Вы, вероятно, начали использовать потоки для получения параллелизма; но вы просто ПРЕДОТВРАТИЛИ параллелизм ".
Правильная ментальная модель для использования мьютексов: мьютекс защищает инвариант.
Почему вы уверены, что это действительно правильная ментальная модель для использования мьютексов? Я думаю, что правильная модель защищает данные, но не инварианты.
Проблема защиты инвариантов присутствует даже в однопоточных приложениях и не имеет ничего общего с многопоточностью и мьютексами.
Кроме того, если вам нужно защитить инварианты, вы все равно можете использовать двоичный семафор, который никогда не бывает рекурсивным.
Единственный хороший вариант использования мьютекса рекурсии - это когда объект содержит несколько методов. Когда какой-либо из методов изменяет содержимое объекта, и, следовательно, должен заблокировать объект, прежде чем состояние снова станет согласованным.
Если методы используют другие методы (например: addNewArray() вызывает addNewPoint() и завершается с помощью recheckBounds()), но любая из этих функций сама по себе должна блокировать мьютекс, то рекурсивный мьютекс является беспроигрышным.
Для любого другого случая (решение просто плохого кодирования, использование его даже в разных объектах) явно неправильно!
Одной из основных причин, по которым рекурсивные мьютексы полезны, является необходимость многократного обращения к методам одним и тем же потоком. Например, скажем, если блокировка мьютекса защищает банковский счет для снятия, то если с этим снятием также связана комиссия, то должен использоваться тот же мьютекс.
IMHO, большинство аргументов против рекурсивных блокировок (которые я использую 99,9% времени, например, за 20 лет параллельного программирования) смешивают вопрос о том, хороши они или плохи, с другими проблемами проектирования программного обеспечения, которые совершенно не связаны. Назовем одну из них, проблему "обратного вызова", которая исчерпывающе разработана без какой-либо точки зрения, связанной с многопоточностью, например, в книге " Программное обеспечение компонентов - за пределами объектно-ориентированного программирования".
Как только у вас есть некоторая инверсия управления (например, запуск событий), вы сталкиваетесь с проблемами повторного входа. Независимо от того, задействованы ли мьютексы и потоки.
class EvilFoo {
std::vector<std::string> data;
std::vector<std::function<void(EvilFoo&)> > changedEventHandlers;
public:
size_t registerChangedHandler( std::function<void(EvilFoo&)> handler) { // ...
}
void unregisterChangedHandler(size_t handlerId) { // ...
}
void fireChangedEvent() {
// bad bad, even evil idea!
for( auto& handler : changedEventHandlers ) {
handler(*this);
}
}
void AddItem(const std::string& item) {
data.push_back(item);
fireChangedEvent();
}
};
Теперь с кодом, подобным приведенному выше, вы получаете все случаи ошибок, которые обычно называются в контексте рекурсивных блокировок - только без каких-либо из них. Обработчик событий может отменить регистрацию после вызова, что приведет к ошибке в наивно написанном
fireChangedEvent()
. Или он может вызывать другие функции-члены
EvilFoo
которые вызывают всевозможные проблемы. Основная причина - повторный вход. Хуже всего то, что это может быть даже не очень очевидным, поскольку это может происходить по целой цепочке событий, запускающих события, и в конечном итоге мы возвращаемся к нашему EvilFoo (нелокальному).
Итак, основная проблема - это повторный вход, а не рекурсивная блокировка. Теперь, если вы чувствовали себя в большей безопасности, используя нерекурсивную блокировку, как могла бы проявиться такая ошибка? В тупике всякий раз, когда происходит неожиданный повторный вход. А с рекурсивной блокировкой? Точно так же это проявится в коде без каких-либо блокировок.
Так что злая часть
EvilFoo
события и то, как они реализованы, а не рекурсивная блокировка.
fireChangedEvent()
необходимо сначала создать копию
changedEventHandlers
и используйте это для итерации, для начала.
Еще один часто обсуждаемый аспект - это определение того, что блокировка должна делать в первую очередь:
- Защитите фрагмент кода от повторного входа
- Защитите ресурс от одновременного использования (несколькими потоками).
Как я занимаюсь параллельным программированием, у меня есть ментальная модель последнего (защита ресурса). Это основная причина, по которой я хорошо разбираюсь в рекурсивных блокировках. Если какая-то функция (член) требует блокировки ресурса, она блокируется. Если он вызывает другую функцию (член) во время выполнения своих действий и эта функция также требует блокировки - она блокируется. И мне не нужен "альтернативный подход", потому что подсчет ссылок рекурсивной блокировки такой же, как если бы каждая функция написала что-то вроде:
void EvilFoo::bar() {
auto_lock lock(this); // this->lock_holder = this->lock_if_not_already_locked_by_same_thread())
// do what we gotta do
// ~auto_lock() { if (lock_holder) unlock() }
}
И как только в игру вступят события или подобные конструкции (посетители?!), я не надеюсь, что все последующие проблемы дизайна будут решены какой-нибудь нерекурсивной блокировкой.
Для чего нужны нерекурсивные мьютексы?
Они абсолютно хороши, когда вам нужно убедиться, что мьютекс разблокирован, прежде чем что-то делать. Это потому чтоpthread_mutex_unlock
может гарантировать, что мьютекс разблокирован, только если он не рекурсивен.
pthread_mutex_t g_mutex;
void foo()
{
pthread_mutex_lock(&g_mutex);
// Do something.
pthread_mutex_unlock(&g_mutex);
bar();
}
Если g_mutex
не рекурсивен, код выше гарантированно вызовет bar()
с разблокированным мьютексом.
Таким образом исключена возможность возникновения тупика в случае bar()
оказывается неизвестной внешней функцией, которая вполне может сделать что-то, что может привести к тому, что другой поток попытается получить тот же мьютекс. Такие сценарии не редкость в приложениях, построенных на пулах потоков, и в распределенных приложениях, где межпроцессный вызов может порождать новый поток, даже если клиентский программист даже не осознает этого. Во всех таких сценариях лучше всего вызывать указанные внешние функции только после снятия блокировки.
Если g_mutex
был рекурсивным, просто не было бы возможности убедиться, что он разблокирован, прежде чем делать вызов.