Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
В статье приводится опасный антипаттерн «Зомби», в некоторых ситуациях естественным образом возникающий при использовании std::enable_shared_from_this. Материал — где-то на стыке техники современного C++ и архитектуры.
C++11 предоставил разработчику замечательные инструменты для работы с памятью — умные указатели std::unique_ptr и связку std::shared_ptr + std::weak_ptr. Использование умных указателей по удобству и безопасности существенно перевешивает использование сырых указателей. Умные указатели широко применяются на практике, т.к. позволяют разработчику сосредоточиться на более высокоуровневых вопросах, чем отслеживание корректности создания/удаления динамически создаваемых сущностей.
Частью стандарта является также шаблон класса std::enable_shared_from_this, при первом знакомстве кажущийся довольно странным.
В статье пойдёт речь о том, как можно вляпаться при его использовании.
Все примеры кода, приведённые в статье, опубликованы на гитхабе.
Код демонстрирует плохие техники, замаскированные под обычное безопасное применение современного C++
Вроде бы ничего не предвещает проблем. Объявление класса выглядит просто и понятно. За исключением одной «мелкой» детали — зачем-то применено наследование от std::enable_shared_from_this.
А в реализации:
В теле функции doSomething() экземпляр класса сам создёт дополнительную сильную копию того std::shared_ptr, в котором он был размещён. Затем эта копия с помощью обобщённого захвата помещается в лямбда-функцию, присваиваемую полю данных класса под видом безобидного std::function. Вызов doSomething() приводит к возникновению циклической ссылки, и экземпляр класса уже не будет разрушен даже после уничтожения всех внешних сильных ссылок.
Возникает утечка памяти. Деструктор SimpleCyclic::Cyclic::~Cyclic не вызывается.
Экземпляр класса «держит» себя сам.
Код завязался в узел.
(изображение взято отсюда)
И что, это и есть антипаттерн «Зомби»?
Нет, это только разминка. Всё самое интересное ещё впереди.
Зачем разработчик такое написал?
Пример синтетический. Мне не известны какие-либо ситуации, в которых гармонично получался бы такой код.
И что, неужели динамический анализ кода промолчал?
Нет, Valgrind честно сообщил о состоявшейся утечке памяти:
В данном случае заголовочный файл выглядит совершенно корректно и лаконично. В нём объявлен фасад, хранящий некую реализацию в std::shared_ptr. Наследование — в том числе от std::enable_shared_from_this — отсутствует, в отличие от прошлого примера.
А в реализации:
Вызов Impl::doSomething() приводит к образованию циклической ссылки в экземпляре класса Impl. Фасад уничтожается корректно, а вот реализация утекает. Деструктор PimplCyclic::Cyclic::Impl::~Impl не вызывается.
Пример опять синтетический, но на сей раз более опасный — вся плохая техника расположена в реализации и никак не проявляется в объявлении.
Более того, для возникновения циклической ссылки от пользовательского кода не потребовалось никаких действий, кроме конструирования.
Динамический анализ в лице Valgrind и в этот раз выявил утечку:
Немного подозрительно видеть Pimpl, в котором реализация хранится в std::shared_ptr.
Классический Pimpl на базе сырого указателя слишком архаичен, а std::unique_ptr имеет побочный эффект в виде распространения запрета copy-семантики на фасад. Такой фасад будет реализовывать идиому единоличного владения, что может не соответствовать архитектурной задумке. Из применения std::shared_ptr для хранения реализации следует сделать вывод, что класс задуман для обеспечения совместного владения.
Чем это отличается от классической утечки — выделения памяти с помощью явного вызова new без последующего удаления? Точно так же в интерфейсе было бы всё красиво, а в реализации — баг.
Мы тут обсуждаем современные способы прострелить себе ногу.
Итак, из вышеприведённого материала понятно:
— умные указатели могут завязываться в узлы;
— применение std::enable_shared_from_this может этому способствовать, т.к. позволяет экземпляру класса завязаться в узел почти без посторонней помощи.
А теперь — внимание — ключевой вопрос статьи: имеет ли значение тип ресурса, завёрнутого в умный указатель? Есть ли разница между RAII-заботой о файле и RAII-заботой об HTTPS-соединении в асинхронном исполнении?
Общий для всех последующих примеров зомби код вынесен в библиотеку Common.
Абстрактный интерфейс зомби со скромным названием Manager:
Абстрактный интерфейс Listener'a, готового потокобезопасно принимать текст:
Listener, отображающий текст в консоль. Реализует концепцию SingletonShared из моей статьи Техника избежания неопределённого поведения при обращении к синглтону:
И, наконец, первый зомби, самый простой и бесхитростный.
Зомби запускает в отдельном потоке лямбда-функцию, периодически отправляющую строку в listener. Лямбда-функции для работы нужны семафор и listener, являющиеся полями класса зомби. Лямбда-функция не захватывает их как отдельные поля, а использует объект в качестве агрегатора. Уничтожение экземпляра класса зомби до завершения работы лямбда-функции приведёт к неопределённому поведению. Чтобы этого избежать, лямбда-функция захватывает сильную копию shared_from_this().
В деструкторе зомби семафор устанавливается в false, после чего вызывается detach() для потока. Установка семафора сообщает потоку о необходимости завершения работы.
В деструкторе надо было вызывать не detach(), а join()!
… и получить деструктор, блокирующий выполнение на неопределённое время, что может являться неприемлемым.
Так это же нарушение RAII! RAII должно было выйти из деструктора только после освобождения ресурса!
Если строго — то да, деструктор зомби не осуществляет освобождение ресурса, а только гарантирует, что освобождение будет произведено. Когда-нибудь произведено — может скоро, а может и не очень. И возможно даже, что main завершит работу раньше — тогда поток будет принудительно зачищен операционной системой. Но на самом деле, грань между «правильным» и «неправильным» RAII может быть очень тонкой: например, «правильное» RAII, осуществляющее в деструкторе вызов std::filesystem::remove() для временного файла, вполне может вернуть управление в тот момент, когда команда на запись ещё будет находиться в каком-нибудь из энергозависимых кэшей и не будет честно записана на магнитную пластину жёсткого диска.
Что видно из вывода программы:
— зомби продолжил работу даже после выхода из области видимости;
— не были вызваны деструкторы ни для зомби, ни для WriteToConsoleListener.
Возникла утечка памяти.
Возникла утечка ресурса. А ресурс в данном случае — поток выполнения.
Код, который должен был остановиться, продолжил работу в отдельном потоке.
Утечку WriteToConsoleListener можно было бы предотвратить применением техники SingletonWeak из моей статьи Техника избежания неопределённого поведения при обращении к синглтону, но я намеренно не стал этого делать.
(изображение взято отсюда)
Почему «Зомби»?
Потому что его убили, а он всё ещё жив.
Чем это отличается от циклических ссылок из предыдущих примеров?
Тем, что потерянный ресурс — это не просто участок памяти, а нечто, самостоятельно выполняющее код независимо от запустившего его потока.
Можно ли уничтожить «Зомби»?
После выхода из области видимости (т.е. после уничтожения всех внешних сильных и слабых ссылок на зомби) — нельзя. Зомби уничтожится тогда, когда сам решит уничтожиться (да-да, это же нечто с активным поведением), возможно — никогда, т.е. доживёт до момента зачистки операционной системой при завершении приложения. Конечно, пользовательский код может иметь какое-то влияние на условие выхода из зомби-кода, но это влияние будет опосредованным и зависящим от реализации.
А до выхода из области видимости?
Можно явно вызвать деструктор зомби, но при этом вряд ли удастся избежать неопределённого поведения из-за повторного уничтожения объекта ещё и деструктором умного указателя — это борьба с RAII. Или можно добавить функцию явной деинициализации — а это отказ от RAII.
Чем это отличается от простого запуска потока с последущим detach()?
В случае с зомби, в отличие от простого вызова detach(), присутствует задумка на остановку потока. Только она не срабатывает. Присутствие правильной задумки способствует маскировке проблемы.
Пример всё ещё синтетический?
Частично. В данном простом примере не было достаточных оснований для применения shared_from_this() — например, можно было обойтись захватом weak_from_this() или захватом всех нужных полей класса. Но при усложнении задачи баланс может смещаться в сторону
shared_from_this().
Valgrind, Valgrind! У нас же есть дополнительная линия защиты от зомби!
Увы и ах — но Valgrind не выявил утечку памяти. Почему — я не знаю. В диагностике присутствуют только записи «possibly lost», указывающие на системные функции — примерно такие же и примерно в том же количестве, что и при отработке пустого main. Указания на пользовательский код отсутствуют. Возможно, другие инструменты динамического анализа справились бы лучше, но если Вы всё ещё надеетесь на них — читайте дальше.
Код в данном примере продвигается по шагам resolveDnsName ---> connectTcp ---> establishSsl ---> sendHttpRequest ---> readHttpReply, имитируя работу клиентского HTTPS-соединения в асинхронном исполнении. Каждый шаг занимает примерно секунду.
Как и в предыдущем примере, вызов runOnce() привёл к возникновению циклической ссылки.
Но на этот раз деструкторы Zomby и WriteToConsoleListener были вызваны. Все ресурсы были корректно освобождены до момента завершения приложения. Утечки памяти не произошло.
В чём же тогда проблема?
Проблема в том, что зомби прожил слишком долго — примерно три с половиной секунды после уничтожения всех внешних сильных и слабых ссылок на него. Примерно на три секунды дольше, чем ему следовало прожить. И всё это время он занимался продвижением выполнения HTTPS-соединения — до тех пор, пока не довёл его до конца. Несмотря на то, что результат уже не был нужен. Несмотря на то, что вышестоящая бизнес-логика пыталась остановить зомби.
Ну подумаешь, получили никому не нужный ответ....
В случае с клиентским HTTPS-соединением последствия на нашей стороне могут быть следующими:
— расход памяти;
— расход процессора;
— расход TCP-портов;
— расход полосы пропускания канала связи (как запрос, так и ответ могут быть объёмом в мегабайты);
— нежданные данные могут нарушить работу вышестоящей бизнес-логики — вплоть до перехода на неправильную ветвь выполнения или до неопределённого поведения, т.к. механизмы обработки ответа могут быть уже уничтожены.
А на удалённой стороне (не забывайте — HTTPS-запрос кому-то предназначался) — точно такая же растрата ресурсов, плюс возможно:
— опубликование фотографий котиков на корпоративном сайте;
— отключение тёплого пола у Вас на кухне;
— исполнение торгового приказа на бирже;
— перевод денег с Вашего счёта;
— запуск межконтинентальной баллистической ракеты.
Бизнес-логика пыталась остановить зомби, удалив все сильные и слабые ссылки на него. Остановка продвижения HTTPS-запроса должна была произойти — было ещё не слишком поздно, данные прикладного уровня ещё не были отправлены.
Но зомби решил по-своему.
Бизнес-логика может создавать новые объекты на место зомби и снова пытаться их уничтожить, кратно увеличивая утечку ресурсов.
В случае с длящимся процессом (например, Websocket-соединением) растрата ресурсов может продолжаться часами, а при наличии в реализации механизма авто-переподключения при обрыве соединения — вообще до остановки программы.
Valgrind?
Без шансов. Всё корректно освобождено и подчищено. Поздно и не из главного потока, но полностью корректно.
В данном примере используется библиотека boozd::azzio, являющаяся имитацией boost::asio. Несмотря на то, что имитация довольно грубая, она позволяет продемонстрировать суть проблемы. В библиотеке есть функция io_context::async_read (в оригинале она свободная, но сути это не меняет), принимающая:
— stream, из которого могут приходить данные;
— буфер, позволяющий эти данные накапливать;
— callback-функцию, которая будет вызвана по завершении считывания данных.
Функция io_context::async_read выполняется мгновенно и никогда не вызывает callback, даже если результат выполнения уже известен (например, ошибка). Вызов коллбэка происходит только из блокирующей функции io_context::run() (в оригинале есть и другие функции, предназначенные для вызова коллбэков по мере готовности данных).
Единственная реализация интерфейса boozd::azzio::stream, выдающая случайные данные:
BoozdedZomby запускает в отдельном потоке лямбда-функцию. Лямбда-функция регистрирует обработчик с помощью вызова async_read(), после чего отдаёт управление внутренним механизмам boozd::azzio с помощью run(). После этого внутренние механизмы boozd::azzio могут производить обращения к буферу и потоку (источнику данных) в любой момент до вызова callback-функции. Для обеспечения гарантии валидности множества объектов, агрегированных в экземпляре класса, лямбда-функция захватывает shared_from_this.
В результате вызова 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 — они очень опасны.
Введение
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 и т.п.);
— «я ж так не пишу»;
— «у меня архитектура правильная»;
— «у нас код проходит ревью».
— файлы;
— временные папки на диске;
— сетевые соединения (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 имеет существенные нюансы при наследовании, но их обсуждение выходит за рамки статьи.
Что же делает 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 — они очень опасны.