- PVSM.RU - https://www.pvsm.ru -
В статье приводится опасный антипаттерн «Зомби», в некоторых ситуациях естественным образом возникающий при использовании std::enable_shared_from_this. Материал — где-то на стыке техники современного C++ и архитектуры.
C++11 предоставил разработчику замечательные инструменты для работы с памятью — умные указатели std::unique_ptr и связку std::shared_ptr + std::weak_ptr. Использование умных указателей по удобству и безопасности существенно перевешивает использование сырых указателей. Умные указатели широко применяются на практике, т.к. позволяют разработчику сосредоточиться на более высокоуровневых вопросах, чем отслеживание корректности создания/удаления динамически создаваемых сущностей.
Частью стандарта является также шаблон класса std::enable_shared_from_this, при первом знакомстве кажущийся довольно странным.
В статье пойдёт речь о том, как можно вляпаться при его использовании.
Зачем так сложно? Можно же просто сконструировать std::shared_ptr<T>(this)
Нет, нельзя. Все std::shared_ptr'ы, заботящиеся об одном и том же экземпляре класса, должны использовать один блок подсчёта ссылок. Без специальной магии тут не обойтись.
Обязательным условием применения std::enable_shared_from_this является изначальное создание объекта класса в std::shared_ptr. Создание на стеке, динамическое выделение в куче, создание в std::unique_ptr — это всё не подходит. Только строго в std::shared_ptr.
А разве можно ограничить пользователя в способах создания экземпляров класса?
Да, можно. Для этого надо всего-навсего:
— предоставить статический метод для создания экземпляров, изначально размещённых в std::shared_ptr;
— поместить конструктор в private или protected;
— запретить copy- и move-семантику.
Класс зашёл в клетку, закрыл её на замок и проглотил ключ — с этих пор все его экземпляры будут жить только в std::shared_ptr, и не существует законных способов вытащить их оттуда.
Такое ограничение нельзя назвать хорошим архитектурным решением, но стандарту этот способ соответствует полностью.
Кроме того, можно использовать идиому PIMPL: единственный пользователь капризного класса — фасад — будет создавать реализацию строго в std::shared_ptr, а сам фасад уже будет лишён ограничений такого рода.
std::enable_shared_from_this имеет существенные нюансы при наследовании, но их обсуждение выходит за рамки статьи.
Все примеры кода, приведённые в статье, опубликованы на гитхабе [1].
Код демонстрирует плохие техники, замаскированные под обычное безопасное применение современного C++
Вроде бы ничего не предвещает проблем. Объявление класса выглядит просто и понятно. За исключением одной «мелкой» детали — зачем-то применено наследование от std::enable_shared_from_this.
#pragma once
#include <memory>
#include <functional>
namespace SimpleCyclic {
class Cyclic final : public std::enable_shared_from_this<Cyclic>
{
public:
static std::shared_ptr<Cyclic> create();
Cyclic(const Cyclic&) = delete;
Cyclic(Cyclic&&) = delete;
Cyclic& operator=(const Cyclic&) = delete;
Cyclic& operator=(Cyclic&&) = delete;
~Cyclic();
void doSomething();
private:
Cyclic();
std::function<void(void)> _fn;
};
} // namespace SimpleCyclic
А в реализации:
#include <iostream>
#include "SimpleCyclic.h"
namespace SimpleCyclic {
Cyclic::Cyclic() = default;
Cyclic::~Cyclic()
{
std::cout << typeid(*this).name() << "::" << __func__ << std::endl;
}
std::shared_ptr<Cyclic> Cyclic::create()
{
return std::shared_ptr<Cyclic>(new Cyclic);
}
void Cyclic::doSomething()
{
_fn = [shis = shared_from_this()](){};
std::cout << typeid(*this).name() << "::" << __func__ << std::endl;
}
} // namespace SimpleCyclic
#include "SimpleCyclic/SimpleCyclic.h"
int main()
{
auto simpleCyclic = SimpleCyclic::Cyclic::create();
simpleCyclic->doSomething();
return 0;
}
N12SimpleCyclic6CyclicE::doSomething
В теле функции doSomething() экземпляр класса сам создёт дополнительную сильную копию того std::shared_ptr, в котором он был размещён. Затем эта копия с помощью обобщённого захвата помещается в лямбда-функцию, присваиваемую полю данных класса под видом безобидного std::function. Вызов doSomething() приводит к возникновению циклической ссылки, и экземпляр класса уже не будет разрушен даже после уничтожения всех внешних сильных ссылок.
Возникает утечка памяти. Деструктор SimpleCyclic::Cyclic::~Cyclic не вызывается.
Экземпляр класса «держит» себя сам.
Код завязался в узел.

(изображение взято отсюда [2])
И что, это и есть антипаттерн «Зомби»?
Нет, это только разминка. Всё самое интересное ещё впереди.
Зачем разработчик такое написал?
Пример синтетический. Мне не известны какие-либо ситуации, в которых гармонично получался бы такой код.
И что, неужели динамический анализ кода промолчал?
Нет, Valgrind честно сообщил о состоявшейся утечке памяти:
96 (64 direct, 32 indirect) bytes in 1 blocks are definitely lost in loss record 29 of 46
in SimpleCyclic::Cyclic::create() in /Users/User/Projects/Zomby_antipattern_concept/SimpleCyclic/SimpleCyclic.cpp:15
1: malloc in /usr/local/Cellar/valgrind/HEAD-60ab74a/lib/valgrind/vgpreload_memcheck-amd64-darwin.so
2: operator new(unsigned long) in /usr/lib/libc++abi.dylib
3: SimpleCyclic::Cyclic::create() in /Users/User/Projects/Zomby_antipattern_concept/SimpleCyclic/SimpleCyclic.cpp:15
4: main in /Users/User/Projects/Zomby_antipattern_concept/SimpleCyclic/main.cpp:5
В данном случае заголовочный файл выглядит совершенно корректно и лаконично. В нём объявлен фасад, хранящий некую реализацию в std::shared_ptr. Наследование — в том числе от std::enable_shared_from_this — отсутствует, в отличие от прошлого примера.
#pragma once
#include <memory>
namespace PimplCyclic {
class Cyclic
{
public:
Cyclic();
~Cyclic();
private:
class Impl;
std::shared_ptr<Impl> _impl;
};
} // namespace PimplCyclic
А в реализации:
#include <iostream>
#include <functional>
#include "PimplCyclic.h"
namespace PimplCyclic {
class Cyclic::Impl : public std::enable_shared_from_this<Cyclic::Impl>
{
public:
~Impl()
{
std::cout << typeid(*this).name() << "::" << __func__ << std::endl;
}
void doSomething()
{
_fn = [shis = shared_from_this()](){};
std::cout << typeid(*this).name() << "::" << __func__ << std::endl;
}
private:
std::function<void(void)> _fn;
};
Cyclic::Cyclic()
: _impl(std::make_shared<Impl>())
{
if (_impl) {
_impl->doSomething();
}
}
Cyclic::~Cyclic()
{
std::cout << typeid(*this).name() << "::" << __func__ << std::endl;
}
} // namespace PimplCyclic
#include "PimplCyclic/PimplCyclic.h"
int main()
{
auto pimplCyclic = PimplCyclic::Cyclic();
return 0;
}
N11PimplCyclic6Cyclic4ImplE::doSomething
N11PimplCyclic6CyclicE::~Cyclic
Вызов Impl::doSomething() приводит к образованию циклической ссылки в экземпляре класса Impl. Фасад уничтожается корректно, а вот реализация утекает. Деструктор PimplCyclic::Cyclic::Impl::~Impl не вызывается.
Пример опять синтетический, но на сей раз более опасный — вся плохая техника расположена в реализации и никак не проявляется в объявлении.
Более того, для возникновения циклической ссылки от пользовательского кода не потребовалось никаких действий, кроме конструирования.
Динамический анализ в лице Valgrind и в этот раз выявил утечку:
96 bytes in 1 blocks are definitely lost in loss record 29 of 46
in PimplCyclic::Cyclic::Cyclic() in /Users/User/Projects/Zomby_antipattern_concept/PimplCyclic/PimplCyclic.cpp:28
1: malloc in /usr/local/Cellar/valgrind/HEAD-60ab74a/lib/valgrind/vgpreload_memcheck-amd64-darwin.so
2: operator new(unsigned long) in /usr/lib/libc++abi.dylib
3: std::__1::__libcpp_allocate(unsigned long, unsigned long) in /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include/c++/v1/new:252
4: std::__1::allocator<std::__1::__shared_ptr_emplace<PimplCyclic::Cyclic::Impl, std::__1::allocator<PimplCyclic::Cyclic::Impl> > >::allocate(unsigned long, void const*) in /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include/c++/v1/memory:1813
5: std::__1::shared_ptr<PimplCyclic::Cyclic::Impl> std::__1::shared_ptr<PimplCyclic::Cyclic::Impl>::make_shared<>() in /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include/c++/v1/memory:4326
6: _ZNSt3__1L11make_sharedIN11PimplCyclic6Cyclic4ImplEJEEENS_9enable_ifIXntsr8is_arrayIT_EE5valueENS_10shared_ptrIS5_EEE4typeEDpOT0_ in /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include/c++/v1/memory:4706
7: PimplCyclic::Cyclic::Cyclic() in /Users/User/Projects/Zomby_antipattern_concept/PimplCyclic/PimplCyclic.cpp:28
8: PimplCyclic::Cyclic::Cyclic() in /Users/User/Projects/Zomby_antipattern_concept/PimplCyclic/PimplCyclic.cpp:29
9: main in /Users/User/Projects/Zomby_antipattern_concept/PimplCyclic/main.cpp:5
Немного подозрительно видеть Pimpl, в котором реализация хранится в std::shared_ptr.
Классический Pimpl на базе сырого указателя слишком архаичен, а std::unique_ptr имеет побочный эффект в виде распространения запрета copy-семантики на фасад. Такой фасад будет реализовывать идиому единоличного владения, что может не соответствовать архитектурной задумке. Из применения std::shared_ptr для хранения реализации следует сделать вывод, что класс задуман для обеспечения совместного владения.
Чем это отличается от классической утечки — выделения памяти с помощью явного вызова new без последующего удаления? Точно так же в интерфейсе было бы всё красиво, а в реализации — баг.
Мы тут обсуждаем современные способы прострелить себе ногу.
Итак, из вышеприведённого материала понятно:
— умные указатели могут завязываться в узлы;
— применение std::enable_shared_from_this может этому способствовать, т.к. позволяет экземпляру класса завязаться в узел почти без посторонней помощи.
А теперь — внимание — ключевой вопрос статьи: имеет ли значение тип ресурса, завёрнутого в умный указатель? Есть ли разница между RAII-заботой о файле и RAII-заботой об HTTPS-соединении в асинхронном исполнении?
Общий для всех последующих примеров зомби код вынесен в библиотеку Common.
Абстрактный интерфейс зомби со скромным названием Manager:
#pragma once
#include <memory>
namespace Common {
class Listener;
class Manager
{
public:
Manager() = default;
Manager(const Manager&) = delete;
Manager(Manager&&) = delete;
Manager& operator=(const Manager&) = delete;
Manager& operator=(Manager&&) = delete;
virtual ~Manager() = default;
virtual void runOnce(std::shared_ptr<Common::Listener> listener) = 0;
};
} // namespace Common
Абстрактный интерфейс Listener'a, готового потокобезопасно принимать текст:
#pragma once
#include <string>
#include <memory>
namespace Common {
class Listener
{
public:
virtual ~Listener() = default;
using Data = std::string;
// thread-safe
virtual void processData(const std::shared_ptr<const Data> data) = 0;
};
} // namespace Common
Listener, отображающий текст в консоль. Реализует концепцию SingletonShared из моей статьи Техника избежания неопределённого поведения при обращении к синглтону [3]:
#pragma once
#include <mutex>
#include "Common/Listener.h"
namespace Common {
class WriteToConsoleListener final : public Listener
{
public:
WriteToConsoleListener(const WriteToConsoleListener&) = delete;
WriteToConsoleListener(WriteToConsoleListener&&) = delete;
WriteToConsoleListener& operator=(const WriteToConsoleListener&) = delete;
WriteToConsoleListener& operator=(WriteToConsoleListener&&) = delete;
~WriteToConsoleListener() override;
static std::shared_ptr<WriteToConsoleListener> instance();
// blocking
void processData(const std::shared_ptr<const Data> data) override;
private:
WriteToConsoleListener();
std::mutex _mutex;
};
} // namespace Common
#include <iostream>
#include "WriteToConsoleListener.h"
namespace Common {
WriteToConsoleListener::WriteToConsoleListener() = default;
WriteToConsoleListener::~WriteToConsoleListener()
{
auto lock = std::lock_guard(_mutex);
std::cout << typeid(*this).name() << "::" << __func__ << std::endl;
}
std::shared_ptr<WriteToConsoleListener> WriteToConsoleListener::instance()
{
static auto inst = std::shared_ptr<WriteToConsoleListener>(new WriteToConsoleListener);
return inst;
}
void WriteToConsoleListener::processData(const std::shared_ptr<const Data> data)
{
if (data) {
auto lock = std::lock_guard(_mutex);
std::cout << *data << std::flush;
}
}
} // namespace Common
И, наконец, первый зомби, самый простой и бесхитростный.
#pragma once
#include <memory>
#include <atomic>
#include <thread>
#include "Common/Manager.h"
namespace Common {
class Listener;
} // namespace Common
namespace SimpleZomby {
class Zomby final : public Common::Manager, public std::enable_shared_from_this<Zomby>
{
public:
static std::shared_ptr<Zomby> create();
~Zomby() override;
void runOnce(std::shared_ptr<Common::Listener> listener) override;
private:
Zomby();
using Semaphore = std::atomic<bool>;
std::shared_ptr<Common::Listener> _listener;
Semaphore _semaphore = false;
std::thread _thread;
};
} // namespace SimpleZomby
#include <sstream>
#include "SimpleZomby.h"
#include "Common/Listener.h"
namespace SimpleZomby {
std::shared_ptr<Zomby> Zomby::create()
{
return std::shared_ptr<Zomby>(new Zomby());
}
Zomby::Zomby() = default;
Zomby::~Zomby()
{
_semaphore = false;
_thread.detach();
if (_listener) {
std::ostringstream buf;
buf << typeid(*this).name() << "::" << __func__ << std::endl;
_listener->processData(std::make_shared<Common::Listener::Data>(buf.str()));
}
}
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SimpleZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([shis = shared_from_this()](){
while (shis && shis->_listener && shis->_semaphore) {
shis->_listener->processData(std::make_shared<Common::Listener::Data>("SimpleZomby is alive!n"));
std::this_thread::sleep_for(std::chrono::seconds(1));
}
});
}
} // namespace SimpleZomby
Зомби запускает в отдельном потоке лямбда-функцию, периодически отправляющую строку в listener. Лямбда-функции для работы нужны семафор и listener, являющиеся полями класса зомби. Лямбда-функция не захватывает их как отдельные поля, а использует объект в качестве агрегатора. Уничтожение экземпляра класса зомби до завершения работы лямбда-функции приведёт к неопределённому поведению. Чтобы этого избежать, лямбда-функция захватывает сильную копию shared_from_this().
В деструкторе зомби семафор устанавливается в false, после чего вызывается detach() для потока. Установка семафора сообщает потоку о необходимости завершения работы.
В деструкторе надо было вызывать не detach(), а join()!
… и получить деструктор, блокирующий выполнение на неопределённое время, что может являться неприемлемым.
Так это же нарушение RAII! RAII должно было выйти из деструктора только после освобождения ресурса!
Если строго — то да, деструктор зомби не осуществляет освобождение ресурса, а только гарантирует, что освобождение будет произведено. Когда-нибудь произведено — может скоро, а может и не очень. И возможно даже, что main завершит работу раньше — тогда поток будет принудительно зачищен операционной системой. Но на самом деле, грань между «правильным» и «неправильным» RAII может быть очень тонкой: например, «правильное» RAII, осуществляющее в деструкторе вызов std::filesystem::remove() для временного файла, вполне может вернуть управление в тот момент, когда команда на запись ещё будет находиться в каком-нибудь из энергозависимых кэшей и не будет честно записана на магнитную пластину жёсткого диска.
#include <chrono>
#include <thread>
#include <sstream>
#include "Common/Impl/WriteToConsoleListener.h"
#include "SimpleZomby/SimpleZomby.h"
int main()
{
auto writeToConsoleListener = Common::WriteToConsoleListener::instance();
{
auto simpleZomby = SimpleZomby::Zomby::create();
simpleZomby->runOnce(writeToConsoleListener);
std::this_thread::sleep_for(std::chrono::milliseconds(4500));
} // Zomby should be killed here
{
std::ostringstream buf;
buf << "============================================================n"
<< "| Zomby was killed |n"
<< "============================================================n";
if (writeToConsoleListener) {
writeToConsoleListener->processData(std::make_shared<Common::Listener::Data>(buf.str()));
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(5000));
return 0;
}
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
============================================================
| Zomby was killed |
============================================================
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
Что видно из вывода программы:
— зомби продолжил работу даже после выхода из области видимости;
— не были вызваны деструкторы ни для зомби, ни для WriteToConsoleListener.
Возникла утечка памяти.
Возникла утечка ресурса. А ресурс в данном случае — поток выполнения.
Код, который должен был остановиться, продолжил работу в отдельном потоке.
Утечку WriteToConsoleListener можно было бы предотвратить применением техники SingletonWeak из моей статьи Техника избежания неопределённого поведения при обращении к синглтону [3], но я намеренно не стал этого делать.

(изображение взято отсюда [4])
Почему «Зомби»?
Потому что его убили, а он всё ещё жив.
Чем это отличается от циклических ссылок из предыдущих примеров?
Тем, что потерянный ресурс — это не просто участок памяти, а нечто, самостоятельно выполняющее код независимо от запустившего его потока.
Можно ли уничтожить «Зомби»?
После выхода из области видимости (т.е. после уничтожения всех внешних сильных и слабых ссылок на зомби) — нельзя. Зомби уничтожится тогда, когда сам решит уничтожиться (да-да, это же нечто с активным поведением), возможно — никогда, т.е. доживёт до момента зачистки операционной системой при завершении приложения. Конечно, пользовательский код может иметь какое-то влияние на условие выхода из зомби-кода, но это влияние будет опосредованным и зависящим от реализации.
А до выхода из области видимости?
Можно явно вызвать деструктор зомби, но при этом вряд ли удастся избежать неопределённого поведения из-за повторного уничтожения объекта ещё и деструктором умного указателя — это борьба с RAII. Или можно добавить функцию явной деинициализации — а это отказ от RAII.
Чем это отличается от простого запуска потока с последущим detach()?
В случае с зомби, в отличие от простого вызова detach(), присутствует задумка на остановку потока. Только она не срабатывает. Присутствие правильной задумки способствует маскировке проблемы.
Пример всё ещё синтетический?
Частично. В данном простом примере не было достаточных оснований для применения shared_from_this() — например, можно было обойтись захватом weak_from_this() или захватом всех нужных полей класса. Но при усложнении задачи баланс может смещаться в сторону
shared_from_this().
Valgrind, Valgrind! У нас же есть дополнительная линия защиты от зомби!
Увы и ах — но Valgrind не выявил утечку памяти. Почему — я не знаю. В диагностике присутствуют только записи «possibly lost», указывающие на системные функции — примерно такие же и примерно в том же количестве, что и при отработке пустого main. Указания на пользовательский код отсутствуют. Возможно, другие инструменты динамического анализа справились бы лучше, но если Вы всё ещё надеетесь на них — читайте дальше.
Код в данном примере продвигается по шагам resolveDnsName ---> connectTcp ---> establishSsl ---> sendHttpRequest ---> readHttpReply, имитируя работу клиентского HTTPS-соединения в асинхронном исполнении. Каждый шаг занимает примерно секунду.
#pragma once
#include <memory>
#include <atomic>
#include <thread>
#include "Common/Manager.h"
namespace Common {
class Listener;
} // namespace Common
namespace SteppingZomby {
class Zomby final : public Common::Manager, public std::enable_shared_from_this<Zomby>
{
public:
static std::shared_ptr<Zomby> create();
~Zomby() override;
void runOnce(std::shared_ptr<Common::Listener> listener) override;
private:
Zomby();
using Semaphore = std::atomic<bool>;
std::shared_ptr<Common::Listener> _listener;
Semaphore _semaphore = false;
std::thread _thread;
void resolveDnsName();
void connectTcp();
void establishSsl();
void sendHttpRequest();
void readHttpReply();
};
} // namespace SteppingZomby
#include <sstream>
#include <string>
#include "SteppingZomby.h"
#include "Common/Listener.h"
namespace {
void doSomething(Common::Listener& listener, std::string&& callingFunctionName)
{
listener.processData(std::make_shared<Common::Listener::Data>(callingFunctionName + " startedn"));
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
listener.processData(std::make_shared<Common::Listener::Data>(callingFunctionName + " finishedn"));
}
} // namespace
namespace SteppingZomby {
Zomby::Zomby() = default;
std::shared_ptr<Zomby> Zomby::create()
{
return std::shared_ptr<Zomby>(new Zomby());
}
Zomby::~Zomby()
{
_semaphore = false;
_thread.detach();
if (_listener) {
std::ostringstream buf;
buf << typeid(*this).name() << "::" << __func__ << std::endl;
_listener->processData(std::make_shared<Common::Listener::Data>(buf.str()));
}
}
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SteppingZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([shis = shared_from_this()](){
if (shis && shis->_listener && shis->_semaphore) {
shis->resolveDnsName();
}
if (shis && shis->_listener && shis->_semaphore) {
shis->connectTcp();
}
if (shis && shis->_listener && shis->_semaphore) {
shis->establishSsl();
}
if (shis && shis->_listener && shis->_semaphore) {
shis->sendHttpRequest();
}
if (shis && shis->_listener && shis->_semaphore) {
shis->readHttpReply();
}
});
}
void Zomby::resolveDnsName()
{
doSomething(*_listener, std::string(typeid(*this).name()) + "::" + __func__);
}
void Zomby::connectTcp()
{
doSomething(*_listener, std::string(typeid(*this).name()) + "::" + __func__);
}
void Zomby::establishSsl()
{
doSomething(*_listener, std::string(typeid(*this).name()) + "::" + __func__);
}
void Zomby::sendHttpRequest()
{
doSomething(*_listener, std::string(typeid(*this).name()) + "::" + __func__);
}
void Zomby::readHttpReply()
{
doSomething(*_listener, std::string(typeid(*this).name()) + "::" + __func__);
}
} // namespace SteppingZomby
#include <chrono>
#include <thread>
#include <sstream>
#include "SteppingZomby/SteppingZomby.h"
#include "Common/Impl/WriteToConsoleListener.h"
int main()
{
auto writeToConsoleListener = Common::WriteToConsoleListener::instance();
{
auto steppingZomby = SteppingZomby::Zomby::create();
steppingZomby->runOnce(writeToConsoleListener);
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
} // Zombies should be killed here
{
std::ostringstream buf;
buf << "============================================================n"
<< "| Zomby was killed |n"
<< "============================================================n";
if (writeToConsoleListener) {
writeToConsoleListener->processData(std::make_shared<Common::Listener::Data>(buf.str()));
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(5000));
return 0;
}
N13SteppingZomby5ZombyE::resolveDnsName started
N13SteppingZomby5ZombyE::resolveDnsName finished
N13SteppingZomby5ZombyE::connectTcp started
============================================================
| Zomby was killed |
============================================================
N13SteppingZomby5ZombyE::connectTcp finished
N13SteppingZomby5ZombyE::establishSsl started
N13SteppingZomby5ZombyE::establishSsl finished
N13SteppingZomby5ZombyE::sendHttpRequest started
N13SteppingZomby5ZombyE::sendHttpRequest finished
N13SteppingZomby5ZombyE::readHttpReply started
N13SteppingZomby5ZombyE::readHttpReply finished
N13SteppingZomby5ZombyE::~Zomby
N6Common22WriteToConsoleListenerE::~WriteToConsoleListener
Как и в предыдущем примере, вызов runOnce() привёл к возникновению циклической ссылки.
Но на этот раз деструкторы Zomby и WriteToConsoleListener были вызваны. Все ресурсы были корректно освобождены до момента завершения приложения. Утечки памяти не произошло.
В чём же тогда проблема?
Проблема в том, что зомби прожил слишком долго — примерно три с половиной секунды после уничтожения всех внешних сильных и слабых ссылок на него. Примерно на три секунды дольше, чем ему следовало прожить. И всё это время он занимался продвижением выполнения HTTPS-соединения — до тех пор, пока не довёл его до конца. Несмотря на то, что результат уже не был нужен. Несмотря на то, что вышестоящая бизнес-логика пыталась остановить зомби.
Ну подумаешь, получили никому не нужный ответ....
В случае с клиентским HTTPS-соединением последствия на нашей стороне могут быть следующими:
— расход памяти;
— расход процессора;
— расход TCP-портов;
— расход полосы пропускания канала связи (как запрос, так и ответ могут быть объёмом в мегабайты);
— нежданные данные могут нарушить работу вышестоящей бизнес-логики — вплоть до перехода на неправильную ветвь выполнения или до неопределённого поведения, т.к. механизмы обработки ответа могут быть уже уничтожены.
А на удалённой стороне (не забывайте — HTTPS-запрос кому-то предназначался) — точно такая же растрата ресурсов, плюс возможно:
— опубликование фотографий котиков на корпоративном сайте;
— отключение тёплого пола у Вас на кухне;
— исполнение торгового приказа на бирже;
— перевод денег с Вашего счёта;
— запуск межконтинентальной баллистической ракеты.
Бизнес-логика пыталась остановить зомби, удалив все сильные и слабые ссылки на него. Остановка продвижения HTTPS-запроса должна была произойти — было ещё не слишком поздно, данные прикладного уровня ещё не были отправлены.
Но зомби решил по-своему.
Бизнес-логика может создавать новые объекты на место зомби и снова пытаться их уничтожить, кратно увеличивая утечку ресурсов.
В случае с длящимся процессом (например, Websocket-соединением) растрата ресурсов может продолжаться часами, а при наличии в реализации механизма авто-переподключения при обрыве соединения — вообще до остановки программы.
Valgrind?
Без шансов. Всё корректно освобождено и подчищено. Поздно и не из главного потока, но полностью корректно.
В данном примере используется библиотека boozd::azzio, являющаяся имитацией boost::asio. Несмотря на то, что имитация довольно грубая, она позволяет продемонстрировать суть проблемы. В библиотеке есть функция io_context::async_read (в оригинале она свободная, но сути это не меняет), принимающая:
— stream, из которого могут приходить данные;
— буфер, позволяющий эти данные накапливать;
— callback-функцию, которая будет вызвана по завершении считывания данных.
Функция io_context::async_read выполняется мгновенно и никогда не вызывает callback, даже если результат выполнения уже известен (например, ошибка). Вызов коллбэка происходит только из блокирующей функции io_context::run() (в оригинале есть и другие функции, предназначенные для вызова коллбэков по мере готовности данных).
#pragma once
#include <vector>
namespace boozd::azzio {
using buffer = std::vector<int>;
} // namespace boozd::azzio
#pragma once
#include <optional>
namespace boozd::azzio {
class stream
{
public:
virtual ~stream() = default;
virtual std::optional<int> read() = 0;
};
} // namespace boozd::azzio
#pragma once
#include <functional>
#include <optional>
#include "buffer.h"
namespace boozd::azzio {
class stream;
class io_context
{
public:
~io_context();
enum class error_code {no_error, good_error, bad_error, unknown_error, known_error, well_known_error};
using handler = std::function<void(error_code)>;
// Start an asynchronous operation to read a certain amount of data from a stream.
// This function is used to asynchronously read a certain number of bytes of data from a stream.
// The function call always returns immediately.
void async_read(stream& s, buffer& b, handler&& handler);
// Run the io_context object's event processing loop.
void run();
private:
using pack = std::tuple<stream&, buffer&>;
using pack_optional = std::optional<pack>;
using handler_optional = std::optional<handler>;
pack_optional _pack_optional;
handler_optional _handler_optional;
};
} // namespace boozd::azzio
#include <iostream>
#include <thread>
#include <chrono>
#include "io_context.h"
#include "stream.h"
namespace boozd::azzio {
io_context::~io_context()
{
std::cout << typeid(*this).name() << "::" << __func__ << std::endl;
}
void io_context::async_read(stream& s, buffer& b, io_context::handler&& handler)
{
_pack_optional.emplace(s, b);
_handler_optional.emplace(std::move(handler));
}
void io_context::run()
{
if (_pack_optional && _handler_optional) {
auto& [s, b] = *_pack_optional;
using namespace std::chrono;
auto start = steady_clock::now();
while (duration_cast<milliseconds>(steady_clock::now() - start).count() < 1000) {
if (auto read = s.read())
b.emplace_back(*read);
std::this_thread::sleep_for(milliseconds(100));
}
(*_handler_optional)(error_code::no_error);
}
}
} // namespace boozd::azzio
Единственная реализация интерфейса boozd::azzio::stream, выдающая случайные данные:
#pragma once
#include "boozd/azzio/stream.h"
namespace boozd::azzio {
class random_stream final : public stream
{
public:
~random_stream() override;
std::optional<int> read() override;
};
} // namespace boozd::azzio
#include <iostream>
#include "random_stream.h"
namespace boozd::azzio {
boozd::azzio::random_stream::~random_stream()
{
std::cout << typeid(*this).name() << "::" << __func__ << std::endl;
}
std::optional<int> random_stream::read()
{
if (!(rand() & 0x1))
return rand();
return std::nullopt;
}
} // namespace boozd::azzio
BoozdedZomby запускает в отдельном потоке лямбда-функцию. Лямбда-функция регистрирует обработчик с помощью вызова async_read(), после чего отдаёт управление внутренним механизмам boozd::azzio с помощью run(). После этого внутренние механизмы boozd::azzio могут производить обращения к буферу и потоку (источнику данных) в любой момент до вызова callback-функции. Для обеспечения гарантии валидности множества объектов, агрегированных в экземпляре класса, лямбда-функция захватывает shared_from_this.
#pragma once
#include <memory>
#include <atomic>
#include <thread>
#include "Common/Manager.h"
#include "boozd/azzio/buffer.h"
#include "boozd/azzio/io_context.h"
#include "boozd/azzio/impl/random_stream.h"
namespace Common {
class Listener;
} // namespace Common
namespace BoozdedZomby {
class Zomby final : public Common::Manager, public std::enable_shared_from_this<Zomby>
{
public:
static std::shared_ptr<Zomby> create();
~Zomby() override;
void runOnce(std::shared_ptr<Common::Listener> listener) override;
private:
Zomby();
using Semaphore = std::atomic<bool>;
Semaphore _semaphore = false;
std::shared_ptr<Common::Listener> _listener;
boozd::azzio::random_stream _stream;
boozd::azzio::buffer _buffer;
boozd::azzio::io_context _context;
std::thread _thread;
};
} // namespace BoozdedZomby
#include <iostream>
#include <sstream>
#include "boozd/azzio/impl/random_stream.h"
#include "BoozdedZomby.h"
#include "Common/Listener.h"
namespace BoozdedZomby {
Zomby::Zomby() = default;
std::shared_ptr<Zomby> Zomby::create()
{
return std::shared_ptr<Zomby>(new Zomby());
}
Zomby::~Zomby()
{
if (_semaphore && _thread.joinable()) {
if (_thread.get_id() == std::this_thread::get_id()) {
_thread.detach();
} else {
_semaphore = false;
_thread.join();
}
}
if (_listener) {
std::ostringstream buf;
buf << typeid(*this).name() << "::" << __func__ << std::endl;
_listener->processData(std::make_shared<Common::Listener::Data>(buf.str()));
}
}
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("BoozdedZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([shis = shared_from_this()]() {
while (shis && shis->_semaphore && shis->_listener) {
auto handler = [shis](auto errorCode) {
if (shis && shis->_listener && errorCode == boozd::azzio::io_context::error_code::no_error) {
std::ostringstream buf;
buf << "BoozdedZomby has got a fresh data: ";
for (auto const &elem : shis->_buffer)
buf << elem << ' ';
buf << std::endl;
shis->_listener->processData(std::make_shared<Common::Listener::Data>(buf.str()));
}
};
shis->_buffer.clear();
shis->_context.async_read(shis->_stream, shis->_buffer, handler);
shis->_context.run();
}
});
}
} // namespace BoozdedZomby
#include <chrono>
#include <thread>
#include <sstream>
#include "BoozdedZomby/BoozdedZomby.h"
#include "Common/Impl/WriteToConsoleListener.h"
int main()
{
auto writeToConsoleListener = Common::WriteToConsoleListener::instance();
{
auto boozdedZomby = BoozdedZomby::Zomby::create();
boozdedZomby->runOnce(writeToConsoleListener);
std::this_thread::sleep_for(std::chrono::milliseconds(4500));
} // Zombies should be killed here
{
std::ostringstream buf;
buf << "============================================================n"
<< "| Zomby was killed |n"
<< "============================================================n";
if (writeToConsoleListener) {
writeToConsoleListener->processData(std::make_shared<Common::Listener::Data>(buf.str()));
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(5000));
return 0;
}
BoozdedZomby has got a fresh data: 1144108930 101027544 1458777923 1115438165 74243042
BoozdedZomby has got a fresh data: 143542612 1131570933
BoozdedZomby has got a fresh data: 893351816 563613512 704877633
BoozdedZomby has got a fresh data: 1551901393 1399125485 1899894091 937186357 590357944 357571490
============================================================
| Zomby was killed |
============================================================
BoozdedZomby has got a fresh data: 1927702196 130060903 1083454666 2118797801 2035308228 824938981
BoozdedZomby has got a fresh data: 2020739063 1635339425 34075629
BoozdedZomby has got a fresh data: 2146319451 500782188 1269406752 884936716 892053144
BoozdedZomby has got a fresh data: 330111137 1723153177 1070477904
BoozdedZomby has got a fresh data: 343098142 280090412 589673557 889688008 2014119113 388471006
В результате вызова run_once() возникла циклическая ссылка. Зомби продолжил работу даже после выхода из области видимости. Не были вызваны деструкторы для множества объектов, созданных в ходе работы программы:
— boozdedZomby;
— writeToConsoleListener;
— полей данных зомби.
Возникла утечка памяти.
Возникла утечка ресурса.
Чем этот пример отличается от предыдущих?
Он намного ближе к реальному коду. Это уже совсем не синтетический пример. Такой код вполне может естественным образом возникать при использовании boost::asio. Более того, его не получится исправить простым отказом от захвата сильной ссылки в пользу слабой — это помешает обеспечению валидности буфера и потока (источника данных).
Valgrind?
Мимо. Хотя вроде бы должен был обнаружить утечки.
Проблема надуманная! Так никто не пишет!
Ещё как пишет.
Пример HTTP-клиента [5]
Пример Websocket-клиента [6]
Официальная документация на boost учит, как написать гибрид BoozdedZomby + SteppingZomby. Остановить его невозможно, но никто и не пытается. Конкретно в демонстрационном коде основное свойство зомби не проявляется, но стоит перенести это в production — и вот Вы уже ходите вдоль края, скорее всего даже на тёмной стороне.
Можно остановить зомби, уничтожив экземпляр boost::asio::io_context!
… попутно уничтожив ещё n сущностей (возможно, не-зомби), живущих в данном контексте.
Ещё примеры:
Вот похожий пример на стороннем ресурсе [7]
Вот человек задаёт на stackoverflow вопрос, как бы ему сделать его код более зомбистым [8]
Вот ещё один спрашивает, почему его любимый зомби не работает [9]
Вот человек напуган сообщениями об утечках памяти при эксплуатации зомби [10]
Конечно, в статье описаны не все разновидности антипаттерна «Зомби».
Он может встречаться как в виде гибридов вышеприведённых типов, так и в виде новых самостоятельных типов.
Антипаттерн может возникать не только при запуске std::thread в Вашем коде — эту часть работы может взять на себя сторонняя многопоточная библиотека.
Циклическая ссылка может быть более длинной, чем в примерах.
Архитектура может быть как event-driven, так и на основе периодического опроса состояний (polling-based).
Это всё не очень важно.
Важно, что всегда антипаттерн начинается с получения экземпляром класса сильной ссылки на самого себя. Она почти всегда генерируется с помощью std::enable_shared_from_this, хотя может быть предоставлена и извне (в том числе в виде слабой ссылки — класс может самостоятельно сделать из неё сильную). Пожалуй, есть только одно экзотическое исключение из этого правила: когда внешний код предоставляет сильную или слабую ссылку на экземпляр класса кому-то из его полей данных.
Динамический анализ кода может оказаться не в силах обнаруживать этот антипаттерн, особенно его разновидность SteppingZomby. На статический анализ тоже надежды мало — очень уж тонкая грань между корректным и некорректным использованием shared_from_this (все примеры кода, приведённые в статье, можно исправить внесением очень небольших правок — всего от 1 до 6 строк кода).
Автотесты могут помочь в его выявлении и проверке корректности устранения — но для этого надо знать, что искать. Совершенно точно знать.
Искать антипаттерн, сюдя по всему, придётся вручную. А для этого надо пересматривать все применения std::enable_shared_from_this — они очень опасны.
Автор: Александр Дубовик
Источник [11]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-3/336046
Ссылки в тексте:
[1] гитхабе: https://github.com/ADDubovik/Zomby_antipattern_concept
[2] отсюда: https://photoshop-master.ru/lessons/photo/zavyazyivaem-gvozd-v-fotoshop.html
[3] Техника избежания неопределённого поведения при обращении к синглтону: https://habr.com/ru/post/471326/
[4] отсюда: https://www.behance.net/gallery/1166449/Fatherland-Defenderas-Day
[5] Пример HTTP-клиента: https://www.boost.org/doc/libs/master/libs/beast/example/http/client/async/http_client_async.cpp
[6] Пример Websocket-клиента: https://www.boost.org/doc/libs/develop/libs/beast/example/websocket/client/async/websocket_client_async.cpp
[7] Вот похожий пример на стороннем ресурсе: http://kangssu.com/2019/01/23/beast-websocket-client-async.html
[8] Вот человек задаёт на stackoverflow вопрос, как бы ему сделать его код более зомбистым : https://stackoverflow.com/questions/43036793/handling-lifetime-in-a-boost-asio-server
[9] Вот ещё один спрашивает, почему его любимый зомби не работает: https://stackoverflow.com/questions/50785372/bad-weak-ptr-while-using-class-inheriting-from-boostasioio-contextservice
[10] Вот человек напуган сообщениями об утечках памяти при эксплуатации зомби: https://stackoverflow.com/questions/56330058/memory-leak-at-async-handshake-using-boost-beast-and-openssl
[11] Источник: https://habr.com/ru/post/471326/?utm_source=habrahabr&utm_medium=rss&utm_campaign=471326
Нажмите здесь для печати.