Программирование C++МногопоточностьРазработчик системного программного обеспечения на C++

Что произойдёт при выполнении функции одним потоком, если обычный мьютекс повторно захватывается тем же вла...

Что произойдёт при выполнении функции одним потоком, если обычный мьютекс повторно захватывается тем же владельцем?

#include <mutex>

void update(std::mutex& m, int& value) {
    std::lock_guard<std::mutex> first(m);
    ++value;

    if (value == 1) {
        std::lock_guard<std::mutex> second(m);
        value = 2;
    }
}
Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Повторный захват std::mutex тем же потоком недопустим: стандарт не гарантирует корректного поведения, а на практике поток обычно блокируется сам на себе. У обычного мьютекса нет рекурсивной семантики, поэтому до присваивания value = 2 выполнение, как правило, не дойдёт.

Исторический контекст

Мьютекс предназначен для защиты критической секции, в которой одновременно работает только один поток. Разделение обычного и рекурсивного мьютекса позволяет явно выбирать модель владения: обычный мьютекс выявляет повторный вход, а std::recursive_mutex допускает несколько захватов одним владельцем.

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

Постановка проблемы

В примере поток уже владеет m, когда создаётся объект second. Второй lock_guard вызывает m.lock(), но мьютекс ожидает освобождения, которое может выполнить только текущий поток после выхода из внешнего lock_guard.

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

Подробное решение

Нужно устранить повторный захват, а не пытаться «дождаться» разблокировки. Обычно внутреннюю часть функции выносят в helper, который предполагает, что мьютекс уже захвачен, и вызывают его только из внешней критической секции.

#include <mutex> void update_locked(int& value) { ++value; if (value == 1) value = 2; } void update(std::mutex& m, int& value) { std::lock_guard<std::mutex> lock(m); update_locked(value); }

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

Для публичных методов полезно различать методы, которые захватывают мьютекс, и внутренние методы с суффиксом вроде _locked, не захватывающие его повторно. Вызов внешнего пользовательского callback под удерживаемым мьютексом также опасен: callback может реентерабельно вызвать тот же объект или создать цикл блокировок.

Ситуация из практики

Класс хранит состояние под мьютексом и во время изменения вызывает callback наблюдателя. Наблюдатель обращается к тому же публичному методу, который снова пытается захватить мьютекс. Замена std::mutex на std::recursive_mutex устраняет самоблокировку, но оставляет объект в промежуточном состоянии и позволяет callback увидеть неконсистентные данные.

Вариант с рекурсивным мьютексом прост, но скрывает реентерабельность и обычно имеет менее прозрачную модель владения. Вариант с ручным unlock перед callback уменьшает риск блокировки, однако требует сначала скопировать данные для callback и тщательно продумать, что изменится между разблокировкой и уведомлением.

Предпочтительное решение — изменить состояние под обычным мьютексом, скопировать необходимые данные, освободить мьютекс и только затем вызвать callback. Такой подход сохраняет инвариант во время публикации, не удерживает блокировку во внешнем коде и предотвращает повторный захват того же мьютекса.

Что кандидаты часто упускают

  1. Можно ли безопасно заменить std::mutex на std::recursive_mutex без других изменений?

Не всегда. Рекурсивный мьютекс устраняет самоблокировку только на уровне захватов, но не исправляет реентерабельность. Повторный вызов может наблюдать частично обновлённое состояние, вызвать callback в неподходящий момент или нарушить логический инвариант. Поэтому такая замена допустима лишь при явно спроектированной рекурсивной модели.

  1. Что произойдёт, если вместо второго lock использовать try_lock?

Нельзя считать это универсальным способом обнаружить самоблокировку. Для уже принадлежащего текущему потоку std::mutex стандарт не гарантирует корректное поведение повторной попытки захвата; рассчитывать на обязательный результат false нельзя. Кроме того, проверка через try_lock не устраняет логическую проблему повторного входа и может породить ветку с частично выполненной операцией.

  1. Почему нельзя просто передать мьютекс по значению во вспомогательную функцию?

std::mutex не копируется и не перемещается, поскольку копирование владения разрушило бы смысл примитива синхронизации. Вспомогательной функции передают ссылку или указатель на тот же мьютекс, но это не должно приводить к повторному захвату. Часто безопаснее передавать в закрытый helper только защищаемые данные и явно документировать, что helper вызывается исключительно при уже удерживаемой блокировке.