Oh, dieser std :: make_shared ...

C ++ - Kernrichtlinien enthalten die R22- Regel , die anweist, std :: make_shared zu verwenden, anstatt den Konstruktor std :: shared_ptr aufzurufen. In den Kernrichtlinien gibt es nur ein Argument für eine solche Entscheidung - Einsparungen bei der Zuweisung (und Freigabe).



Und wenn Sie etwas tiefer graben?



std :: make_shared nützlich



Warum erschien std :: make_shared überhaupt in STL?



Es gibt ein kanonisches Beispiel, bei dem das Erstellen eines std :: shared_ptr aus einem frisch erstellten Rohzeiger zu einem Speicherverlust führen kann:



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


Um die Argumente für die Prozessfunktion (...) zu berechnen, müssen Sie Folgendes aufrufen:



  • neue Bar;
  • Konstruktor std :: shared_ptr;
  • foo ().


Der Compiler kann sie in beliebiger Reihenfolge verwechseln, zum Beispiel wie folgt:



  • neue Bar;
  • foo ();
  • Konstruktor std :: shared_ptr.


Wenn in foo () eine Ausnahme ausgelöst wird, wird ein Leck der Bar-Instanz angezeigt.



Keines der folgenden Codebeispiele enthält ein potenzielles Leck (aber wir werden auf diese Frage zurückkommen):



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


Ich wiederhole: Für ein potenzielles Leck müssen Sie genau den gleichen Code wie im ersten Beispiel schreiben - eine Funktion verwendet mindestens zwei Parameter, von denen einer mit einem frisch erstellten unbenannten std :: shared_ptr initialisiert wird, und der zweite Parameter wird durch Aufrufen einer anderen Funktion initialisiert, die Ausnahmen auslösen kann.



Und damit ein potenzieller Speicherverlust auftritt, sind zwei weitere Bedingungen erforderlich:



  • so dass der Compiler Aufrufe auf ungünstige Weise mischt;
  • so dass die Funktion, die den zweiten Parameter auswertet, tatsächlich eine Ausnahme auslöst.


Es ist unwahrscheinlich, dass solch gefährlicher Code häufiger als einmal pro hundert Verwendungen von std :: shared_ptr vorkommt.

Um diese Gefahr zu kompensieren, wurde std :: shared_ptr von einer Krücke namens std :: make_shared unterstützt.



Um die Pille leicht zu versüßen, wurde der Beschreibung von std :: make_shared im Standard der folgende Satz hinzugefügt:

Anmerkungen: Implementierungen sollten nicht mehr als eine Speicherzuweisung durchführen.


Hinweis: Implementierungen sollten nicht mehr als eine Speicherzuordnung vornehmen.



Nein, dies ist keine Garantie.

Laut cppreference tun jedoch alle bekannten Implementierungen genau das.



Diese Lösung zielt darauf ab, die Leistung im Vergleich zum Erstellen von std :: shared_ptr zu verbessern, indem ein Konstruktor aufgerufen wird, für den mindestens zwei Zuordnungen erforderlich sind: eine zum Platzieren des Objekts und die zweite zum Steuern des Blocks.



std :: make_shared ist nutzlos



Ab c ++ 17 ist ein Speicherverlust in diesem kniffligen, seltenen Beispiel, für das std :: make_shared zur STL hinzugefügt wurde, nicht mehr möglich.



Studienlinks:





Es gibt mehrere andere Fälle, in denen std :: make_shared nutzlos ist:



std :: make_shared kann den privaten Konstruktor nicht aufrufen
#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 unterstützt keine benutzerdefinierten Löscher
… variadic template. , , deleter.

std::make_shared_with_custom_deleter…



Nun, lernen Sie diese Probleme zumindest in der Kompilierungszeit kennen ...



std :: make_shared ist schädlich



Wir vergehen in der Laufzeit.



Der überladene Operator new und der Operator delete werden von std :: make_shared ignoriert
#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:





Und jetzt - das Wichtigste, wofür der Artikel selbst gestartet wurde.



Überraschenderweise kann die Tatsache, wie std :: shared_ptr mit dem Speicher umgeht, erheblich davon abhängen, wie er erstellt wurde - mit std :: make_shared oder mit dem Konstruktor!



Warum passiert dies?



Weil die von std :: make_shared erzeugte „nützliche“ Einzelzuordnung einen inhärenten Nebeneffekt in Form einer zusätzlichen Verbindung zwischen dem Steuerblock und dem verwalteten Objekt hat. Sie können einfach nicht einzeln freigegeben werden. Ein Steuerblock muss so lange leben, wie mindestens ein schwaches Glied vorhanden ist.



Von std :: shared_ptr, das mit dem Konstruktor erstellt wurde, sollten Sie das folgende Verhalten erwarten:



  • Zuweisung eines verwalteten Objekts (vor dem Aufruf des Konstruktors, d. h. auf der Benutzerseite);
  • Zuordnung der Steuereinheit;
  • bei Zerstörung der letzten starken Verbindung der Aufruf des Destruktors des verwalteten Objekts und die Freigabe des von ihm belegten Speichers ; Wenn kein einziges schwaches Glied vorhanden ist, lassen Sie die Steuereinheit los.
  • bei Zerstörung des letzten schwachen Gliedes ohne starke Glieder - Freigabe des Steuerblocks.


Und wenn mit std :: make_shared erstellt:



  • Zuordnung des verwalteten Objekts und der Steuereinheit;
  • bei Zerstörung der letzten starken Verbindung ein Aufruf an den Destruktor des verwalteten Objekts, ohne den von ihm belegten Speicher freizugeben ; Wenn kein einziges schwaches Glied vorhanden ist, geben Sie den Steuerblock und den Speicher des verwalteten Objekts frei.
  • — .


Das Erstellen von std :: shared_ptr mit std :: make_shared führt zu einem Speicherplatzleck.



Es ist unmöglich, zur Laufzeit genau zu unterscheiden, wie die std :: shared_ptr-Instanz erstellt wurde.



Fahren wir mit dem Testen dieses Verhaltens fort.



Es gibt einen sehr einfachen Weg: Verwenden Sie std :: allocate_shared mit einem benutzerdefinierten Allokator, der alle Aufrufe an ihn meldet. Es ist jedoch falsch, die auf diese Weise erhaltenen Ergebnisse auf std :: make_shared zu erweitern.



Eine korrektere Methode besteht darin, den gesamten Speicherverbrauch zu steuern. Von einer plattformübergreifenden Rede ist jedoch keine Rede.



Der auf Ubuntu 20.04 Desktop x64 getestete Linux-Code ist angegeben. Wer daran interessiert ist, dies für andere Plattformen zu wiederholen - siehe hier (Meine Experimente mit MacOs haben gezeigt, dass die Option TASK_BASIC_INFO die Speicherfreigabe nicht verfolgt und TASK_VM_INFO_PURGEABLE ein besserer Kandidat ist.)



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




Konsolenausgabe bei Verwendung des Konstruktors std :: shared_ptr:

initial: 5884

vor dem Zurücksetzen: 71424

~ Groß

nach dem starken Zurücksetzen: 5884

nach dem schwachen Zurücksetzen: 5884



Konsolenausgabe bei Verwendung von std :: make_shared:

Initiale: 5888

vor dem Zurücksetzen: 71428

~ Groß

nach starkem Zurücksetzen: 71428

nach schwachem Zurücksetzen: 5888



Bonus



Ist es dennoch möglich, aufgrund der Codeausführung Speicher zu verlieren?



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


?

Was passiert, wenn die Zuweisung von Bar erfolgreich ist, aber nicht genügend Speicher für den Steuerblock vorhanden ist?



Was passiert, wenn der Konstruktor mit einem benutzerdefinierten Deleter aufgerufen wurde?



Der Abschnitt [util.smartptr.shared.const] des Standards stellt sicher, dass beim Auftreten einer Ausnahme im Konstruktor von std :: shared_ptr:



  • Bei einem Konstruktor ohne benutzerdefinierten Löscher wird der übergebene Zeiger mit delete oder delete [] gelöscht.
  • Bei einem Konstruktor mit einem benutzerdefinierten Löscher wird der übergebene Zeiger mit demselben Löscher gelöscht.


Keine Leckage durch den Standard garantiert.



Durch das schnelle Lesen der Implementierungen in drei Compilern (Apple Clang Version 11.0.3, GCC 9.3.0, MSVC 2019 16.6.2) kann ich bestätigen, dass alles so ist.



Ausgabe



In C ++ 11 und C ++ 14 könnte der Schaden durch die Verwendung von std :: make_shared durch seine einzige nützliche Funktion ausgeglichen werden.



Seit c ++ 17 spricht Arithmetik überhaupt nicht für std :: make_shared.



Ähnlich ist die Situation bei std :: allocate_shared.



Vieles davon gilt auch für std :: make_unique, schadet aber weniger.



All Articles