Является ли std:: lock() неопределенным, нереализуемым или бесполезным?

(Примечание. Большая часть этого избытка с комментарием Массивная загрузка ЦП с использованием 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. Я хотел бы, чтобы меня доказали неправильно.

Ответ 1

Я думаю, что вы недооцениваете сферу избегания тупика. Это понятно, поскольку текст, как представляется, упоминает lock в двух разных контекстах: "multi-lock" std::lock и отдельные блокировки, выполняемые этим "многозадачным" (однако блокировки реализуют его). Текст для std::lock гласит:

Все аргументы блокируются через последовательность вызовов lock(), try_lock() или unlock() для каждого аргумента. Последовательность вызовов не должна в тупике

Если вы вызываете std::lock, передавая десять разных блокировок, стандарт не гарантирует взаимоблокировку для этого вызова. Это не гарантировало, что тупик можно избежать, если вы заблокируете блокировки вне контроля std::lock. Это означает, что нить 1 блокирует A, а затем B может блокировать блокировку B нити 2, затем A. Это было в вашем первоначальном третьем примере, который имел (псевдокод):

Thread 1     Thread 2
lock A       lock B
lock B       lock A

Поскольку это не могло быть std::lock (он заблокировал только один ресурс), он должен был быть чем-то вроде unique_lock.

Уклонение от тупика произойдет, если оба потока попытаются заблокировать A/B и B/A в одном вызове std::lock, согласно вашему первому примеру. Второй пример не будет заторможен, так как поток 1 будет отступать, если второй замок необходим потоку 2, уже имеющему первую блокировку. Ваш обновленный третий пример:

Thread 1                  Thread 2
std::lock(lock1,lock2);   std::lock(lock3,lock4);
std::lock(lock3,lock4);   std::lock(lock1,lock2);

по-прежнему имеет тупик, поскольку атомарность блокировки - это один вызов std::lock. Например, если поток 1 успешно блокирует lock1 и lock2, тогда поток 2 успешно блокирует lock3 и lock4, произойдет взаимоблокировка, поскольку оба потока пытаются заблокировать ресурс, хранящийся другим.

Итак, в ответ на ваши конкретные вопросы:

1/Да, я думаю, вы неправильно поняли, что говорит стандарт. Последовательность, о которой он говорит, - это, очевидно, последовательность блокировок, выполняемых на отдельных заблокированных записях, переданных в один std::lock.

2/Что касается того, о чем они думали, иногда трудно сказать:-) Но я бы сказал, что они хотели бы предоставить нам возможности, которые нам в противном случае пришлось бы написать сами. Да, back-off-and-retry может быть не идеальной стратегией, но, если вам нужна функция блокировки взаимоблокировки, вам, возможно, придется заплатить цену. Лучше для реализации, чтобы обеспечить его, а не необходимость писать много раз разработчиками.

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


В отношении комментариев, что откат и повтор - это проблемная стратегия, да, это правильно. Но вам может быть не хватать точки, что это может быть необходимо, если, например, вы не можете принудительно выполнить заказ блокировок заранее.

И это не должно быть так плохо, как вы думаете. Поскольку блокировки могут выполняться в любом порядке с помощью std::lock, ничто не останавливает реализацию при повторном заказе после каждого отсрочка, чтобы принести "сбои" блокировки в начало списка. Это означает, что те, которые были заблокированы, будут стремиться собираться на фронте, так что std::lock будет с меньшей вероятностью требовать ненужные ресурсы.

Рассмотрим вызов std::lock (a, b, c, d, e, f), в котором f был единственным заблокированным, который уже был заблокирован. В первой попытке блокировки этот вызов блокирует a через e, затем "fail" на f.

После отпирания (разблокировка a через e) список блокировки будет изменен на f, a, b, c, d, e, чтобы последующие итерации были бы менее вероятными для блокировки. Это не безупречно, поскольку другие ресурсы могут быть заблокированы или разблокированы между итерациями, но они имеют тенденцию к успеху.

Фактически, он может даже заказать список изначально, проверяя состояния всех блокировок, чтобы все заблокированные в данный момент были спереди. Это начало операции "стремление к успеху" ранее в этом процессе.

Только одна стратегия, может быть, и другие, даже лучше. Именно поэтому стандарт не дал никаких указаний о том, как это должно было быть сделано, по неоспоримому, может быть какой-то гений, который придумает лучший способ.

Ответ 2

Возможно, это помогло бы, если бы вы подумали о каждом индивидуальном вызове std::lock(x, y, ...) как атомарном. Он будет блокироваться, пока он не сможет заблокировать все его аргументы. Если вы не знаете все мьютексы, вам необходимо заблокировать априори, не используйте эту функцию. Если вы это знаете, вы можете безопасно использовать эту функцию, не заказывая свои блокировки.

Но обязательно закажите свои блокировки, если это то, что вы предпочитаете делать.

Thread 1                    Thread 2
std::lock(lock1, lock2);    std::lock(lock2, lock1);

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

Thread 1                                  Thread 2
std::lock(lock1, lock2, lock3, lock4);    std::lock(lock3, lock4);
                                          std::lock(lock1, lock2);

Вышеуказанное не будет заторможено. Хотя это сложно. Если Thread 2 получает lock3 и lock4 до Thread1, то Thread 1 будет блокироваться до тех пор, пока Thread 2 не выпустит все 4 замка. Если в Thread 1 сначала берутся четыре замка, тогда Thread 2 будет блокироваться в точке блокировки lock3 и lock4, пока Thread 1 не освободит все 4 замка.

Thread 1                          Thread 2
std::lock(lock1,lock2);           std::lock(lock3,lock4);
std::lock(lock3,lock4);           std::lock(lock1,lock2);

Да, вышеупомянутое может затормозить. Вы можете просмотреть приведенное выше значение в точности как:

Thread 1                          Thread 2
lock12.lock();                    lock34.lock();
lock34.lock();                    lock12.lock();

Обновление

Я считаю, что недоразумение заключается в том, что блокировка и блокировка в реальном времени являются проблемами правильности.

Фактически, мертвая блокировка является проблемой правильности, поскольку она заставляет процесс замораживаться. А live-lock - это проблема производительности, поскольку она заставляет процесс замедляться, но он по-прежнему выполняет свою задачу правильно. Причина в том, что live-lock не будет (на практике) поддерживать себя бесконечно.

<disclaimer> Существуют формы живого замка, которые могут быть созданы, которые являются постоянными и, таким образом, эквивалентны мертвой блокировке. Этот ответ не рассматривает такой код, и такой код не имеет отношения к этой проблеме. </disclaimer>

Результат, показанный в этом ответе, представляет собой значительную оптимизацию производительности, которая значительно уменьшает прямую блокировку и, таким образом, значительно увеличивает производительность std::lock(x, y, ...).

Обновление 2

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

http://howardhinnant.github.io/dining_philosophers.html

Ответ 3

Ваше замешательство со стандартным, похоже, связано с этим утверждением

5 Эффекты: все аргументы блокируются с помощью последовательности вызовов lock(), try_lock() или unlock() для каждого аргумента.

Это не означает, что std::lock будет рекурсивно вызывать себя с каждым аргументом в исходный вызов.

Объекты, которые удовлетворяют концепции Lockable (§30.2.5.4 [thread.req.lockable.req]), должны реализовать все три из этих функций-членов. std::lock будет вызывать эти функции-члены для каждого аргумента в неуказанном порядке, чтобы попытаться получить блокировку для всех объектов, выполняя определенную реализацию, чтобы избежать тупиковой ситуации.

В вашем примере 3 есть вероятность тупика, потому что вы не отправляете один вызов std::lock со всеми объектами, которые вы хотите заблокировать.

Пример 2 не приведет к тупиковой ситуации, Howard answer объясняет, почему.

Ответ 4

Использовал ли С++ 11 эту функцию из Boost?

Если это так, описание Boost поучительно (акцент мой):

Эффекты: блокирует блокируемые объекты, предоставленные в качестве аргументов, в неуказанном и неопределенный порядок таким образом, чтобы избежать тупиковой ситуации. Безопасно звонить эта функция одновременно из нескольких потоков с одинаковыми мьютексами (или других заблокируемых объектов) в разных заказах без риска тупик. Если какие-либо операции lock() или try_lock() на Предоставляемые блокируемые объекты создают исключение, любые блокировки, функция будет выпущена до выхода функции.