Подтвердить что ты не робот

Std:: mutex vs std:: recursive_mutex как член класса

Я видел некоторую ненависть ppl на recursive_mutex (не для С++ 11):

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

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

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

РЕДАКТИРОВАТЬ: сделать все ясно. Я говорю об одном частном нестатических мьютексах. Поэтому каждый экземпляр класса имеет только один мьютекс.

EDIT2: чтобы все было ясно, я предлагаю щедрость для этого не потому, что чувствую, что текущий А плох, но потому что idk, если они хороши. Ака они кажутся слишком "ИМХО это".

И чтобы понять, о чем я говорю: один мьютекс за один раз,
в начале каждого публичного метода

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

Ответ 1

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

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

Если функции-члену public необходимо вызвать другую функцию-член public, затем разделите вторую на две части: функцию реализации private, которая выполняет эту работу, и функцию-член public, которая просто блокирует mutex и вызывает 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) освобождается только один уровень блокировки, поэтому вы можете удерживать блокировку и, таким образом, тупик, потому что поток, который запускает предикат, и делает уведомление, не может получить блокировку.

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

Ответ 2

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

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

Существует достаточно распространенная ситуация, когда у вас есть:

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

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

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

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

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

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

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

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

Ответ 3

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

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

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