¿Y si cavas un poco más profundo?
std :: make_shared útil
¿Por qué std :: make_shared apareció en STL?
Hay un ejemplo canónico en el que construir un std :: shared_ptr a partir de un puntero sin formato recién creado puede provocar una pérdida de memoria:
process(std::shared_ptr<Bar>(new Bar), foo());
Para calcular los argumentos de la función de proceso (...), debe llamar a:
- nuevo bar;
- constructor std :: shared_ptr;
- foo ().
El compilador puede mezclarlos en cualquier orden, por ejemplo, así:
- nuevo bar;
- foo ();
- constructor std :: shared_ptr.
Si se lanza una excepción en foo (), obtenemos una fuga de la instancia de Bar.
Ninguno de los siguientes ejemplos de código contiene una fuga potencial (pero volveremos a esta pregunta):
auto bar = std::shared_ptr<Bar>(new Bar);
auto bar = std::shared_ptr<Bar>(new Bar);
process(bar, foo());
process(std::shared_ptr<Bar>(new Bar));
Repito: para una posible fuga, debe escribir exactamente el mismo código que en el primer ejemplo: una función toma al menos dos parámetros, uno de los cuales se inicializa con un std :: shared_ptr recién creado y el segundo parámetro se inicializa llamando a otra función que puede generar excepciones.
Y para que se produzca una posible pérdida de memoria, se necesitan dos condiciones más:
- para que el compilador baraje las llamadas de manera desfavorable;
- para que la función que evalúa el segundo parámetro en realidad arroje una excepción.
Es poco probable que este código peligroso ocurra más de una vez en cada cien usos de std :: shared_ptr.
Y para compensar este peligro, std :: shared_ptr fue apoyado por una muleta llamada std :: make_shared.
Para endulzar un poco la píldora, se agregó la siguiente frase a la descripción std :: make_shared en el Estándar:
Observaciones: las implementaciones no deben realizar más de una asignación de memoria.
Nota: las implementaciones no deben producir más de una asignación de memoria.
No, esto no es una garantía.
Pero cppreference dice que todas las implementaciones conocidas hacen exactamente eso.
Esta solución tiene como objetivo mejorar el rendimiento en comparación con la creación de std :: shared_ptr llamando a un constructor que requiere al menos dos asignaciones: una para colocar el objeto y la segunda para controlar el bloque.
std :: make_shared es inútil
Comenzando con c ++ 17, ya no es posible una pérdida de memoria en ese complicado ejemplo raro para el que std :: make_shared se agregó a la STL.
Enlaces de estudio:
- Documentación en cppreference.com : busque "hasta C ++ 17";
- La profundidad de una madriguera de conejo o una entrevista en C ++ en PVS-Studio
- Más documentación en cppreference.com - elemento 15.
Hay varios otros casos en los que std :: make_shared es inútil:
std :: make_shared no podrá llamar al constructor privado
#include <memory>
class Bar
{
public:
static std::shared_ptr<Bar> create()
{
// return std::make_shared<Bar>(); - no build
return std::shared_ptr<Bar>(new Bar);
}
private:
Bar() = default;
};
int main()
{
auto bar = Bar::create();
return 0;
}
std :: make_shared no admite eliminadores personalizados
… variadic template. , , deleter.
std::make_shared_with_custom_deleter…
std::make_shared_with_custom_deleter…
Es bueno al menos aprender sobre estos problemas en tiempo de compilación ...
std :: make_shared es dañino
Pasamos en tiempo de ejecución.
std :: make_shared ignorará el operador sobrecargado nuevo y el operador eliminado.
std::shared_ptr:
std::make_shared:
#include <memory>
#include <iostream>
class Bar
{
public:
void* operator new(size_t)
{
std::cout << __func__ << std::endl;
return ::new Bar();
}
void operator delete(void* bar)
{
std::cout << __func__ << std::endl;
::delete static_cast<Bar*>(bar);
}
};
int main()
{
auto bar = std::shared_ptr<Bar>(new Bar);
// auto bar = std::make_shared<Bar>();
return 0;
}
std::shared_ptr:
operator new
operator delete
std::make_shared:
Y ahora, lo más importante para lo que se inició el artículo en sí.
Sorprendentemente, es cierto: cómo std :: shared_ptr manejará la memoria puede depender significativamente de cómo se creó, ¡usando std :: make_shared o usando un constructor!
¿Por qué está pasando esto?
Porque la asignación uniforme "útil" producida por std :: make_shared tiene un efecto secundario inherente de comunicación innecesaria entre el bloque de control y el objeto administrado. Simplemente no pueden ser lanzados individualmente. Un bloque de control debe vivir mientras haya al menos un enlace débil.
Desde std :: shared_ptr creado usando el constructor, debe esperar el siguiente comportamiento:
- asignación de un objeto gestionado (antes de llamar al constructor, es decir, en el lado del usuario);
- asignación de la unidad de control;
- cuando destruye el último enlace fuerte, llama al destructor del objeto administrado y libera la memoria que ocupa ; si al mismo tiempo no hay un solo enlace débil: liberación de la unidad de control;
- tras la destrucción del último eslabón débil en ausencia de eslabones fuertes: la liberación de la unidad de control.
Y si se crea usando std :: make_shared:
- asignación del objeto gestionado y la unidad de control;
- tras la destrucción del último enlace fuerte, una llamada al destructor del objeto gestionado sin liberar la memoria ocupada por él ; Si no hay un enlace débil, suelte el bloque de control y la memoria del objeto administrado;
- — .
Crear std :: shared_ptr con std :: make_shared provoca una fuga de espacio.
Es imposible distinguir en tiempo de ejecución exactamente cómo se creó la instancia std :: shared_ptr.
Pasamos a probar este comportamiento.
Hay una manera muy simple: use std :: allocate_shared con el asignador personalizado, que le informará todas las llamadas. Pero es incorrecto extender los resultados obtenidos de esta manera a std :: make_shared.
Una forma más correcta es controlar el consumo total de memoria. Pero no se habla de ninguna plataforma cruzada.
Código para Linux, probado en Ubuntu 20.04 escritorio x64. ¿Quién está interesado en repetir esto para otras plataformas? Vea aquí (Mis experimentos con macOs mostraron que la opción TASK_BASIC_INFO no permite el seguimiento de memoria libre, y TASK_VM_INFO_PURGEABLE es un candidato más adecuado).
Monitoreo.h
#pragma once
#include <cstdint>
uint64_t memUsage();
Monitoring.cpp
#include "Monitoring.h"
#include <fstream>
#include <string>
uint64_t memUsage()
{
auto file = std::ifstream("/proc/self/status", std::ios_base::in);
auto line = std::string();
while(std::getline(file, line)) {
if (line.find("VmSize") != std::string::npos) {
std::string toConvert;
for (const auto& elem : line) {
if (std::isdigit(elem)) {
toConvert += elem;
}
}
return stoull(toConvert);
}
}
return 0;
}
main.cpp
#include <iostream>
#include <array>
#include <numeric>
#include <memory>
#include "Monitoring.h"
struct Big
{
~Big()
{
std::cout << __func__ << std::endl;
}
std::array<volatile unsigned char, 64*1024*1024> _data;
};
volatile uint64_t accumulator = 0;
int main()
{
std::cout << "initial: " << memUsage() << std::endl;
auto strong = std::shared_ptr<Big>(new Big);
// auto strong = std::make_shared<Big>();
std::accumulate(strong->_data.cbegin(), strong->_data.cend(), accumulator);
auto weak = std::weak_ptr<Big>(strong);
std::cout << "before reset: " << memUsage() << std::endl;
strong.reset();
std::cout << "after strong reset: " << memUsage() << std::endl;
weak.reset();
std::cout << "after weak reset: " << memUsage() << std::endl;
return 0;
}
Salida a la consola cuando se usa el constructor std :: shared_ptr:
inicial: 5884
antes del reinicio: 71424
~ Grande
después del reinicio seguro: 5884
después del reinicio débil: 5884
Salida de la consola cuando se usa std :: make_shared:
inicial: 5888
antes del reinicio: 71428
~ Grande
después del reinicio fuerte: 71428
después del reinicio débil: 5888
Prima
Aún así, ¿es posible perder memoria como resultado de la ejecución del código?
auto bar = std::shared_ptr<Bar>(new Bar);
?
¿Qué sucede si la asignación de Bar tiene éxito, pero no hay suficiente memoria para el bloque de control?
¿Y qué sucede si se llama al constructor con un eliminador personalizado?
La sección [util.smartptr.shared.const] del Estándar asegura que cuando ocurra una excepción dentro del constructor std :: shared_ptr:
- para un constructor sin eliminador personalizado, el puntero pasado se eliminará usando delete o delete [];
- para un constructor con un eliminador personalizado, el puntero pasado se eliminará utilizando este mismo eliminador.
No hay fugas garantizadas por la norma.
Como resultado de una lectura rápida de las implementaciones en tres compiladores (Apple clang versión 11.0.3, GCC 9.3.0, MSVC 2019 16.6.2), puedo confirmar que todo es así.
Salida
En C ++ 11 y C ++ 14, el daño del uso de std :: make_shared podría compensarse con su única función útil.
Comenzando con c ++ 17, la aritmética no está a favor de std :: make_shared.
La situación es similar con std :: allocate_shared.
Gran parte de lo anterior también es cierto para std :: make_unique, pero tiene menos daño.