В статье приводится опасный антипаттерн «Зомби», в некоторых ситуациях естественным образом возникающий при использовании 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 имеет существенные нюансы при наследовании, но их обсуждение выходит за рамки статьи.

Ближе к делу


Все примеры кода, приведённые в статье, опубликованы на гитхабе.
Код демонстрирует плохие техники, замаскированные под обычное безопасное применение современного 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 не вызывается.

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



(изображение взято отсюда)

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

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

И что, неужели динамический анализ кода промолчал?
Нет, 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 из моей статьи Техника избежания неопределённого поведения при обращении к синглтону:

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


(изображение взято отсюда)

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

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

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

А до выхода из области видимости?
Можно явно вызвать деструктор зомби, но при этом вряд ли удастся избежать неопределённого поведения из-за повторного уничтожения объекта ещё и деструктором умного указателя — это борьба с 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 + " started\n"));
    std::this_thread::sleep_for(std::chrono::milliseconds(1000));
    listener.processData(std::make_shared<Common::Listener::Data>(callingFunctionName + " finished\n"));
}
} // 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-клиента
Пример Websocket-клиента
Официальная документация на boost учит, как написать гибрид BoozdedZomby + SteppingZomby. Остановить его невозможно, но никто и не пытается. Конкретно в демонстрационном коде основное свойство зомби не проявляется, но стоит перенести это в production — и вот Вы уже ходите вдоль края, скорее всего даже на тёмной стороне.

Можно остановить зомби, уничтожив экземпляр boost::asio::io_context!
… попутно уничтожив ещё n сущностей (возможно, не-зомби), живущих в данном контексте.

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

Заключение


Конечно, в статье описаны не все разновидности антипаттерна «Зомби».

Он может встречаться как в виде гибридов вышеприведённых типов, так и в виде новых самостоятельных типов.

Антипаттерн может возникать не только при запуске std::thread в Вашем коде — эту часть работы может взять на себя сторонняя многопоточная библиотека.

Циклическая ссылка может быть более длинной, чем в примерах.

Архитектура может быть как event-driven, так и на основе периодического опроса состояний (polling-based).

Это всё не очень важно.

Важно, что всегда антипаттерн начинается с получения экземпляром класса сильной ссылки на самого себя. Она почти всегда генерируется с помощью std::enable_shared_from_this, хотя может быть предоставлена и извне (в том числе в виде слабой ссылки — класс может самостоятельно сделать из неё сильную). Пожалуй, есть только одно экзотическое исключение из этого правила: когда внешний код предоставляет сильную или слабую ссылку на экземпляр класса какому-то из его полей данных.

Динамический анализ кода может оказаться не в силах обнаруживать этот антипаттерн, особенно его разновидность SteppingZomby. На статический анализ тоже надежды мало — очень уж тонкая грань между корректным и некорректным использованием shared_from_this (все примеры кода, приведённые в статье, можно исправить внесением очень небольших правок — всего от 1 до 6 строк кода).

Автотесты могут помочь в его выявлении и проверке корректности устранения — но для этого надо знать, что искать. Совершенно точно знать.

Искать антипаттерн, сюдя по всему, придётся вручную. А для этого надо пересматривать все применения std::enable_shared_from_this — они очень опасны.

Комментарии (90)


  1. gecube
    09.11.2019 21:10
    +1

    С++ очень сложный. И очень многогранный.
    Он реально крутой… Наверное. Но писать на нем не буду. Слишком велик риск остаться инвалидом.


    1. KanuTaH
      09.11.2019 21:40
      +2

      Ну, в данном случае риск остаться инвалидом не сильнее, чем в любом языке с GC или ARC (Java, Swift) — там циклические ссылки тоже образуются на счет «раз» и вполне могут превратить объекты в точно такие же зомби (зависит от реализации GC конечно). Ничего необычного не происходит ©.


      1. red75prim
        10.11.2019 12:54

        зависит от реализации GC конечно

        GC справляется с циклическими ссылками. Для этого он и сделан — удалять весь мусор, на который нет активных ссылок. ARC — это не сборщик мусора. Там или нет мусора (если нет циклических ссылок), или мусор не удаляется (если они есть).


        1. KanuTaH
          10.11.2019 13:45

          Насчёт GC — это не всегда так. Например, IIRC, в Питоне до какой-то версии GC справлялся с циклическими ссылками… за исключением того случая, когда у какого-то объекта из цепочки был кастомный _del_(), тогда он справляться переставал. Я же говорю — зависит от реализации GC.


          1. Mingun
            10.11.2019 16:08

            Так в питоне вроде бы как раз не GC в классическом понимании, а подсчет ссылок.


            1. KanuTaH
              10.11.2019 16:16

              Там и то, и другое сразу, так сказать.


    1. mapron
      09.11.2019 21:51
      +3

      Зависит от того, как интерпретировать знания :)

      Не буду использовать циркулярную пилу, буду использовать канцелярский нож. Им я хотя бы не отпилю себе руку.

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


      1. gecube
        09.11.2019 21:57
        -1

        Вопрос совместимости с легаси переоценен, как и преувеличена совместимость с++ с с и более старыми версиями стандарта с++
        Кратко — код не соберётся, а если даже соберётся, то не факт, что будет работать так же


        1. Effolkronium
          09.11.2019 22:55
          +2

          скажи это проектам 10+ лет на плюсах разрабатываемым. с кучей депендов на такие же плюсовые проекты


          1. gecube
            09.11.2019 23:02
            -2

            Лол. Секрет в том, что эти проекты адаптируются в каждый конкретный момент под актуальную версию компилятора и стандарта. И нет шоковой терапии, когда мы берём код десятилетней давности и пытаемся его адаптировать.
            Так что — нет, я с Вами не согласен.


            1. KanuTaH
              09.11.2019 23:15
              +2

              Сборка C++ кода даже десятилетней давности современным компилятором с указанием современного -std= (даже если там использовались вещи, которых в современных стандартах уже нет, типа auto_ptr или throw specifier) — это ГОРАЗДО более простая и безболезненная операция, чем переписывание этого же кода на каком-то другом языке, в котором нет «всякого легаси». Так что я бы не сказал, что «вопрос совместимости с легаси переоценен», это далеко не так.


              1. gecube
                10.11.2019 00:06
                -2

                А знаете, что я в этом наблюдаю? Что С++ остается в достаточно узком сегменте, где он отлично подходит. Т.е. это не истории про веб-сервисы, про сетевые монолиты, от которых отпиливают куски и переводят на более другие языки, не про распределенные сервисы, не про микроконтроллеры и встройку.
                Конечно, я знаю, что про проекты вроде Scylladb (drop-in замена Cassandra) — но это частные случаи.


                1. KanuTaH
                  10.11.2019 00:16
                  +2

                  Насколько я понимаю, antoshkka может с вами поспорить на тему неиспользования C++ в веб-сервисах (по ним я не специалист), в embedded C++ тоже не такая уж редкость. Ну это уже такой философско-холиварный вопрос.


                1. antoshkka
                  10.11.2019 00:25

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

                  И это я ещё не упоминал проектирование вашей квартиры, выдачу вам зарплаты, лечение в больничках, дальние путешествия, дизайн всего подряд, кино, мультфильмы, научные исследования…

                  Но да, сфера узковата, надо расширять. Тут я согласен


                  1. gecube
                    10.11.2019 00:26

                    Это десктоп приложение
                    Точно так же никто не отрицает, что игры до сих пор по определённым причинам пишут на плюсах.
                    И, да, я читал статьи про сборку хрома. Это тотал боль. И тестирование тоже.


                    1. KanuTaH
                      10.11.2019 00:32
                      +1

                      Напишите свой браузер на Go и микросервисах без боли и страданий, кто ж вам не дает-то. Только вот что-то не пишут.


                      1. gecube
                        10.11.2019 00:34

                        Ещё раз. Это вполне подходит под "узкий сегмент, где с++ отлично выполняет свою работу". Вы ведь правда читали, что я написал тремя постами выше?
                        И Вы же согласны, что не каждый Васян пишет свой браузер. Основных — полтора штука, которые есть на каждой платформе.


                        1. KanuTaH
                          10.11.2019 00:40

                          Он не такой уж узкий, об этом вам antoshkka и пытается намекнуть. Впрочем, это вполне понятная профдеформация, в чем каждый варится, то он и видит. Я тоже не исключение.


                          1. 0xd34df00d
                            11.11.2019 17:24
                            +1

                            Вопрос в том, сколько из этого обусловлено скорее легаси-причинами.


                            Аудиопроигрыватели точно на C++ писать не обязательно (как автор проигрывателя на С++ говорю).


                            1. KanuTaH
                              11.11.2019 17:27
                              -1

                              Ну если «легаси-причины» состоят в том, что к твоим услугам куча библиотек на все случаи жизни, которые ты можешь использовать сразу, без написания к ним всяких FFI и тем более без тяжелых NIH-синдромов типа RIIR, то такие «легаси-причины» можно только приветствовать, нет?


                              1. 0xd34df00d
                                11.11.2019 18:05
                                +2

                                Удобство языка не ограничивается наличием библиотек под него.


                1. Antervis
                  10.11.2019 04:14

                  Лол. Секрет в том, что эти проекты адаптируются в каждый конкретный момент под актуальную версию компилятора и стандарта. И нет шоковой терапии, когда мы берём код десятилетней давности и пытаемся его адаптировать.
                  1. код надо писать переносимым, а не так, чтобы приходилось под каждый компилятор адаптировать
                  2. бывает пишешь новый код, а ему нужны старые библиотеки.
                  А знаете, что я в этом наблюдаю? Что С++ остается в достаточно узком сегменте, где он отлично подходит
                  плохо наблюдаете. с++ в той или иной мере применим везде, для чего существуют с++ компиляторы, т.е. за исключением совсем непопулярного ембеда. Его распространенность действительно легко недооценить если забывать про то, что у всяких питончиков и джав под капотом плюсы.


                  1. gecube
                    10.11.2019 14:02

                    Это отличная история, когда плюсовики присваивают себе наработки Сишников.
                    Линукс, Windows (последний раз я смотрел на исходники 2000 ядро + драйвера + немного юзерспейса) — все на Сях.
                    Касательно питончика — Вы имели в виду либы или сам интерпретатор? Ну, так и ТО, и ТО как правило на Сях, а не на С++ и Вы сами прекрасно знаете почему это так (бинарная совместимость, манглинг и прочая-прочая).
                    В качестве пруфа — https://launchpad.net/~jonathonf/+archive/ubuntu/python-3.7/+sourcefiles/python3.7/3.7.4-2~18.04.york0/python3.7_3.7.4.orig.tar.xz — вот пакет, из которого собирается альтернативный 3.7 питон к убунте. Можно точно так же открыть и оригинальный пакет. С++ там и не пахнет.
                    Java — положим, сама виртуальная машина. Очень даже интересно посмотреть. Только давайте договоримся о какой имплементации мы говорим, ок?


                    1. Antervis
                      10.11.2019 15:43

                      Линукс, Windows
                      а вы под «Линукс» имеете в виду ядро, застрявшее на си в силу фанатизма Торвальдса, или всю ось? А винду вы оцениваете какой код? Открытый?
                      Касательно питончика — Вы имели в виду либы или сам интерпретатор? Ну, так и ТО, и ТО как правило на Сях, а не на С++
                      не горячился бы я с «как правило». Взять тот же opencv — они отказались от сишного интерфейса, попросту потому что неразумно. И если вы попробуете скажем оформить питонячий модуль на плюсах и на чистом си, вы прекрасно поймете почему.
                      … и Вы сами прекрасно знаете почему это так (бинарная совместимость, манглинг и прочая-прочая).
                      внезапно плюсы умеют не только вызывать сишные функции, но и так же просто определять их.
                      Java — положим, сама виртуальная машина. Очень даже интересно посмотреть. Только давайте договоримся о какой имплементации мы говорим, ок?
                      а о какой существующей имплементации мы говорим?


                      1. gecube
                        10.11.2019 15:49

                        а вы под «Линукс» имеете в виду ядро, застрявшее на си в силу фанатизма Торвальдса, или всю ось?

                        Вся ось — там такое сборище… есть ВСЕ — начиная от С, кончая перлом. Да, есть код на С++ (в первую очередь — граф. приложения). Но сказать, что он "системообразующий"… кхм… слишком смелое утверждение.


                        А винду вы оцениваете какой код? Открытый?

                        Это не имеет значения. Кто хочет — тот может посмотреть (тем более были утечки кодов NT & W2k или можно по академ подписке).


                        внезапно плюсы умеют не только вызывать сишные функции, но и так же просто определять их.

                        в обратную сторону все сильно сложнее.


                        а о какой существующей имплементации мы говорим?

                        Это к Вам вопрос. Ибо даже школьнику известно, что есть несколько разных JVM....


  1. mapron
    09.11.2019 21:47
    +3

    А мне понравилась статья. Поэтапное раскрытие мысли с небольшими примерами кода, а не так что под спойлером простыня на 2 тысячи строк, которая иллюстрирует сразу всё.
    Прям вдохновило пойти и проверить те места, где я использовал shared_from_this.

    Ну и очень порадовал ответ на самый распространенный обычный вопрос «так никто не пишет! по крайней мере в библиотеках написанных профи!»


    1. KanuTaH
      09.11.2019 21:59
      +1

      Статья очень хорошая, тут спору нет. Насчет «не пишет» — пишут, еще как пишут, в языках с ARC захватывают strong self в какой-нибудь лямбде, которую потом присваивают члену этого же класса, в Java забывают про static nested class'ы, в C++ веселятся с shared_from_this. Всякое бывает.


  1. mayorovp
    09.11.2019 21:57
    +1

    Не вижу тут ничего специфичного для enable_shared_from_this, обыкновенная циклическая ссылка же. Я такое могу и без enable_shared_from_this сделать.


    1. Dubovik_a Автор
      09.11.2019 22:04
      +1

      Можно и без — если кто-нибудь снаружи ссылку подсунет. И в статье об этом тоже говорится.


      1. myxo
        09.11.2019 23:28
        +1

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

        Но оформлена статья хорошо, спору нет.


        1. Dubovik_a Автор
          09.11.2019 23:52
          +1

          Статья не совсем о циклических ссылках. Это только половина вопроса. Причём более лёгкая половина — та, которую можно выявить динамическим анализом кода.
          Статья — о циклических ссылках, в которые попадают активные сущности.

          Моя практика (да и примеры стороннего кода) показывает, что обычно циклическая ссылка возникает всё же при помощи std::enable_shared_from_this.
          И я бы даже сильнее сказал: если Вам захотелось задействовать std::enable_shared_from_this — вероятно, Вам надо чинить архитектуру.


        1. Dubovik_a Автор
          10.11.2019 00:39
          +1

          Всякая селёдка — рыба, но не всякая рыба — селёдка.
          Не каждое место в C++-коде, требующее повышенного внимания, вызвано применением std::enable_shared_from_this. Но каждое место применения std::enable_shared_from_this требует повышенного внимания.


          1. mayorovp
            10.11.2019 20:38

            Я бы сформулировал по-другому. Всякая операция захвата shared_ptr в лямбде требует повышенного внимания.


            1. Dubovik_a Автор
              10.11.2019 20:58

              Лямбда — это всего лишь краткий способ попросить компилятор сгенерировать для меня класс с определёнными полями данных, определённым конструктором, определённой функцией operator(), а также создать его экземпляр.
              Точно такого же эффекта я добьюсь, если напишу подобный класс руками. Только это более многословно.

              А в примерах SimpleCyclic и PimplCyclic operator() всё равно не используется, т.е. достаточно только конструктора и поля данных.

              Как собираетесь такие места выделять в коде для обращения на них повышенного внимания?


  1. DerRotBaron
    10.11.2019 02:57

    К сожалению, для valgrind или ASAN тут практически нет шансов. С их точки зрения всё отлично до последнего момента, т. к. память не утекла и находится у другого потока. И по завершению detached thread зачищается системой. При этом в нём самом тоже толком ничего не утекло.


    1. Dubovik_a Автор
      10.11.2019 08:57

      Какое отношение вызов detach имеет к зачистке системой? Поток продолжает жить и после detch(), только деструктор std::thread перестаёт быть блокирующим.
      Кроме того, аналогичного поведения можно добиться вообще без вызова detach и даже без запуска потока в пользовательском коде. Примеры из документации буста легко переделать под такое — надо только не блокировать главный поток вызовом run(), а запустить в нём polling-loop с вызовами boost::asio::io_context::poll().


      1. DerRotBaron
        10.11.2019 13:58

        Суть в том, что ситуация завершения по exit() при нескольких живых потоках для ASAN нормальная, а для valgrind – почти нормальная. И на момент завершения утечки нет, т.к. ресурсами владеет как раз "утекший" поток.


  1. Antervis
    10.11.2019 03:08

    сначала не понял почему из всех языков и способов создать циклическую ссылку автору столь нелюбим именно std::enable_shared_from_this. Однако потом до меня дошло… что дело не в нём, точнее, не совсем в нём. Грубо говоря, если из области применимости shared_from_this исключить часть, где достаточно простого shared_ptr, останутся сценарии «классу нужно оперировать умными указателями на собственный инстанс». Класс, управляющий собственным лайфтаймом — известный антипаттерном. И называется он не «зомби».

    Впрочем, практически любое использование shared_from_this можно переписать через pimpl.

    Например так
    class MyClass {
        ... // copyable, movable, etc. handle to data
    private:
        class MyClassPrivate;
        std::shared_ptr<MyClassPrivate>;
    };
    


    1. Dubovik_a Автор
      10.11.2019 09:39
      +1

      Pimpl на базе std::shared_ptr в статье приведён.
      Если Вы имеете в виду, что при таком построении пимпла фасад может подсунуть реализации ссылку на неё же саму — то да, может. И получится то же самое, что с std::enable_shared_from_this. Это ситуация типа «захотелось применить std::enable_shared_from_this, но не знали о таком, поэтому сделали то же самое, но без перламутровых пуговиц». И проверить будет сложнее, т.к. поиск по shared_from_this не покажет такого места.

      Антипаттерн «Зомби» — не просто про управление классом собственным временем жизни. Это ещё цветочки, которые могут работать корректно.
      Пример SteppingZomby в статье отработал так:

      Вывод в консоль
      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


      1. Antervis
        10.11.2019 15:19
        +1

        Если Вы имеете в виду, что при таком построении пимпла фасад может подсунуть реализации ссылку на неё же саму — то да, может. И получится то же самое, что с std::enable_shared_from_this. Это ситуация типа «захотелось применить std::enable_shared_from_this, но не знали о таком, поэтому сделали то же самое, но без перламутровых пуговиц»
        нет, я про то, что можно переписать код так, чтобы время жизни класса и его логика были разнесены по разным сущностям. А обратить внимание очевидно надо будет на те места, где в инстанс класса (в моем примере MyClassPrivate) передается shared_ptr на него же. И это намного проще, потому что привлечет внимание еще на этапе написания кода, а не во время отладки
        И проверить будет сложнее, т.к. поиск по shared_from_this не покажет такого места
        а вы какую задачу хотите решить — написания корректного кода, поиска багов или поиска мест где баги высоковероятны?


        1. Dubovik_a Автор
          10.11.2019 16:05
          +1

          О чём статья — написано в заголовке: я предупредил в ней об опасности.

          Надо ли разбирать методы устранения опасности — посмотрим по опросу через пару дней.

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

          Ну и несколько исключительных читателей достаточно внимательны и опытны, чтобы самостоятельно решить, что с этим делать.

          Предлагаю не обсуждать методы исправления — и так материал довольно объёмный, уже 60 комментов настрочили, а эта тема потянет ещё на целую статью такого же объёма.


  1. da-nie
    10.11.2019 10:10

    А не подскажет ли мне кто-нибудь вот какую штуку.
    Вот есть у класса функция типа

    void CNet::SetInput(std::shared_ptr<CInput> cInput_Ptr)
    {
     cInput_Local_Ptr= cInput_Ptr
    }
    

    То есть, я просто указываю классу указатель на другой класс, который он запоминает для дальнейшего использования как входной параметр.
    А вот дальше я не понимаю, как передать указатель, например, на такое (независимот от того, как был создан cData):
    class CData
    {
      public:
       CInput cInput;
    };
    CData cData;
    
    cNet.SetInput(&cData.cInput); // вот как это сделать? Объект cInput не создавался динамически напрямую.



    1. Dubovik_a Автор
      10.11.2019 10:25

      Простой путь — это завести в классе CData поле именно типа std::shared_ptr (я надеюсь, что между классами CInputClass и CInput есть связь типа «наследование»?)
      Если выставляете поля данных в public — это уже скорее struct, чем class.


      1. da-nie
        10.11.2019 10:32

        я надеюсь, что между классами CInputClass и CInput есть связь типа «наследование»?


        Опечатался. :) Это один и тот же класс. Исправил.

        Простой путь — это завести в классе CData поле именно типа std::shared_ptr


        Ну это-то очевидно. Но вот как без этого?

        Если выставляете поля данных в public — это уже скорее struct, чем class.


        Это для примера. Реально у меня классы — слои свёрточной нейросети и я хочу соединять входы и выходы (вход — указатель извне, выход — собственность класса слоя) и создавать классы обучения слоёв, порождаемые самими слоями (с передачей this в порождаемый класс обучения). И вот захотелось мне просто передать статический объект по указателю. А так как всё через shared_ptr сделано, то и возник вопрос, а как это сделать? Пока решение вижу одно — выкинуть все эти shared_ptr в параметрах функций типа SetInput нафиг и поставить и хранить обычный указатель. Тогда можно будет абсолютно любой указатель передавать, хоть умный, хоть нет и на что угодно указывающий. Только это, как я понимаю, сейчас совсем не приветствуется.


        1. Dubovik_a Автор
          10.11.2019 10:47

          Если хотите сложностей — смотрите в сторону std::shared_ptr с deleter-пустышкой. Но судя по вопросам, до корректного применения подобных техник Вам ещё года 2-3 покоддить бы… Ну и — я честно не понимаю, зачем плыть против течения.

          Сырые указатели легко превращаются в сырые указатели на уничтоженные объекты. Если у Вас есть достаточные способы контроля жизни объектов — то пожалуйста.


          1. da-nie
            10.11.2019 11:30

            Ну и — я честно не понимаю, зачем плыть против течения.


            Ну это рекомендация в стиле «тут так принято». Типа карго культа. А мне вот интересно, как именно в такой ситуации быть, если функция хочет shared_ptr, а объект статический. Перегружать deleter это как минимум некрасиво, так как извне неочевидны причины такой перегрузки.

            Сырые указатели легко превращаются в сырые указатели на уничтоженные объекты.


            Зависит от подхода и стиля. Если привык к «щас сбацаем и пусть умный указатель следит сам», то да, при переходе к обычным указателям начнутся утечки памяти из-за отсутствия опыта отслеживания всего созданного. А если привык сам следить за всем и не создавать динамические объекты без надобности, тогда нет. За 19 лет ни разу сырые указатели у меня не превратились в указатели на уничтоженные объекты. В конце-концов, не часто программе требуется постоянно что-то динамически создавать — обычно, при запуске создаются нужные объекты, а при завершении они уничтожаются.


            1. Dubovik_a Автор
              10.11.2019 11:40

              Если бы автору вопроса были «даны свыше» (из какой-нибудь библиотеки) и класс CData, и класс CNet в том виде, в котором они присутствуют в вопросе — то да, это была бы интересная задачка. Решение с пустым deleter — первое, что пришло в голову, и наверняка есть ещё 2-3 варианта получше.
              Но, судя по всему, тут ещё не до полицейских разворотов — на второй передаче научиться бы ездить.


              1. da-nie
                10.11.2019 11:50
                +1

                Решение с пустым deleter — первое, что пришло в голову, и наверняка есть ещё 2-3 варианта получше.


                А может, вариантов получше и нет.

                Но, судя по всему, тут ещё не до полицейских разворотов — на второй передаче научиться бы ездить.


                Я вот не пойму, какой вам резон уже во втором сообщении пытаться меня уязвить по типу «мал ещё»? Да, я не часто использую эти умные указатели (у меня на основной рабочей системе QNX 6.3 просто вообще нет Си++ 11 компилятора. Вот нет и всё.) и гораздо меньше озабочен трюками с ними — меня волнует решение задачи пользователя и читаемость программы, а не нюансы извращений с умными указателями, пересыпанные костылями вроде enable_shared_from_this. И поэтому вполне логичен вышеобозначенный вопрос. Он элементарен для обычного указателя (да он даже не возникает в этом случае), но вызывает проблемы с умным.


                1. Dubovik_a Автор
                  10.11.2019 12:02

                  Эммм… Простите. Если честно — не посмотрел на авторов комментов. Первый Ваш коммент просто слишком сильно диссонирует с последующими, чтобы можно было предположить, что они принадлежат одному человеку.

                  Если по существу вопрса — лучше в личку.


                  1. da-nie
                    10.11.2019 12:11
                    +1

                    Если по существу вопрса — лучше в личку.


                    Так а зачем? Я думаю, варианты решения много кому будут интересны.
                    Может кто знает ещё красивые методы решения.


                    1. Dubovik_a Автор
                      10.11.2019 12:40

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

                      В случае с std::shared_ptr этого можно добиться подсовыванием пустого deleter. Аналога std::unique_ptr::release() в std::shared_ptr нет и быть не может, а если бы и была — она должна была бы вызываться постфактум, т.е. примерно в деструкторе CNet. А вот пустой deleter можно подсунуть в том месте, где будете наводить связь.
                      Ну а как перестать заботиться о времени жизни штуковины, не живущей в умном указателе? Вероятно, просто воспользоваться new без последующего delete, т.к. со штуковиной, созданной на стеке, так не получится.

                      Первый вариант приводит к странной спорной конструкции, нуждающейся в пояснениях комментарием.
                      Второй вариант естественным образом приводит примерно к:

                      auto input = std::shared_ptr<CInput>(new CInput);
                      

                      , и далее — прямо к улучшенной версии:
                      auto input = std::make_shared<CInput>();
                      


                      Первый вариант — редкостное извращение, второй вариант — обычное штатное использование std::shared_ptr.

                      До тех пор, пока Вы контролируете хотя бы одну из сторон этой связи — просто нет нужды выдумывать вот это вот всё, и задачка является чисто спортивной.
                      А если Вы не контролируете обе стороны этой связи — то прошу объяснить, как так вышло.


                      1. da-nie
                        10.11.2019 13:02

                        Надо как-то согласовать сырой указатель с умным.

                        Итого: поведение по умолчанию приведёт к двойному удалению, что является UB.


                        Именно.

                        Ну а как перестать заботиться о времени жизни штуковины, не живущей в умном указателе? Вероятно, просто воспользоваться new без последующего delete, т.к. со штуковиной, созданной на стеке, так не получится.


                        Разве я не могу в тестовой функции создать все нужные классы на стеке, создать к ним shared_ptr без deleter'а и их уже передать в методы нужных классов? Методы отработают, функция завершится. Объекты разрушатся.

                        А если Вы не контролируете обе стороны этой связи — то прошу объяснить, как так вышло.


                        Тут дело просто в том, что не хочется всё создавать динамически. Поэтому я и спросил, какие есть вообще решения.


                        1. Dubovik_a Автор
                          10.11.2019 13:16

                          Разве я не могу в тестовой функции создать все нужные классы на стеке, создать к ним shared_ptr без deleter'а и их уже передать в методы нужных классов?

                          Можете. Но зачем бороться с RAII вместо того, чтобы его использовать? Ну я не знаю — это как вытащить из микроскопа оптику, чтобы он не сломался при забивании гвоздей.

                          Умные указатели совместимы с сырыми только в одну сторону. Если Вам одна и та же штуковина в одном месте нужна в умном указателе, а в другом — в сыром, то проще создать в умном и отдать туда и туда.

                          не хочется всё создавать динамически

                          Почему?

                          Если Вы так не любите/не умеете пользоваться умными указателями — зачем тогда:
                          void CNet::SetInput(std::shared_ptr<CInput> cInput_Ptr)
                          

                          ?


                          1. da-nie
                            10.11.2019 14:28
                            +1

                            Почему?


                            Просто не нравится. :)

                            Если Вы так не любите/не умеете пользоваться умными указателями — зачем тогда:


                            Нет, я умными люблю пользоваться (конечно, они ведь удобны), но иногда хочется смешать, как в указанном примере. А функция принимает умный потому, что хочется в то же время в современном стиле Си++ всё-таки писать. А то опыта работы с ними и понимания их ограничений не будет. Без проблемы ведь не появится понимание метода решения. Да и вообще, бывает, придумаешь решение, а компилятор с ним работает не так, как задумывалось. Вот например. gcc 2.95 это компилирует, но работает неправильно — указатель приводится к базовому классу с вызовом функции без реализации (изначально — в примере там заглушка с выводом «CErrorsBased!»). Современный компилятор такое уже не компилирует вообще. А идея использования задумывалась интересной, но всё обломала реализация языка. :)


                            1. KanuTaH
                              10.11.2019 14:54
                              +1

                              С чисто технической точки зрения вполне можно сделать, чтобы этот код работал:

                              godbolt.org/z/uzTQzc

                              Виртуальное наследование в конечном классе совершенно излишне, ну и все ж вызов set_function_ptr() я бы через std::invoke переделал.


                              1. da-nie
                                10.11.2019 15:08

                                Виртуальное наследование в конечном классе совершенно излишне,


                                Тут дело в том, что базовый класс обеспечивает работу с разделяемой памятью, общей для всех классов, от него унаследованных. Я всё наследование сделал виртуальным в этой цепочке, чтобы нигде гарантированно не образовывалась копия этого базового класса и не приходилось за этим следить.
                                Интересно, почему ошибка возникает только при виртуальном наследовании в объединяющий класс.

                                я бы через std::invoke переделал.


                                Не могу. Это 17-й стандарт, а это фрагмент сделан по мотивам даже не 11-го (это было в моей QNX программе).


                                1. KanuTaH
                                  10.11.2019 15:11
                                  +1

                                  Достаточно, чтобы первый уровень наследования от общего класса был виртуальным. А почему вообще возникает эта ошибка — потому, что приведение к виртуальному базовому классу в compile time в общем случае невозможно из-за деталей реализации этого механизма.


                                  1. da-nie
                                    10.11.2019 15:18

                                    Достаточно, чтобы первый уровень наследования от общего класса был виртуальным.


                                    Да, в данном случае каждый из объектов получит ссылку на этот базовый класс вместо его самого.

                                    А почему вообще возникает эта ошибка — потому, что приведение к виртуальному базовому классу в compile time в общем случае невозможно из-за деталей реализации этого механизма.


                                    Вот. Я нашёл в своё время статью «жуткие сведения об указателях на функции классов» со всем историческим геморроем при создании компиляторов с указателями на функции классов. Но тут какая штука? С точки зрения идеи я ведь ничего не нарушил.


                                    1. KanuTaH
                                      10.11.2019 15:34

                                      Ну, как грицца, не каждая идея выдерживает свою реализацию, хехе.


                                      1. da-nie
                                        10.11.2019 15:35

                                        Увы. :)


                            1. Dubovik_a Автор
                              10.11.2019 14:58

                              но всё обломала реализация языка

                              Это признак того, что Вы сражаетесь со своими инструментами.

                              Просто не нравится

                              «Просто не нравиться» может цвет обоев. Или вкус еды. Или музыка. Или текстура ткани.
                              А в технических решениях принято взвешивать «за» и «против», и выбирать в соответствии с этим. Если решения при взвешивании оказываются эквивалентными — ну что ж, тогда дальше выбор за автором. Иногда конечно говорят «мне это решение не нравится», но обычно это означает что-то типа «чутьё подсказывает, что это приведёт к плохому коду, но вербализовать недостатки вот прямо на ходу не могу», или «я вижу, что недостатки перевешивают и могу это вербализовать, но это долго, а Вы вроде как и сами должны их видеть». Это совсем не то, что «настоящее» «не нравится».

                              Ходить вдоль края и заходить на тёмную сторону конечно обязательно надо. Но зачем начинать-то с этого? Вы же не начинали изучение сырых указателей с попыток разыменования указателей на удалённые объекты?

                              Касательно кода из примера — в личку. Слишком далеко ушли от темы статьи.


                              1. da-nie
                                10.11.2019 15:13

                                Это признак того, что Вы сражаетесь со своими инструментами.


                                Нет, это признак того, что не всё придуманное реализуемо средствами формальной системы языка. :)

                                чутьё подсказывает, что это приведёт к плохому коду, но вербализовать недостатки вот прямо на ходу не могу


                                Вот вы сами и ответили на этот вопрос. :)

                                Ходить вдоль края и заходить на тёмную сторону конечно обязательно надо. Но зачем начинать-то с этого? Вы же не начинали изучение сырых указателей с попыток разыменования указателей на удалённые объекты?


                                Вообще-то, делал и это. Знаете, как Windows98 крашилась если под MS-DOS в Watcom выделить память через new больше, чем есть в системе и сделать её очистку? Это было интересно. :)


                                1. Dubovik_a Автор
                                  10.11.2019 15:28

                                  Вот как раз тот код, который у Вас уже возник, и является плохим. Не надо так делать. А чтобы это понять — надо просто взять и попользоваться умными указателями в тех сценариях, для которых они предназначены. Хотя никто не заставляет — плюсы, к счастью, позволяют выбрать комфортное для себя подмножество языка и не выходить за его пределы (конечно, если этого не требует работодатель или ещё какие сильные обстоятельства).

                                  Вообще-то, делал и это

                                  И что, прям вот первой попыткой использования сырых указателей решили «а дай-ка я посмотрю, что будет»? Или может всё же пытались воспользоваться ими правильно, а ошибки иногда получались сами собой?


                                  1. da-nie
                                    10.11.2019 15:40

                                    Вот как раз тот код, который у Вас уже возник, и является плохим. Не надо так делать.


                                    Ну, код проекта тут я ещё не приводил — я привёл упрощённый тут же написанный пример. Сам же проект перестраивался раза 4, ибо придумать удобную архитектуру построения CNN и не запутаться в ней сходу не удалось.

                                    И что, прям вот первой попыткой использования сырых указателей решили «а дай-ка я посмотрю, что будет»?


                                    Я начинал с ZX-Spectrum с его ассемблером и бейсиком, а потому к моменту появления у меня IBM, я уже примерно представлял, что такое указатель и не погнушался побаловаться с ними и посмотреть, что будет, хотя, может, и не с самой первой попытки. :)


            1. 0xd34df00d
              11.11.2019 18:04
              +1

              А мне вот интересно, как именно в такой ситуации быть, если функция хочет shared_ptr, а объект статический.

              Переделывать архитектуру.


              Если функция ожидает аргумент типа std::shared_ptr<T>, то это означает, что она будет разделять владение этим объектом. Разделять владение полем как-то бессмысленно, поэтому вы хотите нарушить контракт этой функции.


              В конце-концов, не часто программе требуется постоянно что-то динамически создавать — обычно, при запуске создаются нужные объекты, а при завершении они уничтожаются.

              Зависит от программ. У меня есть и такие, где всё действительно создаётся на старте, а потом не создаётся ничего, так и такие, где в рантайме очень много чего создаётся и удаляется.


    1. orignal
      10.11.2019 15:42

      Лично я делаю вот так


      1. Dubovik_a Автор
        10.11.2019 15:55

        Ну да — std::shared_ptr с пустым deleter. Который предоставляет пользователю, не знакомому с подробностями реализации, нечто с весьма неожиданными свойствами.
        Зачем сначала брать умный указатель, а потом кастрировать его до состояния глупого, но только с повышенными накладными расходами? Если Вам не нужно поведение умного указателя — зачем Вы его используете?


        1. orignal
          10.11.2019 17:23

          Потому что остальной код принимает в качестве параметра shared_ptr, чтобы было можно передавать туда.


          1. Dubovik_a Автор
            10.11.2019 17:34

            От std::shared_ptr там только название (более длинное, кстати), плюс повышенные накладные расходы по процессору и памяти.
            А поведение — тадам — от сырого указателя.
            Исходя из того, что внешние сущности успешно работают с возвращаемым кастратом std::shared_ptr — их устраивает именно поведение сырого указателя.
            Вот его и надо возвращать, а не пытаться сделать «современно» — получается какое-то уродство.


    1. FoxCanFly
      11.11.2019 00:00

      А aliasing constructor у shared_ptr вам не подходит? Он же как раз для такого случая создавался


  1. gasizdat
    10.11.2019 10:56

    Циклы из shared_ptr это банальщина и enable_shared_from_this просто еще один из миллиона способов их сделать. А вот weak_ptr и make_shared это посерьезнее проблема. Когда все пользовательские деструкторы выполнились, а память в систему не вернулась.


    1. Dubovik_a Автор
      10.11.2019 11:17

      Кратко перескажу содержание предыдущих серий (видимо, читать что статью, что комментарии Вам лень): если у Вас возникло желание использовать std::enable_shared_from_this — Вы в шаге от создания циклической ссылки. И при некоторых обстоятельствах это приведёт к трудонодетектируемой проблеме.


      1. ianzag
        10.11.2019 11:39

        > если у Вас возникло желание использовать std::enable_shared_from_this — Вы в шаге от создания циклической ссылки. И при некоторых обстоятельствах это приведёт к трудонодетектируемой проблеме.

        Тут не то, чтобы желание — например, весь boost::asio на этом построен. И ничего. При аккуратном использовании в силу понимания происходящего он вполне себе торт.


        1. Dubovik_a Автор
          10.11.2019 11:46

          При аккуратном использовании в силу понимания происходящего

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


          1. Effolkronium
            10.11.2019 16:26

            хоть вим


  1. Dubovik_a Автор
    10.11.2019 11:16

    Промазал с комментарием.


  1. so1ov
    10.11.2019 16:34

    На мой взгляд, в первом примере, после легкомысленной инициализации capture group результатом shared_from_this() вполне логичной выглядит необходимость оставаться в живых, пока тебя могут дернуть за _fn.
    Так что я даже не знаю, std::enable_shared_from_this ли виноват.
    С таким же успехом можно было бы попытаться захватить невинно выглядящую ссылку, и почувствовать себя в среде immutable by default.


    1. Dubovik_a Автор
      10.11.2019 17:16
      +1

      Простите, а кто может «дёрнуть за _fn»? Он разве выходит наружу?
      В коде нет никаких предпосылок думать, что _fn предназначен для какого-либо использования снаружи, тем более — после уничтожения родительского экземпляра SimpleCyclic (Вы же SimpleCyclic имеете в виду, говоря «в первом примере»?)
      Более того, _fn в примере вообще никем и нигде не вызывается, даже внутри класса.
      И написана эта конструкция именно так только по одной причине — это просто экстремально лаконичный способ организации циклической ссылки. И всё. Без высоких целей и идеологической подоплёки.


  1. orignal
    11.11.2019 03:18

    >Можно остановить зомби, уничтожив экземпляр boost::asio::io_context!

    Или просто закрыть стрим, тогда async_read вызовет коллбэк с ошибкой и все остановится корректно.


    1. Dubovik_a Автор
      11.11.2019 09:12

      А ничего, что в примерах из буста stream, в отличие от io_context, является полем данных класса зомби?


      1. orignal
        11.11.2019 14:47

        Ну так надо метод сделать для его закрытия.
        Антипаттерн здесь вовсе не в shared_from_this, а использовании асинхронного чтения в runOnce. Тут надо или читать синхронно или дожидаться завершения и уж потом выходить.


        1. Dubovik_a Автор
          11.11.2019 16:15
          +2

          shared_from_this во всех примерах статьи является отличным инструментом для завязывания в узел.

          Антипаттерн здесь вовсе не в shared_from_this, а использовании асинхронного чтения в runOnce

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

          Ага. Т.е. запущенный процесс ну никак нельзя прервать на уровне бизнес-логики. Просто прелесть.


          1. orignal
            11.11.2019 17:53

            Так метод тогда должен называться не runOnce а типа invoke...., и соответствующими методами класса для контроля над асинхронными процессами. У вас же претензия в том, что чтение продолжает работать после завершения runOnce.


            1. Dubovik_a Автор
              11.11.2019 18:48
              +1

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


              1. orignal
                11.11.2019 19:43
                -1

                Например возвращать не void, а некоторую сущность, дающую доступ к этому асинхронному процессу. Главное, чтобы была возможность контроля над этим асинхронным процессом. В противном случае любой асинхронный процесс рискует стать зомби.


                1. Dubovik_a Автор
                  11.11.2019 20:26
                  +1

                  Можно и так, да.
                  Но почему Вы утверждаете, что этот способ — единственно верный?
                  Конструкция с Listener тоже не только способна выполнять роль асинхронного интерфейса, но и — о ужас! — иногда даже применяется в production в таком качестве.

                  Если Вы не заметили, статья не называется «Разбор вариантов построения асинхронных интерфейсов с выбором наиправильнейшего». И в начале статьи я предупредил, что демонстрирую в ней плохие техники. Это значит — показываю путь, следуя которому, можно нехило влипнуть, да ещё и не планирую обсуждать варианты выхода из ситуации ни в статье, ни в комментариях к ней. Хотите разбора — голосуйте «за» и ждите следующей статьи.


  1. sstepashka
    11.11.2019 17:35
    +1

    Так ещё Herb Sutter в прошлогоднем (вроде) докладе говорил: «Не использовать shared_ptr для кольцевой, только weak_ptr. Если захватить нечего, то и результат никому не нужен.» Даже с многопоточность такое работает в некоторых случаях.


    https://youtu.be/xnqTKD8uD64


    1. Dubovik_a Автор
      11.11.2019 18:41

      Почитайте внимательно мой пример BoozdedZomby.
      И попробуйте его исправить, переделав на слабую ссылку.
      А там и поговорим.


  1. mikeus
    11.11.2019 18:15

    Ок, бывает нужным чтоб экземпляр держал последнюю (резервную) ссылку на самого себя до момента завершения управляемого им какого-либо потока / коллбэка (чтобы клиентский код не прибил его из-за удаления экземпляра; или же просто нужно обеспечит жизнь объекту-контексту до конца выполнения потока, который просто не возможно взять и в любой момент завершить). Вот это есть антипаттерн «зомби»? Какие предложения?


    1. Dubovik_a Автор
      11.11.2019 18:44
      +1

      Нет, не это. Это только приближение вплотную к антипаттерну «Зомби».
      Антипаттерн — это когда додержались до того, что оно продолжает что-то делать из-под капота, когда пора бы уже остановиться.