воскресенье, 19 июля 2020 г.

[prog.c++] Продолжение истории про parent/child и удаление child-а из метода самого child-а

Около месяца назад в блоге была заметка, в которой показывалась схема с классами parent и child. Класс parent владел экземпляром child, а child в своем методе мог вызвать у parent-а метод replace_child, во время которого текущий child уничтожался. Т.е. получалось, что объект, метод которого сейчас работает, уничтожается прямо внутри этого работающего сейчас метода.

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

Все-таки вот такое:

void child::some_method()
{
   ... // Some actions.

   delete this// The last action of the method.
}

можно контролировать лишь в самых тривиальных случаях. А по мере того, как some_method усложняется и/или погружается куда-то ниже в стек вызовов, вероятность возникновения use after free стремительно приближается к единице. И можно быть уверенным, что в один прекрасный момент use after free таки произойдет.

Под катом небольшой рассказ о схеме, которая была применена для того, чтобы сохранить режим взаимодействия parent и child, но при этом защититься от use after free.

Диспозиция

Итак, принципиальный момент в том, что есть parent, который владеет child-ом. И есть разные child-ы, которые выполняют конкретную работу последовательно сменяя друг друга. Когда очередной child завершает свой кусок работы, то он создает нового child-а и вызывает у parent-а метод replace_child. В методе replace_child parent заменяет текущего child-а новым child-ом. В процессе замены может оказаться, что старый child больше нигде не используется и, поэтому, он сразу же уничтожается.

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

При этом у child-а есть несколько публичных методов, которые у child-а вызывает parent:

class child
{
public:
   virtual void on_start() = 0;
   virtual void on_timer() = 0;
   ...
};

И сам child может регистрировать самого себя в Asio для того, чтобы в нужные моменты Asio вызывал у child-а коллбэки.

shared_ptr вместо unique_ptr

Вскоре после публикации исходной заметки и задолго до того, как случился отстрел ноги (в виде use after free) вместо unique_ptr для управления временем жизни child-ов стал использоваться shared_ptr. А child-ы были сделаны наследниками std::enable_shared_from_this:

class child : public std::enable_shared_from_this<child>
{
   ...
};

using child_shptr = std::shared_ptr<child>;

Это потребовалось потому, что child-ы начали регистрировать свои коллбэки в Asio, а просто так изъять коллбэк из Asio нельзя. Даже если отменяется ранее начатая операция, то Asio спустя какое-то время вызывает коллбэк со специальным кодом ошибки. И нужно было обеспечить, чтобы при таком отложенном вызове коллбэк выполнялся нормально. Для чего при регистрации коллбэка передаваемая в Asio лямбда захватывала shared_ptr на child. Т.е. что-то вроде:

void some_child::some_action()
{
   ...
   connection_.async_read_some(...,
         [this, self=shared_from_this()](
            const asio::error_code & ec,
            std::size_t bytes_transferred )
         {
            this->on_read_result(ec, bytes_transferred);
         });
}

Соответственно, parent теперь держит не unique_ptr, а shared_ptr на child-а.

Переход к non-virtual public interface и создание охраняющего shared_ptr

В результате перехода на shared_ptr сложилась ситуация, когда вызов replace_child из коллбэка был безопасным. Т.к. сам коллбэк хранил в себе охраняющий экземпляр shared_ptr (внутри списка захвата той лямбды, которая и является коллбэк-ом). Соответственно, пока коллбэк не отработает и этот охраняющий экземпляр shared_ptr не разрушится, счетчик ссылок на child-а не обнулится и child будет оставаться живым. Поэтому вызвать replace_child из коллбэков можно спокойно.

Опасность стали представлять публичные методы child-а, который parent может вызывать у текущего child-а напрямую (т.к. on_start, on_timer и т.д.). Нужно было сделать так, чтобы вызов replace_child внутри on_timer был таким же безопасным, как и вызов replace_child из коллбэка.

Сделано это было простым способом: публичные методы on_start/on_timer/... перестали быть виртуальными. Они стали вот такими:

class child
{
protected:
   virtual void on_start_impl() = 0;
   virtual void on_timer_impl() = 0;
   ...

public:
   void on_start() 
   {
      auto sentinel = shared_from_this();
      on_start_impl();
   }
   void on_timer()
   {
      auto sentinel = shared_from_this();
      on_timer_impl();
   }
   ...
};

Т.е. теперь когда у child-а метод вызывает parent, то создается дополнительный охранный shared_ptr, который препятствует уничтожению child-а до завершения вызванного у child-а метода.

Введение дополнительного маркера delete_protector_t

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

Чтобы этого не происходило, был введен специальный тип-маркер delete_protector_t, который разрешает вызывать метод replace_child.

Грубо говоря, формат replace_child стал вот таким:

class parent
{
public:
   void replace_child(delete_protector_t, child_shptr new_child)
   ...
};

Т.е. чтобы child в своем методе мог вызвать replace_child, ему нужно иметь экземпляр delete_protector_t, а получить этот экземпляр просто так нельзя. Его можно создать только имея охранный shared_ptr. Из-за чего child стал выглядеть так:

class child
{
protected:
   virtual void on_start_impl(delete_protector_t) = 0;
   virtual void on_timer_impl(delete_protector_t) = 0;
   ...

public:
   void on_start() 
   {
      auto sentinel = shared_from_this();
      delete_protector_maker_t protector{sentinel};
      on_start_impl(protector.make());
   }
   void on_timer()
   {
      auto sentinel = shared_from_this();
      delete_protector_maker_t protector{sentinel};
      on_timer_impl(protector.make());
   }
   ...
};

Соответственно, сейчас в любой метод child-а, в котором нужно вызвать replace_child, следует передать маркер delete_protector. Ведь без него replace_child не вызывать. А взяться delete_protector просто так не может, он создается только в определенных местах, в которых время жизни child-а специально контролируется.

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

Да и понятность кода так же увеличивается, т.к. по маркерам delete_protector в сигнатурах методов становится понятно, в каком контексте эти методы можно вызывать, а так же сразу видно, что данный метод может привести к замене и удалению текущего child-а.

Сам delete_protector_t, если это кому-то интересно

Вот так выглядит сам delete_protector и класс-фабрика для создания экземпляров delete_protector-а:

class delete_protector_maker_t;

class delete_protector_t
{
   friend class delete_protector_maker_t;

   delete_protector_t() noexcept = default;

public:
   ~delete_protector_t() noexcept = default;

   delete_protector_t( const delete_protector_t & ) noexcept = default;
   delete_protector_t( delete_protector_t && ) noexcept = default;

   delete_protector_t &
   operator=( const delete_protector_t & ) noexcept = default;
   delete_protector_t &
   operator=( delete_protector_t && ) noexcept = default;
};

class delete_protector_maker_t
{
public:
   delete_protector_maker_t( child_shptr & ) {}

   [[nodiscard]]
   delete_protector_t
   make() const noexcept { return {}; }
};

Ничего сложного. Главное сделать так, чтобы delete_protector нельзя было просто так создать. Поэтому у него приватный конструктор.

Вместо послесловия

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

2 комментария:

sv комментирует...

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

class child {
private:
child_interface *child;
public:
void on_start() {
child->on_start();
auto *parent = child->getParent();
auto *newChild = child->getNewChild();
parent->setNewChild(newChild);
child = newChild;
}

Хотя такая схема кажется тоже избыточная, чтото кажется, что в этом случае parent и child выполняют одинаковую работу. Но основная идея именно такая.

eao197 комментирует...

@Unknown

Я не понял в чем смысл отделения child-а от child_interface. Ведь вся прикладная работа исполняется в наследниках child-а и именно оттуда инициируются операции замены текущего child-а новым child-ом.

Далеко не всегда эта замена будет происходить именно в on_start/on_timer.