Oh, ce std :: make_shared ...

Les directives de base C ++ contiennent une règle R22 qui dit d'utiliser std :: make_shared au lieu d'appeler le constructeur std :: shared_ptr. Il n'y a qu'un seul argument dans les Principes directeurs pour une telle décision - économiser sur l'allocation (et la désallocation).



Et si tu creuses un peu plus?



std :: make_shared utile



Pourquoi std :: make_shared est-il apparu dans STL?



Il existe un exemple canonique où la construction d'un std :: shared_ptr à partir d'un pointeur brut fraîchement créé peut conduire à une fuite de mémoire:



process(std::shared_ptr<Bar>(new Bar), foo());


Pour calculer les arguments de la fonction process (...), vous devez appeler:



  • nouveau bar;
  • constructeur std :: shared_ptr;
  • foo ().


Le compilateur peut les mélanger dans n'importe quel ordre, par exemple comme ceci:



  • nouveau bar;
  • foo ();
  • constructeur std :: shared_ptr.


Si une exception se produit dans foo (), nous obtenons une fuite de l'instance Bar.



Aucun des exemples de code suivants ne contient une fuite potentielle (mais nous reviendrons sur cette question plus tard):



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));


Je le répète: pour qu'une fuite potentielle se produise, il est nécessaire d'écrire un code comme dans le tout premier exemple - une fonction prend au moins deux paramètres, dont l'un est initialisé par le std :: shared_ptr sans nom fraîchement créé, et le deuxième paramètre est initialisé en appelant une autre fonction, ce qui peut lever des exceptions.



Et pour qu'une fuite de mémoire potentielle se matérialise, deux autres conditions sont nécessaires:



  • de sorte que le compilateur mélange les appels de manière défavorable;
  • de sorte que la fonction évaluant le deuxième paramètre lève réellement une exception.


Il est peu probable qu'un tel code dangereux se produise plus d'une fois sur cent utilisations de std :: shared_ptr.

Et pour compenser ce danger, std :: shared_ptr a été sauvegardé avec une béquille appelée std :: make_shared.



Pour adoucir légèrement la pilule, la phrase suivante a été ajoutée à la description de std :: make_shared dans le Standard:

Remarques: les implémentations ne doivent pas effectuer plus d'une allocation de mémoire.


Remarque: les implémentations DEVRAIENT faire plus d'une allocation de mémoire.



Non, ce n'est pas une garantie.

Mais cppreference dit que toutes les implémentations connues font exactement cela.



Cette solution vise à améliorer les performances par rapport à la création de std :: shared_ptr en appelant un constructeur qui nécessite au moins deux allocations: une pour placer l'objet et la seconde pour contrôler le bloc.



std :: make_shared inutile



À partir de C ++ 17, une fuite de mémoire dans cet exemple rare et délicat pour lequel std :: make_shared a été ajouté à STL n'est plus possible.



Liens d'étude:





Il existe plusieurs autres cas dans lesquels std :: make_shared est inutile:



std :: make_shared ne pourra pas appeler le constructeur privé
#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 ne prend pas en charge les suppressions personnalisées
… variadic template. , , deleter.

std::make_shared_with_custom_deleter…



Eh bien, apprenez au moins ces problèmes au moment de la compilation ...



std :: make_shared est dangereux



Nous passons en runtime.



l'opérateur surchargé new et l'opérateur delete seront ignorés par 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:





Et maintenant - la chose la plus importante pour laquelle l'article lui-même a été lancé.



Étonnamment, le fait: comment std :: shared_ptr va gérer la mémoire peut dépendre de manière significative de la façon dont elle a été créée - en utilisant std :: make_shared ou en utilisant le constructeur!



Pourquoi cela arrive-t-il?



Parce que l'allocation uniforme "utile" produite par std :: make_shared a un effet secondaire inhérent de communication inutile entre le bloc de contrôle et l'objet géré. Ils ne peuvent tout simplement pas être libérés individuellement. Un bloc de contrôle doit vivre tant qu'il y a au moins un maillon faible.



De std :: shared_ptr créé à l'aide du constructeur, vous devez vous attendre au comportement suivant:



  • allocation d'un objet géré (avant d'appeler le constructeur, c'est-à-dire du côté utilisateur);
  • affectation de l'unité de contrôle;
  • lors de la destruction du dernier maillon fort, l'appel du destructeur de l'objet géré et la libération de la mémoire qu'il occupe ; si en même temps il n'y a pas un seul maillon faible - libération de l'unité de contrôle;
  • sur la destruction du dernier maillon faible en l'absence de liens forts - libération du bloc de contrôle.


Et si créé avec std :: make_shared:



  • allocation de l'objet géré et de l'unité de contrôle;
  • lors de la destruction de la dernière référence forte - appeler le destructeur de l'objet géré sans libérer la mémoire qu'il occupe ; s'il n'y a pas en même temps un seul maillon faible - la libération de l'unité de contrôle et de la mémoire de l'objet géré;
  • — .


La création de std :: shared_ptr avec std :: make_shared provoque une fuite d'espace.



Il est impossible de distinguer à l'exécution exactement comment l'instance std :: shared_ptr a été créée.



Passons au test de ce comportement.



Il existe un moyen très simple: utilisez std :: allocate_shared avec un allocateur personnalisé, qui rapportera tous les appels. Mais il est incorrect de distribuer les résultats obtenus de cette manière à std :: make_shared.



Une manière plus correcte consiste à contrôler la consommation totale de mémoire. Mais il n'est pas question de multiplateforme.



Code pour Linux, testé sur le bureau Ubuntu 20.04 x64. Qui est intéressé à répéter cela pour d'autres plateformes - voir ici (mes expériences avec macOs ont montré que l'option TASK_BASIC_INFO ne suit pas la libération de mémoire, et TASK_VM_INFO_PURGEABLE est un meilleur candidat).



Surveillance.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;
}




Sortie de la console lors de l'utilisation du constructeur std :: shared_ptr:

initiale: 5884

avant réinitialisation: 71424

~ Grande

après réinitialisation forte: 5884

après réinitialisation faible: 5884



Sortie vers la console lors de l'utilisation de std :: make_shared:

initial: 5888

avant réinitialisation: 71428

~ Gros

après réinitialisation forte: 71428

après réinitialisation faible: 5888



Prime



Pourtant, est-il possible de fuir de la mémoire à la suite de l'exécution de code



auto bar = std::shared_ptr<Bar>(new Bar);


?

Que se passe-t-il si l'allocation de Bar se termine avec succès, mais qu'il n'y a plus assez de mémoire pour le bloc de contrôle?



Et que se passe-t-il si le constructeur avec un deleter personnalisé a été appelé?



La section [util.smartptr.shared.const] de la norme garantit que lorsqu'une exception se produit dans le constructeur std :: shared_ptr:



  • pour un constructeur sans suppression personnalisée, le pointeur passé sera supprimé en utilisant delete ou delete [];
  • pour un constructeur avec un deleter personnalisé, le pointeur passé sera supprimé en utilisant ce même deleter.


Aucune fuite garantie par la norme.



À la suite d'une lecture rapide des implémentations dans trois compilateurs (Apple clang version 11.0.3, GCC 9.3.0, MSVC 2019 16.6.2), je peux confirmer que c'est le cas.



Production



En c ++ 11 et c ++ 14, le mal d'utiliser std :: make_shared pourrait être compensé par sa seule fonction utile.



Depuis c ++ 17, l'arithmétique n'est pas du tout en faveur de std :: make_shared.



La situation est similaire avec std :: allocate_shared.



Une grande partie de ce qui précède est également vraie pour std :: make_unique, mais cela fait moins de mal.



All Articles