El artículo proporciona un antipatrón peligroso "Zombie", que en algunas situaciones surge naturalmente cuando se usa std :: enable_shared_from_this. El material está en algún lugar en la unión de la tecnología y la arquitectura modernas de C ++.
Introduccion
C ++ 11 proporcionó al desarrollador herramientas maravillosas para trabajar con memoria: punteros inteligentes std :: unique_ptr y un montón de std :: shared_ptr + std :: weak_ptr. El uso de punteros inteligentes para mayor comodidad y seguridad supera con creces el uso de punteros en bruto. Los punteros inteligentes son ampliamente utilizados en la práctica, ya que Permitir al desarrollador centrarse en problemas de nivel superior que el seguimiento de la corrección de la creación / eliminación de entidades creadas dinámicamente.
La plantilla de clase std :: enable_shared_from_this también es parte del estándar, y parece bastante extraño cuando la conoces por primera vez.
El artículo discutirá cómo puede quedarse atascado con su uso.
Programa educativo
RAII y punteros inteligentesEl propósito directo de los punteros inteligentes es cuidar una porción de RAM asignada en el montón. Los punteros inteligentes implementan el modismo RAII (la adquisición de recursos es la inicialización) y se pueden adaptar fácilmente para cuidar otros tipos de recursos que requieren inicialización y una inicialización no trivial, como:
- archivos;
- carpetas temporales en el disco;
- conexiones de red (http, websockets);
- hilos de ejecución (hilos);
- mutexes;
- otro (que es suficiente para la fantasía).
Para tal generalización, es suficiente escribir una clase (de hecho, a veces incluso no puedes escribir una clase, solo usa deleter, pero hoy la historia no se trata de eso), que implementa:
- inicialización en el constructor o en un método separado;
- desinicialización en el destructor,
luego "envuélvalo" en el puntero inteligente correspondiente, según el modelo de propiedad requerido: conjunto (std :: shared_ptr) o único (std :: unique_ptr). Esto da como resultado una "RAII de dos capas": un puntero inteligente le permite transferir / compartir la propiedad del recurso, y la clase de usuario inicializa / desinicializa un recurso no estándar.
std :: shared_ptr usa un mecanismo de conteo de enlaces. El estándar define el contador de enlaces fuertes (cuenta el número de copias existentes de std :: shared_ptr) y el contador de enlaces débiles (cuenta el número de copias existentes de std :: weak_ptr creadas para esta instancia de std :: shared_ptr). La presencia de al menos un enlace fuerte asegura que la destrucción aún no se ha realizado. Esta propiedad std :: shared_ptr se usa ampliamente para garantizar la validez de un objeto hasta que se complete el trabajo con él en todas las partes del programa. La presencia de un enlace débil no evita la destrucción del objeto y le permite obtener un enlace fuerte solo hasta que se destruya.
RAII garantiza que la liberación de un recurso es mucho más confiable que una llamada explícita para eliminar / eliminar [] / free / close / reset / unlock, porque:
- puedes simplemente olvidar la llamada explícita;
- una llamada explícita puede hacerse erróneamente más de una vez;
- un desafío explícito es difícil cuando se implementa la propiedad compartida de un recurso;
- el mecanismo de promoción de pila en c ++ garantiza la llamada de destructores para todos los objetos que quedan fuera del alcance en caso de una excepción.
La garantía de la inicialización en el idioma es tan importante que merece un buen lugar en el nombre del idioma junto con la inicialización.
Los punteros inteligentes también tienen desventajas:
- la presencia de sobrecarga en términos de rendimiento y memoria (para la mayoría de las aplicaciones no es significativa);
- la posibilidad de que los enlaces cíclicos bloqueen la liberación del recurso y conduzcan a su fuga.
Seguramente, cada desarrollador más de una vez leyó sobre enlaces circulares y vio ejemplos sintéticos de código problemático.
El peligro puede parecer insignificante por las siguientes razones:
- si la memoria se pierde con frecuencia y mucho, esto es notable en su consumo, y si rara vez es escaso, entonces es poco probable que el problema se manifieste a nivel del usuario final;
- utiliza análisis de código dinámico para fugas (Valgrind, Clang LeakSanitizer, etc.);
- "No escribo así";
- "mi arquitectura es correcta";
"Nuestro código está siendo revisado".
std :: enable_shared_from_thisEn C ++ 11, se introduce la clase auxiliar std :: enable_shared_from_this. Para un desarrollador que construye código exitosamente sin std :: enable_shared_from_this, los usos potenciales de esta clase pueden no ser obvios.
¿Qué hace std :: enable_shared_from_this?
Permite que las funciones miembro de la clase que se instancian en std :: shared_ptr reciban copias adicionales fuertes (shared_from_this ()) o débiles (weak_from_this (), a partir de C ++ 17) de std :: shared_ptr en el que se creó . No puede llamar a shared_from_this () y weak_from_this () desde el constructor y destructor.
¿Por qué tan difícil? Simplemente puede construir std :: shared_ptr <T> (esto)
No, no puedes. Todos los std :: shared_ptrs que se preocupan por la misma instancia de la clase deben usar una unidad de conteo de enlaces. No hay forma de hacerlo sin magia especial.
Un requisito previo para usar std :: enable_shared_from_this es crear inicialmente un objeto de clase en std :: shared_ptr. Crear en la pila, asignar dinámicamente en el montón, crear en std :: unique_ptr: todo esto no es adecuado. Solo estrictamente en std :: shared_ptr.
¿Es posible limitar al usuario en la forma de crear instancias de la clase?
Si puedes. Para hacer esto, solo:
- proporciona un método estático para crear instancias ubicadas originalmente en std :: shared_ptr;
- poner al constructor en privado o protegido;
- Prohibir la semántica de copia y movimiento.
La clase entró en la jaula, la cerró con llave y se tragó la llave; de ahora en adelante, todas sus instancias vivirán solo en std :: shared_ptr, y no hay formas legales de sacarlas de allí.
Dicha restricción no puede llamarse una buena solución arquitectónica, pero este método cumple totalmente con el estándar.
Además, puede usar el lenguaje PIMPL: el único usuario de la clase caprichosa, la fachada, creará la implementación estrictamente en std :: shared_ptr, y la fachada misma ya estará privada de restricciones de este tipo.
std :: enable_shared_from_this tiene muchos matices en la herencia, pero discutirlos está más allá del alcance de este artículo.
Llegar al punto
Todos los ejemplos de código proporcionados en el artículo se publican en el
github .
El código demuestra malas técnicas disfrazadas como el uso seguro habitual de C ++ moderno
Simplecíclico
Parece que nada augura problemas. Una declaración de clase parece simple y directa. Excepto por un detalle "pequeño", por alguna razón se aplica la herencia de 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
Y en implementación:
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; }
Salida de la consolaN12SimpleCyclic6CyclicE :: doSomething
En el cuerpo de la función doSomething (), la instancia de la clase creará una copia segura adicional de std :: shared_ptr en la que se colocó. Luego, usando una captura generalizada, esta copia se coloca en una función lambda asignada al campo de datos de la clase bajo la apariencia de una función std :: inofensiva. Una llamada a doSomething () da como resultado una referencia circular, y la instancia de clase ya no se destruirá incluso después de la destrucción de todos los enlaces fuertes externos.
Hay una pérdida de memoria. No se llama al destructor SimpleCyclic :: Cyclic :: ~ Cyclic.
La instancia de clase "se mantiene" en sí misma.
El código quedó atrapado en un nudo.
(imagen tomada
de aquí )
¿Y qué, este es el antipatrón "Zombie"?No, esto es solo un entrenamiento. Todo lo más interesante está por venir.
¿Por qué el desarrollador escribió esto?Ejemplo sintético No tengo conocimiento de ninguna situación en la que dicho código se obtenga armoniosamente.
Entonces, ¿el análisis de código dinámico permaneció en silencio?No, Valgrind honestamente informó una pérdida de memoria:
Publicar Valgrind96 (64 directos, 32 indirectos) bytes en 1 bloques definitivamente se pierden en el registro de pérdidas 29 de 46
en SimpleCyclic :: Cyclic :: create () en /Users/User/Projects/Zomby_antipattern_concept/SimpleCyclic/SimpleCyclic.cpp:15
1: malloc en /usr/local/Cellar/valgrind/HEAD-60ab74a/lib/valgrind/vgpreload_memcheck-amd64-darwin.so
2: operador nuevo (sin firmar largo) en /usr/lib/libc++abi.dylib
3: SimpleCyclic :: Cyclic :: create () en /Users/User/Projects/Zomby_antipattern_concept/SimpleCyclic/SimpleCyclic.cpp:15
4: principal en /Users/User/Projects/Zomby_antipattern_concept/SimpleCyclic/main.cpphaps
Pimplcíclico
En este caso, el archivo de encabezado se ve completamente correcto y conciso. Declaró una fachada que almacena una determinada implementación en std :: shared_ptr. Falta la herencia, incluso de std :: enable_shared_from_this, a diferencia del ejemplo anterior.
Pimplcyclic.h #pragma once #include <memory> namespace PimplCyclic { class Cyclic { public: Cyclic(); ~Cyclic(); private: class Impl; std::shared_ptr<Impl> _impl; }; } // namespace PimplCyclic
Y en implementación:
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; }
Salida de la consolaN11PimplCyclic6Cyclic4ImplE :: doSomething
N11PimplCyclic6CyclicE :: ~ Cyclic
Llamar a Impl :: doSomething () crea una referencia circular en una instancia de la clase Impl. La fachada se destruye correctamente, pero la implementación tiene fugas. No se llama al destructor PimplCyclic :: Cyclic :: Impl :: ~ Impl.
El ejemplo es nuevamente sintético, pero esta vez más peligroso: todo el equipo defectuoso se encuentra en la implementación y no aparece en el anuncio.
Además, para crear un enlace circular, el código de usuario no requirió ninguna otra acción que no sea la construcción.
Un análisis dinámico frente a Valgrind, y esta vez reveló una fuga:
Publicar Valgrind96 bytes en 1 bloques definitivamente se pierden en el registro de pérdida 29 de 46
en PimplCyclic :: Cyclic :: Cyclic () en /Users/User/Projects/Zomby_antipattern_concept/PimplCyclic/PimplCyclic.cpp:28
1: malloc en /usr/local/Cellar/valgrind/HEAD-60ab74a/lib/valgrind/vgpreload_memcheck-amd64-darwin.so
2: operador nuevo (sin firmar largo) en /usr/lib/libc++abi.dylib
3: std :: __ 1 :: __ libcpp_allocate (sin signo largo, sin signo largo) en /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include/c++/v1/new:252
4: std :: __ 1 :: asignador <std :: __ 1 :: __ shared_ptr_emplace <PimplCyclic :: Cyclic :: Impl, std :: __ 1 :: allocator <PimplCyclic :: Cyclic :: Impl >>> allocate (unsigned long , nulo const *) en /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 <> () en /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_ en /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include/c++/v1/memory:4706
7: PimplCyclic :: Cyclic :: Cyclic () en /Users/User/Projects/Zomby_antipattern_concept/PimplCyclic/PimplCyclic.cpp:28
8: PimplCyclic :: Cyclic :: Cyclic () en /Users/User/Projects/Zomby_antipattern_concept/PimplCyclic/PimplCyclic.cpp:29
9: principal en /Users/User/Projects/Zomby_antipattern_concept/PimplCyclic/main.cpphaps
Es un poco sospechoso ver Pimpl, en el que la implementación se almacena en std :: shared_ptr.El Pimpl clásico basado en un puntero sin formato es demasiado arcaico, y std :: unique_ptr tiene el efecto secundario de difundir la prohibición de la semántica de copias en la fachada. Tal fachada implementará el idioma de propiedad exclusiva, que puede no corresponder con la idea arquitectónica. Del uso de std :: shared_ptr para almacenar la implementación, concluimos que la clase está diseñada para proporcionar propiedad compartida.
¿Cómo difiere esto de la fuga clásica: asignar memoria llamando explícitamente a new sin eliminación posterior? Del mismo modo, todo sería hermoso en la interfaz y en la implementación: un error.Estamos discutiendo formas
modernas de dispararte en el pie.
"Zombis" antipatrones
Entonces, del material anterior está claro:
- los punteros inteligentes pueden vincularse en nodos;
- el uso de std :: enable_shared_from_this puede contribuir a esto, porque permite que una instancia de una clase se vincule a un nodo casi sin ayuda externa.
Y ahora, atención, la pregunta clave del artículo: ¿importa el tipo de recurso envuelto en un puntero inteligente? ¿Hay alguna diferencia entre el cuidado de un archivo RAII y una conexión HTTPS asíncrona?Simplezomby
El código común a todos los ejemplos posteriores de zombies se ha movido a la biblioteca común.
Interfaz zombie abstracta con el modesto nombre Manager:
Común / 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
Interfaz abstracta del oyente, lista para aceptar texto seguro para subprocesos:
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
Oyente que muestra texto en la consola. Implementa el concepto SingletonShared de mi artículo
Técnica para evitar comportamientos indefinidos al llamar a un Singleton :
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
Y finalmente, el primer zombie, el más simple e ingenuo.
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; if (_thread.joinable()) { _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
Un zombie ejecuta una función lambda en un hilo separado, enviando periódicamente una cadena al oyente. Las funciones de Lambda para el trabajo necesitan un semáforo y un oyente, que son campos de la clase zombie. La función lambda no los captura como campos separados, sino que usa el objeto como un agregador. Destruir una instancia de la clase zombie antes de que se complete la función lambda dará como resultado un comportamiento indefinido. Para evitar esto, la función lambda captura una copia segura de shared_from_this ().
En el destructor de zombies, el semáforo se establece en falso, después de lo cual se llama a detach () para la secuencia. Establecer el semáforo le dice al hilo que se apague.
En el destructor, era necesario llamar no detach (), sino unirse ()!... y obtener un destructor que bloquea la ejecución por tiempo indefinido, lo que puede ser inaceptable.
¡Entonces esto es una violación de RAII! ¡Se suponía que RAII debía salir del destructor solo después de liberar el recurso!Si es estrictamente, entonces sí, el destructor zombie no libera el recurso, sino que solo
garantiza que se realizará la liberación . En algún momento producido, tal vez pronto, o tal vez no realmente. E incluso es posible que main termine el trabajo antes, luego el sistema operativo borrará el hilo a la fuerza. Pero, de hecho, la línea entre RAII "correcto" e "incorrecto" puede ser muy delgada: por ejemplo, RAII "correcto", que llama std :: filesystem :: remove () en un destructor para un archivo temporal, bien puede devolver el control a ese el momento en que el comando de escritura todavía estará en cualquiera de los cachés volátiles y no se escribirá honestamente en la placa magnética del disco duro.
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; }
Salida de la consolaSimpleZomby está vivo!
SimpleZomby está vivo!
SimpleZomby está vivo!
SimpleZomby está vivo!
SimpleZomby está vivo!
=================================================== ===========
El | Zomby fue asesinado |
=================================================== ===========
SimpleZomby está vivo!
SimpleZomby está vivo!
SimpleZomby está vivo!
SimpleZomby está vivo!
SimpleZomby está vivo!
Lo que se puede ver en la salida del programa:
- el zombie continuó trabajando incluso después de abandonar el campo de visibilidad;
- no se llamaron destructores para zombies o WriteToConsoleListener.
Se ha producido una pérdida de memoria.
Hubo una fuga de recursos. Y el recurso en este caso es el hilo de ejecución.
El código que se suponía que debía detenerse continuó funcionando en un hilo separado.
Una fuga WriteToConsoleListener podría haberse evitado mediante el uso de la técnica SingletonWeak de mi artículo
Evitar el comportamiento indeterminado al llamar a un Singleton , pero no lo hice intencionalmente.

(imagen tomada
de aquí )
¿Por qué zombis?Porque lo mataron y todavía está vivo.
¿Cómo difiere esto de las referencias circulares en ejemplos anteriores?El hecho de que un recurso perdido no es solo una pieza de memoria, sino algo que ejecuta código independientemente del hilo que lo lanzó.
¿Es posible destruir a los "zombis"?Después de abandonar el alcance (es decir, después de destruir todas las referencias externas fuertes y débiles a zombies), es imposible. Un zombie será destruido cuando decida destruirse a sí mismo (sí, es algo con un comportamiento activo), tal vez nunca, es decir. sobrevivirá hasta que el sistema operativo se limpie cuando la aplicación finalice. Por supuesto, el código de usuario puede tener algún efecto sobre la condición para salir del código zombie, pero este efecto será indirecto y dependerá de la implementación.
¿Y antes de salir del alcance?Puede llamar explícitamente al destructor zombie, pero es poco probable que evite un comportamiento indefinido debido a la destrucción repetida del objeto por el destructor de puntero inteligente también: esta es una lucha contra RAII. O puede agregar la función de inicialización explícita, y esto es un rechazo de RAII.
¿En qué se diferencia esto de comenzar un hilo seguido de detach ()?En el caso de los zombis, en contraste con una simple llamada a detach (), existe la idea de detener el flujo. Solo que no funciona. Tener la idea correcta ayuda a enmascarar el problema.
¿El ejemplo sigue siendo sintético?En parte En este simple ejemplo, no había suficientes razones para usar shared_from_this (); por ejemplo, podría hacerlo con la captura de weak_from_this () o la captura de todos los campos obligatorios en la clase. Pero con la complejidad de la tarea, el equilibrio puede desplazarse hacia un lado.
shared_from_this ().
Valgrind, Valgrind! ¡Tenemos una línea de defensa adicional contra zombies!Ay y ah, pero Valgrind no reveló una pérdida de memoria. ¿Por qué? No lo sé. En el diagnóstico, solo hay entradas
"posiblemente perdidas" que indican las funciones del sistema, aproximadamente la misma cantidad y aproximadamente la misma cantidad que cuando se trabaja con una tubería principal vacía. No hay referencias de código de usuario. Otras herramientas de análisis dinámico podrían funcionar mejor, pero si aún confía en ellas, siga leyendo.
Steppingzomby
El código en este ejemplo continúa con los pasos resolveDnsName ---> connectTcp ---> establecerSsl ---> sendHttpRequest ---> readHttpReply, simulando la operación de la conexión HTTPS del cliente en ejecución asincrónica. Cada paso dura aproximadamente un segundo.
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; if (_thread.joinable()) { _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; }
Salida de la consolaN13SteppingZomby5ZombyE :: resolveDnsName iniciado
N13SteppingZomby5ZombyE :: resolveDnsName terminado
N13SteppingZomby5ZombyE :: connectTcp iniciado
=================================================== ===========
El | Zomby fue asesinado |
=================================================== ===========
N13SteppingZomby5ZombyE :: connectTcp terminado
N13SteppingZomby5ZombyE :: beginSsl inició
N13SteppingZomby5ZombyE :: establecerSsl terminado
N13SteppingZomby5ZombyE :: sendHttpRequest inició
N13SteppingZomby5ZombyE :: sendHttpRequest finalizado
N13SteppingZomby5ZombyE :: readHttpReply iniciado
N13SteppingZomby5ZombyE :: readHttpReply terminado
N13SteppingZomby5ZombyE :: ~ Zomby
N6Common22WriteToConsoleListenerE :: ~ WriteToConsoleListener
Como en el ejemplo anterior, una llamada a runOnce () condujo a una referencia circular.
Pero esta vez, se llamaron a los destructores Zomby y WriteToConsoleListener. Todos los recursos se liberaron correctamente hasta que la aplicación finalizó. No se produjo una pérdida de memoria.
¿Cuál es el problema entonces?El problema es que el zombi vivió demasiado tiempo, aproximadamente tres segundos y medio después de la destrucción de todos los enlaces externos fuertes y débiles. Unos tres segundos más de lo que debería haber vivido. Y todo este tiempo estuvo involucrado en promover la implementación de la conexión HTTPS, hasta que la finalizó. A pesar de que el resultado ya no era necesario. A pesar de que la lógica empresarial superior intentó detener a los zombis.
Bueno, piénsalo, tienes la respuesta que no necesitas ...En el caso de una conexión HTTPS de cliente, las consecuencias
de nuestro lado pueden ser las siguientes:
- consumo de memoria;
- consumo de CPU;
- consumo de puerto TCP;
- el ancho de banda del canal de comunicación (tanto la solicitud como la respuesta pueden ser un volumen en megabytes);
- los datos inesperados pueden interrumpir el funcionamiento de la lógica empresarial de nivel superior, hasta la transición a la rama de ejecución incorrecta o al comportamiento indefinido, porque los mecanismos de procesamiento de respuesta ya pueden estar destruidos.
Y
en el lado remoto (no lo olvide, la solicitud HTTPS estaba destinada a alguien), exactamente el mismo desperdicio de recursos, además es posible:
- publicar fotos de gatos en un sitio web corporativo;
- deshabilitar la calefacción por suelo radiante en su cocina;
- ejecución de una orden comercial en el intercambio;
- transferencia de dinero desde su cuenta;
- lanzamiento de un misil balístico intercontinental.
La lógica de negocios trató de detener a los zombies eliminando todos los enlaces fuertes y débiles. Se
suponía que debía detenerse el progreso de la solicitud HTTPS: todavía no era demasiado tarde, los datos del nivel de la aplicación aún no se habían enviado.
Pero los zombis decidieron a su manera.
La lógica empresarial puede crear nuevos objetos en lugar de zombies y nuevamente intentar destruirlos, multiplicando la fuga de recursos.
En el caso de un proceso continuo (por ejemplo, una conexión Websocket), el desperdicio de recursos puede continuar durante horas, y si hay un mecanismo de reconexión automática en la implementación cuando se desconecta la conexión, generalmente se puede detener.
Valgrind?No hay posibilidad Todo se ha liberado y limpiado correctamente. Tarde y no desde el hilo principal, pero completamente correcto.
Boozdedzomby
Este ejemplo utiliza la biblioteca boozd :: azzio, que es una imitación de boost :: asio. A pesar de que la simulación es bastante cruda, nos permite demostrar la esencia del problema.
La biblioteca tiene una función io_context :: async_read (en el original, es gratuita, pero no cambia la esencia), que acepta:- flujo, de donde pueden venir los datos;- un búfer que le permite acumular estos datos;- una función de devolución de llamada que se llamará al finalizar la lectura de datos.La función io_context :: async_read se ejecuta instantáneamente y nunca llama a la devolución de llamada, incluso si el resultado de la ejecución ya se conoce (por ejemplo, un error). Solo se llama a una devolución de llamada desde la función de bloqueo io_context :: run () (en el original, hay otras funciones diseñadas para llamar a devoluciones de llamada tan pronto como los datos estén listos).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
La única implementación de interfaz boozd :: azzio :: stream que produce datos aleatorios: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 ejecuta una función lambda en un hilo separado. La función lambda registra el controlador llamando a async_read (), después de lo cual le da control a los mecanismos internos de boozd :: azzio usando run (). Después de eso, los mecanismos internos de boozd :: azzio pueden realizar llamadas al búfer y al flujo (fuente de datos) en cualquier momento antes de llamar a la función de devolución de llamada. Para garantizar la validez de muchos objetos agregados en una instancia de la clase, la función lambda captura 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() { _semaphore = false; if (_thread.joinable()) { _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("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; }
Salida de la consolaBoozdedZomby 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
============================================================
El | 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
Una llamada a run_once () provocó una referencia circular. Zombie continuó trabajando incluso después de abandonar el campo de visibilidad. No se llamaron a los destructores para muchos objetos creados durante el programa:- boozdedZomby;- writeToConsoleListener;- campos de datos zombie.Se ha producido una pérdida de memoria.Hubo una fuga de recursos.¿Cómo es este ejemplo diferente de los anteriores?Está mucho más cerca del código real. Esto ya no es un ejemplo sintético. Tal código puede ocurrir naturalmente cuando se usa boost :: asio. Además, no se puede solucionar simplemente negándose a capturar un enlace fuerte a favor de uno débil; esto interferirá con la validez del búfer y la transmisión (fuente de datos).Valgrind?Pasado Aunque parece haber sido detectar fugas.Zombis en la naturaleza
¡El problema es descabellado! ¡Entonces nadie escribe!Como él escribe.Ejemplo de cliente HTTP Ejemplo de clienteWebsocketLa documentación oficial de impulso enseña cómo escribir un BoozdedZomby + SteppingZomby híbrido. Es imposible detenerlo, pero nadie lo está intentando. Específicamente, en el código de demostración, la propiedad principal del zombie no aparece, pero debes transferir esto a producción, y ahora ya estás caminando por el borde, probablemente incluso en el lado oscuro.¡Puedes detener a los zombies destruyendo la instancia de boost :: asio :: io_context!... en el camino, destruyendo otras n entidades (posiblemente no zombis) que viven en este contexto.Más ejemplos:Aquí hay un ejemplo similar en un recurso de terceros.Aquí una persona hace una pregunta sobre stackoverflow, ¿cómo haría que su código sea más zombie?Aquí hay otro preguntando por qué su amado zombie no está trabajando.Aquí hay un hombre asustado de mensajes sobre pérdidas de memoria cuando opera un zombie.Conclusión
Por supuesto, el artículo no describe todas las variedades de los "zombis" antipatrón.Se puede encontrar tanto en forma de híbridos de los tipos anteriores como en forma de nuevos tipos independientes.El antipatrón puede ocurrir no solo cuando se inicia std :: thread en su código; esta parte del trabajo puede ser asumida por una biblioteca multiproceso de terceros.Un enlace cíclico puede ser más largo que en los ejemplos.La arquitectura puede ser controlada por eventos o basada en encuestas periódicas basadas en encuestas.Todo esto no es muy importante.Es importante que siempreantipattern comienza con la instancia de la clase obteniendo una fuerte referencia a sí misma. Casi siempre se genera usando std :: enable_shared_from_this, aunque también se puede proporcionar externamente (incluso como un enlace débil, la clase en sí misma puede hacerlo fuerte). Tal vez solo haya una excepción exótica a esta regla: cuando el código externo proporciona una referencia fuerte o débil a una instancia de una clase en uno de sus campos de datos.El análisis de código dinámico puede no ser capaz de detectar este antipatrón, especialmente su versión de SteppingZomby. También hay pocas esperanzas para el análisis estático: una línea muy delgada entre el uso correcto e incorrecto de shared_from_this (todos los ejemplos de código dados en el artículo se pueden corregir introduciendo cambios muy pequeños, solo de 1 a 6 líneas de código).Las pruebas automáticas pueden ayudar a identificarlo y verificar la corrección de la eliminación, pero para esto necesita saber qué buscar. Absolutamente saberBuscar antipatrón, aquí y allá, tendrá que hacerlo manualmente. Y para esto necesita repensar todas las aplicaciones de std :: enable_shared_from_this, son muy peligrosas.PD: de acuerdo con los resultados de la votación, prepararé un artículo separado con una discusión sobre las opciones para eliminar el antipatrón.