std::mutex против std::recursive_mutex как член класса

Я видел, как некоторые люди ненавидят recursive_mutex:

http://www.zaval.org/resources/library/butenhof1.html

Но когда я думаю о том, как реализовать класс, который является потокобезопасным (защищенным мьютексом), мне крайне трудно доказать, что каждый метод, который должен быть защищен мьютексом, защищен мьютексом и что мьютекс блокируется не более одного раза.

Так что для объектно-ориентированного дизайна, следует std::recursive_mutex быть по умолчанию и std::mutex Рассматривается ли оптимизация производительности в общем случае, если только она не используется только в одном месте (для защиты только одного ресурса)?

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

В начале каждого публичного метода:

{
    std::scoped_lock<std::recursive_mutex> sl;

3 ответа

Решение

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

Для класса с одним мьютексом, защищающим элементы данных, мьютекс должен быть заблокирован во всех public функции-члены, и все private функции-члены должны предполагать, что мьютекс уже заблокирован.

Если public функция-член должна вызывать другую public функция-член, а затем разделить второй на две части: private функция реализации, которая делает работу, и public функция-член, которая просто блокирует мьютекс и вызывает private один. Первая функция-член может также вызвать функцию реализации, не беспокоясь о рекурсивной блокировке.

например

class X {
    std::mutex m;
    int data;
    int const max=50;

    void increment_data() {
        if (data >= max)
            throw std::runtime_error("too big");
        ++data;
    }
public:
    X():data(0){}
    int fetch_count() {
        std::lock_guard<std::mutex> guard(m);
        return data;
    }
    void increase_count() {
        std::lock_guard<std::mutex> guard(m);
        increment_data();
    } 
    int increase_count_and_return() {
        std::lock_guard<std::mutex> guard(m);
        increment_data();
        return data;
    } 
};

Это, конечно, тривиальный надуманный пример, но increment_data Функция является общей для двух открытых функций-членов, каждая из которых блокирует мьютекс. В однопоточном коде он может быть встроен в increase_count, а также increase_count_and_return Можно назвать это, но мы не можем сделать это в многопоточном коде.

Это всего лишь применение хороших принципов проектирования: открытые функции-члены берут на себя ответственность за блокировку мьютекса и делегируют ответственность за выполнение работы частной функции-члену.

Это имеет то преимущество, что public функции-члены имеют дело только с вызовом, когда класс находится в согласованном состоянии: мьютекс разблокирован, и как только он заблокирован, все инварианты сохраняются. Если вы позвоните public функции-члены друг от друга, тогда они должны обрабатывать случай, когда мьютекс уже заблокирован, и что инварианты не обязательно держатся.

Это также означает, что такие вещи, как ожидание условной переменной, будут работать: если вы передаете блокировку рекурсивного мьютекса в условную переменную, то (a) вам нужно использовать std::condition_variable_any так как std::condition_variable не будет работать, и (b) будет освобожден только один уровень блокировки, так что вы все равно можете удерживать блокировку и, следовательно, тупик, потому что поток, который будет вызывать предикат и делать уведомление, не сможет получить блокировку.

Я изо всех сил пытаюсь придумать сценарий, где требуется рекурсивный мьютекс.

должен std::recursive_mutex быть по умолчанию и std::mutex рассматривается как оптимизация производительности?

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

Есть довольно распространенная ситуация, когда у вас есть:

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

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

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

Однако иногда вместо этого удобно использовать рекурсивный мьютекс. Есть еще проблема с этим дизайном: remove_two_nodes звонки remove_one_node в момент, когда инвариант класса не сохраняется (во второй раз, когда он вызывает его, список находится именно в том состоянии, которое мы не хотим раскрывать). Но при условии, что мы знаем, что remove_one_node не полагайтесь на этот инвариант, это не является серьезной ошибкой в ​​дизайне, просто мы сделали наши правила немного более сложными, чем идеал "все инварианты классов всегда выполняются всякий раз, когда вводится какая-либо открытая функция".

Таким образом, этот трюк иногда полезен, и я не ненавижу рекурсивные мьютексы в той степени, в которой это делает статья. У меня нет исторических знаний, чтобы утверждать, что причина их включения в Posix отличается от того, что говорится в статье, "чтобы продемонстрировать атрибуты мьютекса и расширения потоков". Я, конечно, не считаю их по умолчанию.

Я думаю, можно с уверенностью сказать, что если в вашем дизайне вы не уверены, нужен ли вам рекурсивный замок или нет, то ваш дизайн неполон. Позже вы пожалеете о том, что пишете код, и не знаете чего-то столь важного, как то, разрешено ли блокирование уже удерживаться или нет. Так что не вставляйте рекурсивную блокировку "на всякий случай".

Если вы знаете, что вам нужен, используйте один. Если вы знаете, что он вам не нужен, то использование нерекурсивной блокировки - это не просто оптимизация, это помогает обеспечить ограничение дизайна. Лучше, если вторая блокировка выйдет из строя, чем если она будет успешной и сокрыт тот факт, что вы случайно сделали что-то, что, как говорит ваш дизайн, никогда не должно произойти. Но если вы будете следовать своему замыслу и никогда не блокируете мьютекс дважды, то вы никогда не узнаете, рекурсивен он или нет, и поэтому рекурсивный мьютекс не наносит прямого вреда.

Эта аналогия может потерпеть неудачу, но вот другой способ взглянуть на это. Представьте, что у вас есть выбор между двумя типами указателей: один, который прерывает программу с помощью трассировки стека, когда вы разыменовываете нулевой указатель, и другой, который возвращает 0 (или расширить его на несколько типов: ведет себя так, как будто указатель ссылается на объект, инициализированный значением). Нерекурсивный мьютекс немного похож на тот, который прерывается, а рекурсивный мьютекс немного похож на тот, который возвращает 0. Они оба потенциально имеют свое применение - люди иногда идут на все, чтобы реализовать "тихий не-а". -значение "значение. Но в том случае, если ваш код разработан так, чтобы никогда не разыменовывать нулевой указатель, вы не хотите использовать по умолчанию версию, которая позволяет это молча.

Я не собираюсь прямо влиять на дебаты о взаимных мьютексах и recursive_mutex, но я подумал, что было бы хорошо поделиться сценарием, в котором recursive_mutex'ы абсолютно критичны для дизайна.

При работе с Boost::asio, Boost::coroutine (и, возможно, такими вещами, как NT Fibers, хотя я с ними менее знаком), абсолютно необходимо, чтобы ваши мьютексы были рекурсивными даже без проблемы повторного входа.

Причина в том, что подход, основанный на сопрограммах, по самой своей структуре будет приостанавливать выполнение внутри подпрограммы, а затем впоследствии возобновлять ее. Это означает, что два метода высшего уровня класса могут "вызываться одновременно в одном потоке" без каких-либо дополнительных вызовов.

Другие вопросы по тегам