Verständnisproblem shared_ptr moven



  • Hi zusammen

    ich bin auf etwas gestoßen, was sich irgendwie meinem Verständnis entzieht.

    
    void testFunction( std::shared_ptr<int> ptr )
    {
        SomeContainer.push_back( std::move( ptr ) );
    }
    
    int main()
    {
        auto ptr = std::make_shared<int> ( 1 );
        testFunction( ptr );
    }
    

    Ich habe einen shared_ptr, den ich als Kopie in eine Funktion übergebe.
    in dieser Funktion wird die Kopie dieses shared_ptr in einen Container gemoved.

    In welchem Zustand ist jetzt der originale shared_ptr in der "main" ? ( vor allem der Reference-Counter )

    gruß Tobi



  • @It0101 sagte in Verständnisproblem shared_ptr moven:

    In welchem Zustand ist jetzt der originale shared_ptr in der "main" ? ( vor allem der Reference-Counter )

    Was erwartest du? Was sagt der Debugger? Wo ist das Problem?



  • Vor Verlassen von main ist der Ref-Count des vom shared_ptr verwalteten Counter-Objekts 2 (der shared_ptr selbst hat keinen Ref-Count!). Und die Variable ptr in main ist unverändert, du hast ja beim Aufruf von testFunction keinen Move erlaubt.



  • @hustbaer sagte in Verständnisproblem shared_ptr moven:

    Vor Verlassen von main ist der Ref-Count des vom shared_ptr verwalteten Counter-Objekts 2 (der shared_ptr selbst hat keinen Ref-Count!). Und die Variable ptr in main ist unverändert, du hast ja beim Aufruf von testFunction keinen Move erlaubt.

    D.h. es wird tatsächlich nur dieses Pärchen aus T* und Ref-Count gemoved ? D.h. nach dem Move in der Funktion testFunction ist "ptr" irgendwas zwischen leer und kaputt? Und das Pärchen ist weitergewandert in den Container, wodurch sich der Referencecounter selber nicht verändert hat.

    void testFunction( std::shared_ptr<int> ptr )
    {
        // Ref-Count = 2
        SomeContainer.push_back( std::move( ptr ) );
        // ptr "tot" / "leer" / "nullptr"
    }
    
    int main()
    {
        auto ptr = std::make_shared<int> ( 1 );
        // Ref-Count = 1
        testFunction( ptr );
        // Ref-Count = 2 ( ptr selbst und die Kopie im Container ) 
    }
    


  • @It0101

    D.h. es wird tatsächlich nur dieses Pärchen aus T* und Ref-Count gemoved ?

    Es wird das Pärchen T* und UnspecifiedTypeOfSharedCountObject* gemoved. Also ein Zeiger auf das "shared count" aka. "control block" Objekt, nicht das Objekt selbst. Wobei das ein Implementierungsdetail ist. shared_ptr muss nicht zwingend ein "shared count" Objekt verwenden (aber alle Implementierungen die ich kenne machen es so).

    D.h. nach dem Move in der Funktion testFunction ist "ptr" irgendwas zwischen leer und kaputt?

    Nach dem Move in testFunction ist deren ptr garantiert leer (vollständig gültiger Zustand). Weil alles andere unpraktisch wäre/für Überraschungen sorgen könnte. Siehe https://en.cppreference.com/w/cpp/memory/shared_ptr/shared_ptr

    1. Move-constructs a shared_ptr from r. After the construction, *this contains a copy of the previous state of r, r is empty and its stored pointer is null. (...)

    Und das Pärchen ist weitergewandert in den Container, wodurch sich der Referencecounter selber nicht verändert hat.

    Rüschtüg.



  • Ok. Vielen Dank für die Aufklärung @hustbaer . Jetzt hab ichs auch geschnallt 😃
    Mir hat irgendwie die Info gefehlt, dass dort der Ref-Count auch als Zeiger liegt.... was ja auch zwangsläufig so sein muss, damit der ganze Quatsch funktioniert 😃



  • @It0101 sagte in Verständnisproblem shared_ptr moven:

    Mir hat irgendwie die Info gefehlt, dass dort der Ref-Count auch als Zeiger liegt.... was ja auch zwangsläufig so sein muss, damit der ganze Quatsch funktioniert 😃

    Nicht zwangsläufig. Man kann den Zähler auch zusammen mit dem gehaltenen Objekt speichern, dann käme man über einen fixen Offset vom Objektpointer dran. Implementierungen machen das sogar als Optimierung, wenn man make_shared verwendet, oder boost::intrusive_ptr für alle damit verwalteten Objekte.

    Da ein shared_ptr allerdings beliebige Zeiger in Besitz nehmen kann und es auch weak_ptr gibt, muss er natürlich auch einen separaten Zeiger auf einen eigenständigen Kontrollblock mit Zähler unterstützen.



  • @Finnegan sagte in Verständnisproblem shared_ptr moven:

    Man kann den Zähler auch zusammen mit dem gehaltenen Objekt speichern, dann käme man über einen fixen Offset vom Objektpointer dran.

    Nope, nicht bei shared_ptr. shared_ptr erlaubt nämlich einen anderen shared_ptr zu erstellen der auf was ganz anders zeigt, aber das selbe Objekt am Leben hält wie der originale shared_ptr. Das kann man z.B. schön verwenden wenn man einen shared_ptr auf ein Member braucht.

    Das selbe Problem gibt es bei Basisklassen, denn nicht jede Basisklasse hat Offset 0.

    Daher werden bei Intrusive Reference Counting auch oft allgemeine Basisklassen ala IUnknown verwendet, die dann virtuelle Funktionen haben um den Reference Count zu verwalten.



  • @hustbaer sagte in Verständnisproblem shared_ptr moven:

    Das kann man z.B. schön verwenden wenn man einen shared_ptr auf ein Member braucht.

    Uhh, da ist was dran. Noch nicht wirklich gebraucht sowas, zeigt aber mal wieder schön wie viel letzendlich doch zu solchen oberflächlich relativ simplen Utility-Klassen dazu gehört.

    Das selbe Problem gibt es bei Basisklassen, denn nicht jede Basisklasse hat Offset 0.

    Ja, jetzt wo du das erwähnst muss ich auch an den vptr bei polymorphen Klassen denken, der es schwer macht zu bestimmen wo das Objekt intern im Speicher tatsächlich anfängt. Da könnte aber auch ein einziger Zeiger auf ein struct counted { int count; T object; } abhelfen (für den Fall dass man make_shared verwendet hat), der dann in shared_ptr::get einen Zeiger auf object zurückgibt. Für die Member Shared Pointer ist das natürlich keine Lösung.

    Aber wie ich schon sagte, das muss in einer Impelementierung nicht in jedem Fall (hier: für jedes T und für jede Art wie das Objekt erzeugt wurde, new, selbst verwalteter Speicher mit Custom Deleter oder make_shared) so sein, der shared_ptr muss sowas aber unterstützen, z.B. mit einem oft ungenutzten zweiten Pointer. Das wären aber alles interne Optimierungen.



  • @Finnegan sagte in Verständnisproblem shared_ptr moven:

    Da könnte aber auch ein einziger Zeiger auf ein struct counted { int count; T object; } abhelfen (für den Fall dass man make_shared verwendet hat), der dann in shared_ptr::get einen Zeiger auf object zurückgibt. Für die Member Shared Pointer ist das natürlich keine Lösung.

    Das geht auch nur wenn du einen Zeiger auf den Most Derived Type hast oder zufällig der Offset Null ist. D.h. damit das mit Basisklassen in beliebigen Klassenhierarchien funktioniert bräuchtest du zusätzlich zum Zeiger auf das Struct noch einen Offset.

    Theoretisch könnte man dann natürlich sagen man unterstützt z.B. max. 16 oder 32 Bit Offsets, damit das Objekt kleiner wird. Das würde vermutlich über 99.9% aller Fälle abdecken, der Teil wäre also nicht das Problem. Nur dass das Objekt dadurch nicht kleiner wird. Das Alignment eines Zeigers ist typischerweise gleich der Grösse, d.h. sogar ein Struct mit einem Zeiger + bloss einem char zusätzlich ist schon 2x so gross wie nur der Zeiger.

    Beim Speicherzugriff spart man sich auch nix. Was Speicherbandbreite und Cache-Auslastung angeht kostet es genau gleich viel einen char zu laden wie einen Zeiger von einer Adresse mit passendem Alignment zu laden.

    Und dann kann man gleich zwei Pointer speichern. Weil der Code dadurch viel einfacher wird. Es gibt dann direkt in shared_ptrkeine Spezialfälle diesbezüglich mehr: du hast immer einen T* auf das Objekt und einen C* auf den Control-Block. Die einzige Fallunterscheidung die dann bleibt ist beim Zerstören des T zu prüfen ob der Speicher für das T auch freigegeben werden soll (nein wenn make_shared verwendet wurde und der Speicher für Objekt und Control-Block in eine Allocation zusammengefasst wurden, ansonsten ja).


    Der einzige mir bekannte Ausweg ist wirklich in die Klassenhierarchie einzugreifen und die Information wie man auf den Counter zugreifen kann mit im Objekt abzuspeicher - und zwar so dass man von jeder Basisklasse aus zugreifen kann. In C++ verwendet man dazu gerne virtuelle Funktionen, da ein Objekt mit virtuellen Funktionen sowieso schon einen VTable Pointer pro Basisklassen-Subobjekt (mit virtuellen Funktionen) braucht (+1 für den most derived type natürlich). Ein paar mehr virtuelle Funktionen ala AddRef und Release machen dann keinen Unterschied mehr im Speicherverbrauch pro Objekt.

    Weitere Nebeneffekte von Intrusive Reference Counting (können je nach Situation Vorteile aber auch Nachteile sein):

    • Du kannst auch ohne ein xxx_ptr Objekt zu erzeugen den Reference-Count ändern.
    • Du kannst aus jedem Raw-Pointer ohne weitere Hilfe einen ownership-sharing xxx_ptr Smart-Pointer machen.

    Ich habe daher in bestimmten Situationen, selten, aber über die Jahre doch immer wieder mal, boost::intrusive_ptr verwendet. (Um eigene Objekte zu verwalten, also nicht nur für Sachen wie COM Objekte wo boost::intrusive_ptr ziemlich klar "die" Lösung ist.)



  • Dieser Beitrag wurde gelöscht!


  • @hustbaer sagte in Verständnisproblem shared_ptr moven:

    @Finnegan sagte in Verständnisproblem shared_ptr moven:

    Da könnte aber auch ein einziger Zeiger auf ein struct counted { int count; T object; } abhelfen (für den Fall dass man make_shared verwendet hat), der dann in shared_ptr::get einen Zeiger auf object zurückgibt. Für die Member Shared Pointer ist das natürlich keine Lösung.

    Das geht auch nur wenn du einen Zeiger auf den Most Derived Type hast oder zufällig der Offset Null ist. D.h. damit das mit Basisklassen in beliebigen Klassenhierarchien funktioniert bräuchtest du zusätzlich zum Zeiger auf das Struct noch einen Offset.

    Eigentlich will ich hier die ganze Zeit lediglich auf die make_shared-Optimierung hinaus. Ich hab nicht beauptet, dass man jeden Zeiger, den man im Konstruktor übergeben bekommt so managen kann. Bei make_shared muss das konstruierte Objekt ja genau dem Typen des shared_ptr<T> entsprechen.... oder habe ich da was übersehen und man kann mit make_shared einen shared_ptr<Base> auf ein Derived-Objekt erstellen?

    Ich sehe in diesem Fall mit dem struct counted kein Problem. Bei einem shared_ptr::reset(pointer_to_derived) wechselt die Implementierung dann eben wieder auf einen spearaten Kontrollblock.

    [...]
    Beim Speicherzugriff spart man sich auch nix. Was Speicherbandbreite und Cache-Auslastung angeht kostet es genau gleich viel einen char zu laden wie einen Zeiger von einer Adresse mit passendem Alignment zu laden.

    Mir geht es hier auch vornehmlich darum einen separaten Speicherbereich für den Kontrollblock zu vermeiden und in Fällen wie z.B. p = shared_ptr<T>(other); p->data = 42; noch eine weitere Cache Line für den Kontrollblock involviert sein muss. Ich denke aber dieser Aspekt ist wohl unstrittig (?).

    Und dann kann man gleich zwei Pointer speichern. Weil der Code dadurch viel einfacher wird. Es gibt dann direkt in shared_ptrkeine Spezialfälle diesbezüglich mehr: du hast immer einen T* auf das Objekt und einen C* auf den Control-Block. Die einzige Fallunterscheidung die dann bleibt ist beim Zerstören des T zu prüfen ob der Speicher für das T auch freigegeben werden soll (nein wenn make_shared verwendet wurde und der Speicher für Objekt und Control-Block in eine Allocation zusammengefasst wurden, ansonsten ja).

    Ja, das sehe ich ein, dass der Code mit zwei Pointern simpler wird, auch wenn der zweite im Speziallfall eben "direkt neben" den anderen zeigt. Da gibts von mir auch keinen Widerspruch, dass das wahrscheinlich bei den meisten shared_ptr so implemetiert ist. Hab mich ursprünglich an dem Der Quatsch funktioniert \Rightarrow shared_ptr muss zwei Pointer haben aufgehängt. Das obige Programm bekommt man nämlich auch mit nur einem Pointer ans "funkioneren" 😉

    Der einzige mir bekannte Ausweg ist wirklich in die Klassenhierarchie einzugreifen [...]
    Ich habe daher in bestimmten Situationen, selten, aber über die Jahre doch immer wieder mal, boost::intrusive_ptr verwendet. (Um eigene Objekte zu verwalten, also nicht nur für Sachen wie COM Objekte wo boost::intrusive_ptr ziemlich klar "die" Lösung ist.)

    Ja, wenn ich wirklich mal die Ehre habe, eine richtige (polymorphe) OOP-Klassenhierarchie von Grund auf zu entwerfen, dann verwende ich auch sowas wie boost::intrusive_ptr - ansonsten eben make_shared wenn möglich. Schade, dass es da keine wirklich gute "fire and forget"-Lösung gibt, die man ohne nachzudenken immer verwenden kann. Schliesslich wollen wir ja auch kein std::pair mit eingebautem Referenzzähler haben 😉

    Bezüglich COM: Das ist schon was länger her, aber gab es da nicht Microsoft::WRL::ComPtr? Den hab ich zumindest verwendet als ich as letzte mal mit COM zu tun hatte.



  • @Finnegan sagte in Verständnisproblem shared_ptr moven:

    Eigentlich will ich hier die ganze Zeit lediglich auf die make_shared-Optimierung hinaus. Ich hab nicht beauptet, dass man jeden Zeiger, den man im Konstruktor übergeben bekommt so managen kann. Bei make_shared muss das konstruierte Objekt ja genau dem Typen des shared_ptr<T> entsprechen.... oder habe ich da was übersehen und man kann mit make_shared einen shared_ptr<Base> auf ein Derived-Objekt erstellen?

    Das was von make_shared<MostDerivedType>() zurückkommt ist immer ein MostDerivedType, ja. Aber du kannst den Zeiger dann sofort in einen Basisklassen-Zeiger konvertieren:

    std::shared_ptr<Base> p = std::make_shared<Derived>(); // OK
    

    Davon abgesehen ist der Typ von std::shared_ptr<Derived> immer identisch, egal wie du ihn initialisiert hast und ob er auf Derived oder auf MoreDerived zeigt. D.h. std::shared_ptr<Derived> kann niemals nicht mit nur einem Zeiger (Membervariable) auskommen. Man könnte die zweite Membervariable in manchen Fällen NULL/0 lassen, ja. Aber nur wenn man unnötige Fallunterscheidungen einbaut. Und wieso sollte man das, wenn man die Membervariable sowieso mitschleppen muss.

    D.h.: So wie std::shared_ptr spezifiziert ist muss er immer mindestens zwei Membervariablen haben.
    (EDIT: OK, das stimmt natürlich so nicht. Trivial kann man natürlich ein pair<T*, C*> nehmen, das wäre dann nur ein Member. Der Knackpunkt ist dass man mit bloss einem T*/C*/X* als Member nicht auskommt.)


Anmelden zum Antworten