Около месяца назад в блоге была заметка, в которой показывалась схема с классами 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 нельзя было просто так создать. Поэтому у него приватный конструктор.
Вместо послесловия
Хочу поблагодарить читателей, которые все еще заходят в мой блог. К сожалению, в последние месяцы очень редко представляется возможность сесть и написать что-то интересное и, надеюсь, полезное в блог. Так что спасибо, что не забываете!
Раз уж Вы дошли до введения в 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 выполняют одинаковую работу. Но основная идея именно такая.
@Unknown
ОтветитьУдалитьЯ не понял в чем смысл отделения child-а от child_interface. Ведь вся прикладная работа исполняется в наследниках child-а и именно оттуда инициируются операции замены текущего child-а новым child-ом.
Далеко не всегда эта замена будет происходить именно в on_start/on_timer.