среда, 4 ноября 2015 г.

[prog.c++11] Потупил над condition_variable, unique_lock и defer_lock/adopt_lock

Дизайн std::condition_variable в C++11, как по мне, продиктован интересной смесью POSIX-овских стандартов и C++ной идиомы RAII. Из-за этой смеси метод condition_variable::wait требует не std::mutex, а std::unique_lock<std::mutex> (тогда как в POSIX функция pthread_cond_wait получает мутекс напрямую).

Подход C++11 удобен в подавляющем большинстве случаев, т.к. wait нужно вызывать под уже захваченным мутексом, а захват мутекса unique_lock-ом -- это эффективно, просто и безопасно. Но вот довелось оказаться в несколько необычной ситуации: потребовалось задействовать condition_variable::wait() в отсутствии unique_lock-а. Тут-то и столкнулся с собственной тупизной и, отчасти, законом дырявых абстракций... :)

Итак, сложилась ситуация, когда в коде есть std::mutex и std::condition_variable. В одном месте для std::mutex явно вызывается метод lock, после чего начинается цепочка операций, в одной из которых нужно вызвать метод condition_variable::wait. Но wait требует аргумента std::unique_lock<std::mutex>. А этого самого unique_lock нет в помине. И что делать в этой ситуации?

Оказалось, что в списке конструкторов std::unique_lock есть парочка, показавшаяся полезной в моей ситуации. Оба этих конструктора получают не только ссылку на std::mutex, но и еще один дополнительный аргумент, который показывает, как нужно воспринимать состояние мутекса. Если передать значение std::defer_lock, то unique_lock не будет захватывать мутекс, предполагая, что захват произойдет позже. Если же передать значение std::adopt_lock, то unique_lock так же не будет захватывать мутекс, но считая, что текущая нить уже владеет мутексом.

Вот тут-то меня и переклинило. Я решил, что значение adopt_lock -- это именно то, что нужно. Текущая нить уже владеет mutex-ом, поэтому передам в конструктор adopt_lock и unique_ptr не будет пытаться захватить мутекс повторно. Так же я почему-то подумал, что раз unique_lock не захватывал мутекс в конструкторе, то он не будет освобождать его и в деструкторе.

Но на тестах начались фокусы. Под GCC вылетало исключение std::system_error с сообщением "Operation not permitted", а под MSVC возникало зависание при завершении работы.

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

Получалось, что я сначала захватывал мутекс, затем доходил до места, где на короткое время создавался unique_lock и вызывался condition_variable::wait, после разрушения unique_lock мутекс освобождался, но я об этом не знал и продолжал выполнять действия над разделяемыми между нитями данными при разблокированном мутексе, после чего освобождал мутекс еще раз. Что и приводило к разным проблемам под разными компиляторами.

Пришлось познакомиться с деталями реализации condition_variable и unique_lock в libstdc++ (которая идет в составе GCC), а так же с libc++ (из LLVM). Оказалось, что там очень примитивная схема. В unique_lock, грубо говоря, хранится всего два поля: указатель на мутекс и булевая переменная, хранящая признак того, захвачен ли мутекс этим экземпляром unique_lock или нет. Если булевая переменная содержит true, то в деструкторе unique_lock освобождает мутекс.

Так вот, передавая adopt_lock мы выставляем этот признак в true и unique_lock считает, что теперь он владеет мутексом и, на основании этого предположения, освобождает мутекс в деструкторе (хотя и не захватывал его в конструкторе). Если же передать defer_lock, то булевый признак получает значение false и unique_lock не освобождает мутекс в деструкторе.

Upd. На самом деле все оказалось не так просто. См. поправку ниже.

Признаюсь, такое поведение несколько меня озадачило. Получается, что если я сам вручную дергаю lock и unlock для мутекса, но нуждаюсь в ожидании на condition_variable, то мне нужно создавать unique_lock с флагом defer_lock. Т.е. создается unique_lock который думает, что мутексом он не владеет. И нужен этот unique_lock только для того, чтобы передать указатель на мутекс внутрь condition_variable::wait.

В принципе, логика в таком поведении есть. Но не могу сказать, что она интуитивно понятна. Ведь defer_lock предназначен для того, чтобы указать, что захват мутекса произойдет позже. Т.е. сначала мы создаем unique_lock для мутекса, а затем где-то дергаем lock для него. И получаем "нормальный" unique_lock, который владеет захваченным мутексом (и, соответственно, освобождает мутекс в деструкторе или в явно вызванном unlock-е). Но в моем сценарии unique_lock так и проживал всю свою жизнь предполагая, что мутекс не захвачен.

Более того, в unique_lock есть метод owns_lock, который позволяет узнать, владеет ли unique_lock захваченным мутексом или нет. И, в принципе, в каких-нибудь отладочных версиях condition_variable::wait мог бы быть вызов owns_lock внутри assert-а для проверки корректности действий пользователя (мол, зачем ты мне подсовываешь unique_lock, который мутексом не владеет?). Но, к счастью, этого не происходит и wait лишних проверок не делает.

В общем, как-то все это костыльно, на мой взгляд. Было бы проще, если бы в condition_variable был еще один метод wait, куда можно было бы передать mutex напрямую. Все-таки C++ спокойно относится к желанию разработчика отстрелить себе ногу, так зачем же лишать программиста такой возможности? ;)

Поправка. Оказалось, что под clang 3.4.1 и FreeBSD 10.1 нельзя передавать в condition_variable::wait экземпляр unique_lock, созданный с флагом defer_lock. Это приводит к краху приложения. Нужно создавать unique_lock с флагом defer_lock, а затем вызывать release для unique_lock-а. Что-то вроде:

std::unique_lock< std::mutex > mlock{ m_mutex, std::adopt_lock };
m_condition.wait( mlock, predicate );
mlock.release();

Комментариев нет: