(Примечание. Большая часть этого избытка с комментарием Массивная загрузка ЦП с использованием std:: lock (С++ 11), но я думаю, что эта тема заслуживает собственный вопрос и ответы.)
Недавно я столкнулся с некоторым примером кода С++ 11, который выглядел примерно так:
std::unique_lock<std::mutex> lock1(from_acct.mutex, std::defer_lock);
std::unique_lock<std::mutex> lock2(to_acct.mutex, std::defer_lock);
std::lock(lock1, lock2); // avoid deadlock
transfer_money(from_acct, to_acct, amount);
Вау, подумал я, std::lock
звучит интересно. Интересно, что говорит стандарт?
С++ 11 раздел 30.4.3 [thread.lock.algorithm], абзацы (4) и (5):
блокировка void void (L1 &, L2 &, L3 &...);
4 Требуется: каждый тип параметра шаблона должен соответствовать блокируемому требования, [Примечание: шаблон класса
unique_lock
соответствует этим требования при соответствующем инстанцировании. - конечная нота]5 Эффекты: все аргументы блокируются через последовательность вызовов
lock()
,try_lock()
илиunlock()
для каждого аргумента. Последовательность вызовов должна не приводят к тупиковой ситуации, но в противном случае не указывается. [Примечание: A необходимо использовать алгоритм избежания взаимоблокировки, такой как попытка "отменить", но алгоритм specificc не указан, чтобы избежать чрезмерного ограничения Реализации. - конец примечания] Если вызовlock()
илиtry_lock()
бросает исключение,unlock()
должно быть вызвано для любого аргумента, который был заблокирован вызовомlock()
илиtry_lock()
.
Рассмотрим следующий пример. Назовите его "Пример 1":
Thread 1 Thread 2
std::lock(lock1, lock2); std::lock(lock2, lock1);
Может ли этот тупик?
Простое чтение стандарта говорит "нет" . Большой! Возможно, компилятор может заказать мои блокировки для меня, что было бы довольно аккуратно.
Теперь попробуйте пример 2:
Thread 1 Thread 2
std::lock(lock1, lock2, lock3, lock4); std::lock(lock3, lock4);
std::lock(lock1, lock2);
Может ли этот тупик?
Здесь снова обычное чтение стандарта говорит "нет" . О, о. Единственный способ сделать это - это какой-то цикл возврата и повтора. Подробнее об этом ниже.
Наконец, пример 3:
Thread 1 Thread 2
std::lock(lock1,lock2); std::lock(lock3,lock4);
std::lock(lock3,lock4); std::lock(lock1,lock2);
Может ли этот тупик?
Еще раз, обычное чтение стандарта говорит "нет" . (Если "последовательность вызовов на lock()
" в одном из этих вызовов не является "результатом тупика", что именно?) Однако я уверен, что это невозможно реализовать, поэтому я полагаю, что это не то, что они имели в виду.
Это, по-видимому, одна из худших вещей, которые я когда-либо видел в стандарте С++. Я предполагаю, что это началось как интересная идея: пусть компилятор назначит упорядочение блокировки. Но как только комитет пережевывал его, результат либо не реализуется, либо требует петли повтора. И да, это плохая идея.
Вы можете утверждать, что иногда полезно использовать "отключение и повтор". Это правда, но только тогда, когда вы не знаете, какие блокировки вы пытаетесь захватить спереди. Например, если идентификатор второго замка зависит от данных, защищенных первым (скажем, потому что вы просматриваете некоторую иерархию), тогда вам, возможно, придется немного отжимать захват. Но в этом случае вы не можете использовать этот гаджет, потому что вы не знаете все блокировки спереди. С другой стороны, если вы знаете, какие блокировки вы хотите перед собой, тогда вы (почти) всегда хотите просто наложить порядок, а не на цикл.
Кроме того, обратите внимание, что пример 1 может быть заблокирован, если реализация просто захватывает блокировки по порядку, отступает и повторяет попытку.
Короче говоря, этот гаджет в лучшем случае кажется мне бесполезным. Просто плохая идея вокруг.
ОК, вопросы. (1) Являются ли какие-либо из моих утверждений или интерпретаций неправильными? (2) Если нет, о чём они думали? (3) Должны ли мы все согласиться с тем, что "наилучшая практика" заключается в том, чтобы полностью избегать std::lock
?
[Обновление]
Некоторые ответы говорят, что я неправильно интерпретирую стандарт, затем продолжаю интерпретировать его так же, как и я, а затем путаю спецификацию с реализацией.
Итак, просто чтобы быть ясным:
В моем чтении стандарта, пример 1 и пример 2 не могут быть взаимоблокировками. Пример 3 может, но только потому, что исключение тупика в этом случае невозможно.
Весь мой вопрос состоит в том, что для избежания тупика для примера 2 требуется цикл возврата и повтора, и такие циклы являются крайне плохой практикой. (Да, какой-то статический анализ на этом тривиальном примере может сделать это предотвратимым, но не в общем случае.) Также обратите внимание, что GCC реализует эту вещь как цикл занятости.
[Обновить 2]
Я думаю, что большая часть разъединения здесь является основным различием в философии.
Существует два подхода к написанию программного обеспечения, особенно многопоточного программного обеспечения.
В одном подходе вы бросаете кучу вещей и запускаете его, чтобы увидеть, насколько хорошо он работает. Вы никогда не убеждаетесь в том, что ваш код имеет проблему, если кто-то не сможет продемонстрировать эту проблему в реальной системе прямо сейчас.
В другом подходе вы пишете код, который может быть тщательно проанализирован, чтобы доказать, что у него нет данных, что все его петли заканчиваются с вероятностью 1 и т.д. Вы выполняете этот анализ строго в пределах модели машины, гарантированной спецификацией языка, а не какой-либо конкретной реализации.
Сторонники последнего подхода не впечатлены никакими демонстрациями на отдельных процессорах, компиляторах, младших версиях компилятора, операционных системах, времени выполнения и т.д. Такие демонстрации едва ли интересны и совершенно неактуальны. Если ваш алгоритм имеет гонку данных, он сломан, независимо от того, что произойдет, когда вы запустите его. Если ваш алгоритм имеет livelock, он сломан, независимо от того, что происходит, когда вы его запускаете. И так далее.В моем мире второй подход называется "Инжиниринг". Я не уверен, как называется первый подход.
Насколько я могу судить, интерфейс std::lock
бесполезен для Engineering. Я хотел бы, чтобы меня доказали неправильно.