Как обойти блокировку мьютекса из-под рекурсивного вызова

Имеется дерево из инстансов некоторых классов A и B (псевдокод):

class A
{
public:
   void callB()
   {
      m.lock();

      // ...
      // много всякого непотокобезопасного кода
      // ...

      b.callF();
      m.unlock();
   }

private:
   B b;
   std::mutex m;
};

class B
{
public:
   void callF()
   {
      f();
   }

private:
   std::function<void()> f;
   std::mutex m;
}

Вызов B::f() может привести к вызову A::callB() извне. Однако никак нельзя убрать блокировку мьютексом в A::callB(), т.к. это может быть обычный нерекурсивный вызов. Понятно, что использование std::recursive_mutex решает проблему, но хотелось бы обойтись без него.

Пробовал выносить вызов callF() в A::callB() за границу защиты мьютекса:

class A
{
public:
   void callB()
   {
      m.lock();

      // ...
      // много всякого непотокобезопасного кода
      // ...

      m.unlock();
      b.callF();
   }
...

но это приводило к усложнению кода, плюс, поскольку таких вызовов в реальной задаче несколько, к нарушению целостности выполнения операции A::callB(), т.к. несколько тредов начинают менять значения членов A в конкурентном порядке. Что-то типа такого:

class A
{
public:
   void call()
   {
      m.lock();
      // ...
      val = 10;
      m.unlock();

      b.callF(); // здесь val то 10, то 20

      m.lock();
      // ...
      val = 20;
      m.unlock();

      c.callF();  // здесь val то 10, то 20
   }

private:
   B b;
   C c;
   int val;
   std::mutex m;
};

Какую технику возможно использовать, чтобы, с одной стороны, обойти блокировки при рекурсивном вызове, а с другой - сохранить целостность всей операции?


Ответы (1 шт):

Автор решения: alexis031182

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

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

Или, например, можно озадачиться проверкой какого-нибудь атомарного флага, который будет говорить, является ли текущий поток уже защищённым соответствующим мьютексом или нет. В этом случае мьютекс не будет блокироваться рекурсивно, будет всегда известно, что любой вызов unlock() приведёт к полному его освобождению, а не возможно частичному, которое очевидно свойственно рекурсивному мьютексу. Но это решение также привносит и свои пять копеек в набор подводных камней. Придётся плодить эти флаги или таскать ссылки на них в дереве объектов и располагать на каждом уровне в тех местах, где используется каждый из мьютексов.

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

Чем же в свою очередь плох рекурсивный мьютекс? Как минимум:

  • в каждом конкретном контексте вызов unlock() не гарантирует полного освобождения мьютекса;
  • мьютекс должен быть заблокирован на максимально короткое время (иначе зачем нам многопоточность?), что при рекурсивной блокировке становится слабо достижимо;
  • оверхед из-за проверки на текущий тред всякий раз при каждой блокировке.
→ Ссылка