- PVSM.RU - https://www.pvsm.ru -

Сказ об опасном std::enable_shared_from_this, или антипаттерн «Зомби»

В статье приводится опасный антипаттерн «Зомби», в некоторых ситуациях естественным образом возникающий при использовании std::enable_shared_from_this. Материал — где-то на стыке техники современного C++ и архитектуры.

Введение

C++11 предоставил разработчику замечательные инструменты для работы с памятью — умные указатели std::unique_ptr и связку std::shared_ptr + std::weak_ptr. Использование умных указателей по удобству и безопасности существенно перевешивает использование сырых указателей. Умные указатели широко применяются на практике, т.к. позволяют разработчику сосредоточиться на более высокоуровневых вопросах, чем отслеживание корректности создания/удаления динамически создаваемых сущностей.
Частью стандарта является также шаблон класса std::enable_shared_from_this, при первом знакомстве кажущийся довольно странным.
В статье пойдёт речь о том, как можно вляпаться при его использовании.

Ликбез

RAII и умные указатели

Прямое назначение умных указателей — заботиться об участке оперативной памяти, выделенной в куче. Умные указатели реализуют идиому RAII (Resource acquisition is initialization), и их с лёгкостью можно адаптировать для заботы о других типах ресурсов, требующих инициализации и нетривиальной деинициализации, таких как:
— файлы;
— временные папки на диске;
— сетевые соединения (http, websockets);
— потоки выполнения (threads);
— мьютексы;
— прочее (на что хватит фантазии).
Для такого обобщения достаточно написать класс (на самом деле иногда можно даже класс не писать, а просто воспользоваться deleter — но сегодня сказ не о том), осуществляющий:
— инициализацию в конструкторе либо отдельном методе;
— деинициализацию в деструкторе,
после чего «завернуть» его в соответствующий умный указатель в зависимости от требуемой модели владения — совместного (std::shared_ptr) либо единоличного (std::unique_ptr). При этом получается «двухслойное RAII»: умный указатель позволяет передавать/разделять владение ресурсом, а инициализацию/деинициализацию нестандартного ресурса осуществляет пользовательский класс.
std::shared_ptr использует механизм подсчёта ссылок. Стандартом определены счётчик сильных ссылок (подсчитывает количество существующих копий std::shared_ptr) и счётчик слабых ссылок (подсчитывает количество существующих экземпляров std::weak_ptr, созданных для данного экземпляра std::shared_ptr). Наличие хотя бы одной сильной ссылки гарантирует, что уничтожение ещё не произведено. Данное свойство std::shared_ptr широко применяется для обеспечения валидности объекта до тех пор, пока работа с ним не будет завершена во всех участках программы. Наличие же слабой ссылки не препятствует уничтожению объекта и позволяет получить сильную ссылку только до момента его уничтожения.
RAII гарантирует освобождение ресурса намного надёжнее, чем явный вызов delete/delete[]/free/close/reset/unlock, т.к.:
— явный вызов можно просто забыть;
— явный вызов можно ошибочно осуществить более одного раза;
— явный вызов сложен при реализации совместного владения ресурсом;
— механизм раскрутки стека в c++ гарантирует вызов деструкторов для всех объектов, выходящих из области видимости в случае возникновения исключения.
Гарантия деинициализации в идиоме настолько важна, что по-хорошему заслуживает места в названии идиомы наравне с инициализацией.
У умных указателей есть и недостатки:
— наличие накладных расходов по производительности и памяти (для большинства применений не является существенным);
— возможность возникновения циклических ссылок, блокирующих освобождение ресурса и приводящих к его утечке.
Наверняка каждый разработчик не раз читал про циклические ссылки и видел синтетические примеры проблемного кода.
Опасность может казаться несущественной по следующим причинам:
— если память утекает часто и много — это заметно по её расходу, а если редко и мало — то проблема вряд ли проявится на уровне конечного пользователя;
— используется динамический анализ кода на предмет утечек (Valgrind, Clang LeakSanitizer и т.п.);
— «я ж так не пишу»;
— «у меня архитектура правильная»;
— «у нас код проходит ревью».

std::enable_shared_from_this

В C++11 появился вспомогательный класс std::enable_shared_from_this. Для разработчика, успешно строившего код без std::enable_shared_from_this, потенциальные применения этого класса могут быть неочевидны.
Что же делает std::enable_shared_from_this?
Он позволяет функциям-членам класса, экземпляр которого создан в std::shared_ptr, получить дополнительные сильные (shared_from_this()) или слабые (weak_from_this(), начиная с C++17) копии того std::shared_ptr, в котором он был создан. Вызывать shared_from_this() и weak_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++

SimpleCyclic

Вроде бы ничего не предвещает проблем. Объявление класса выглядит просто и понятно. За исключением одной «мелкой» детали — зачем-то применено наследование от std::enable_shared_from_this.

SimpleCyclic.h

#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

А в реализации:

SimpleCyclic.cpp

#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

main.cpp

#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 не вызывается.

Экземпляр класса «держит» себя сам.
Код завязался в узел.

Сказ об опасном std::enable_shared_from_this, или антипаттерн «Зомби» - 1
(изображение взято отсюда [2])

И что, это и есть антипаттерн «Зомби»?
Нет, это только разминка. Всё самое интересное ещё впереди.

Зачем разработчик такое написал?
Пример синтетический. Мне не известны какие-либо ситуации, в которых гармонично получался бы такой код.

И что, неужели динамический анализ кода промолчал?
Нет, Valgrind честно сообщил о состоявшейся утечке памяти:

Сообщение 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

PimplCyclic

В данном случае заголовочный файл выглядит совершенно корректно и лаконично. В нём объявлен фасад, хранящий некую реализацию в std::shared_ptr. Наследование — в том числе от std::enable_shared_from_this — отсутствует, в отличие от прошлого примера.

PimplCyclic.h

#pragma once

#include <memory>

namespace PimplCyclic {
class Cyclic
{
public:
    Cyclic();
    ~Cyclic();

private:
    class Impl;
    std::shared_ptr<Impl> _impl;
};
} // namespace PimplCyclic

А в реализации:

PimplCyclic.cpp

#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

main.cpp

#include "PimplCyclic/PimplCyclic.h"

int main()
{
    auto pimplCyclic = PimplCyclic::Cyclic();

    return 0;
}

Вывод в консоль

N11PimplCyclic6Cyclic4ImplE::doSomething
N11PimplCyclic6CyclicE::~Cyclic

Вызов Impl::doSomething() приводит к образованию циклической ссылки в экземпляре класса Impl. Фасад уничтожается корректно, а вот реализация утекает. Деструктор PimplCyclic::Cyclic::Impl::~Impl не вызывается.
Пример опять синтетический, но на сей раз более опасный — вся плохая техника расположена в реализации и никак не проявляется в объявлении.
Более того, для возникновения циклической ссылки от пользовательского кода не потребовалось никаких действий, кроме конструирования.
Динамический анализ в лице Valgrind и в этот раз выявил утечку:

Сообщение 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-соединении в асинхронном исполнении?

SimpleZomby

Общий для всех последующих примеров зомби код вынесен в библиотеку Common.

Абстрактный интерфейс зомби со скромным названием Manager:

Common/Manager.h

#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, готового потокобезопасно принимать текст:

Common/Listener.h

#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]:

Common/Impl/WriteToConsoleListener.h

#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

Common/Impl/WriteToConsoleListener.cpp

#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

И, наконец, первый зомби, самый простой и бесхитростный.

SimpleZomby.h

#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

SimpleZomby.cpp

#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() для временного файла, вполне может вернуть управление в тот момент, когда команда на запись ещё будет находиться в каком-нибудь из энергозависимых кэшей и не будет честно записана на магнитную пластину жёсткого диска.

main.cpp

#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], но я намеренно не стал этого делать.

Сказ об опасном std::enable_shared_from_this, или антипаттерн «Зомби» - 2
(изображение взято отсюда [4])

Почему «Зомби»?
Потому что его убили, а он всё ещё жив.

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

Можно ли уничтожить «Зомби»?
После выхода из области видимости (т.е. после уничтожения всех внешних сильных и слабых ссылок на зомби) — нельзя. Зомби уничтожится тогда, когда сам решит уничтожиться (да-да, это же нечто с активным поведением), возможно — никогда, т.е. доживёт до момента зачистки операционной системой при завершении приложения. Конечно, пользовательский код может иметь какое-то влияние на условие выхода из зомби-кода, но это влияние будет опосредованным и зависящим от реализации.

А до выхода из области видимости?
Можно явно вызвать деструктор зомби, но при этом вряд ли удастся избежать неопределённого поведения из-за повторного уничтожения объекта ещё и деструктором умного указателя — это борьба с RAII. Или можно добавить функцию явной деинициализации — а это отказ от RAII.

Чем это отличается от простого запуска потока с последущим detach()?
В случае с зомби, в отличие от простого вызова detach(), присутствует задумка на остановку потока. Только она не срабатывает. Присутствие правильной задумки способствует маскировке проблемы.

Пример всё ещё синтетический?
Частично. В данном простом примере не было достаточных оснований для применения shared_from_this() — например, можно было обойтись захватом weak_from_this() или захватом всех нужных полей класса. Но при усложнении задачи баланс может смещаться в сторону
shared_from_this().

Valgrind, Valgrind! У нас же есть дополнительная линия защиты от зомби!
Увы и ах — но Valgrind не выявил утечку памяти. Почему — я не знаю. В диагностике присутствуют только записи «possibly lost», указывающие на системные функции — примерно такие же и примерно в том же количестве, что и при отработке пустого main. Указания на пользовательский код отсутствуют. Возможно, другие инструменты динамического анализа справились бы лучше, но если Вы всё ещё надеетесь на них — читайте дальше.

SteppingZomby

Код в данном примере продвигается по шагам resolveDnsName ---> connectTcp ---> establishSsl ---> sendHttpRequest ---> readHttpReply, имитируя работу клиентского HTTPS-соединения в асинхронном исполнении. Каждый шаг занимает примерно секунду.

SteppingZomby.h

#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

SteppingZomby.cpp

#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

main.cpp

#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?
Без шансов. Всё корректно освобождено и подчищено. Поздно и не из главного потока, но полностью корректно.

BoozdedZomby

В данном примере используется библиотека boozd::azzio, являющаяся имитацией boost::asio. Несмотря на то, что имитация довольно грубая, она позволяет продемонстрировать суть проблемы. В библиотеке есть функция io_context::async_read (в оригинале она свободная, но сути это не меняет), принимающая:
— stream, из которого могут приходить данные;
— буфер, позволяющий эти данные накапливать;
— callback-функцию, которая будет вызвана по завершении считывания данных.
Функция io_context::async_read выполняется мгновенно и никогда не вызывает callback, даже если результат выполнения уже известен (например, ошибка). Вызов коллбэка происходит только из блокирующей функции io_context::run() (в оригинале есть и другие функции, предназначенные для вызова коллбэков по мере готовности данных).

buffer.h

#pragma once

#include <vector>

namespace boozd::azzio {
using buffer = std::vector<int>;
} // namespace boozd::azzio

stream.h

#pragma once

#include <optional>

namespace boozd::azzio {
class stream
{
public:
    virtual ~stream() = default;

    virtual std::optional<int> read() = 0;
};
} // namespace boozd::azzio

io_context.h

#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

io_context.cpp

#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, выдающая случайные данные:

impl/random_stream.h

#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

impl/random_stream.cpp

#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.

BoozdedZomby.h

#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

BoozdedZomby.cpp

#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

main.cpp

#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