std::mutex by value VS. by reference



  • Hallo liebe Community,

    Ich habe eine Frage zu std::mutex. Wenn ich einen mutex by value übergebe, ist das dann in der Kopie der gleiche mutex? Oder ist es, wie sehr wahrscheinlich, nur eine Kopie?

    Ich möchte eine x-beliebige Klasse haben, die selbst einen mutex besitzt und sie soll thread safe sein unabhängig davon, ob ich eine Instanz by value oder by reference übergebe. Ich bin leider an C++11 gebunden.

    Hier ein kleines Beispiel:

    #include <thread>
    #include <mutex>
    #include <iostream>
    
    class Integer
    {
      private:
    
        std::mutex mtx;
        int value;
    
      public:
    
        explicit Integer(int value) {
          this->value = value;  
        }
    
        ~Integer() = default;
    
        void inc()
        {
          std::lock_guard<std::mutex> lock(this->mtx);
          this->value += 1;
        }
    
        int get()
        {
          std::lock_guard<std::mutex> lock(this->mtx);
          return this->value;
        }
    };
    
    void threadFunction(Integer& integer)
    {
      std::thread t1(&Integer::inc, &integer);
      std::thread t2(&Integer::inc, &integer);
    
      t1.join();
      t2.join();
    }
    
    int main(int argc, char** argv)
    {
      Integer x(3);
      threadFunction(x);
      x.inc();
    
      std::cout << x.get() << std::endl;
      exit(0);
    }
    

    Wäre das so korrekt im Sinne, dass die Klasse threadsafe ist und egal wohin ich die Instanz 'x' schiebe, ich sicher sein kann, dass sie überall funktioniert und keinen crash verursacht?
    Das Ausführen des Programmes funktioniert zwar und das Ergebnis ist '6'. Aber ich bin mir doch irgendwie unsicher, dass das richtig ist...

    Danke schon mal! Mit diesem Problem beschäftige ich mich mittlerweile sehr lange.

    Gruß,
    Patrick



  • Ich seh grade kein Problem mit deiner Klasse. Allerdings übergibst du auch nirgends die Mutex "by value", wie du in deinem Text beschrieben hast.

    Bei deinem Anwendungsfall und C++11 würde ich mal über std::atomic nachdenken.

    Wenn ich das richtig verstanden habe, bis du mit

    std::atomic<int> value;
    

    auf der sicheren Seite und brauchst den Mutex nicht.



  • Hey,

    Vielen Dank für deine Antwort!

    Allerdings übergibst du auch nirgends die Mutex "by value"

    Okay dann hier die entscheidene Frage. Würde ich die Instanz immer als Referenz übergeben oder in einer Funktion auch als Referenz zurückgeben, habe ich keine Probleme mit dem mutex?

    Bei deinem Anwendungsfall und C++11 würde ich mal über std::atomic nachdenken.

    Ja bei primitiven Datentypen nutze ich auch std::atomic. Allerdings muss ich auf std::mutex zurückgreifen, wenn ich nicht-primitive Datentypen schützen möchte. Ab späteren C++ Versionen gibt es ja std::atomic_ptr und den ganzen Spaß. Das Beispiel mit der Integer Klasse war nur zum Veranschaulichen.



  • @padmad sagte in std::mutex by value VS. by reference:

    Würde ich die Instanz immer als Referenz übergeben oder in einer Funktion auch als Referenz zurückgeben,

    Dein Integer lässt sich nicht kopieren, weil ein Mutex nicht kopiert werden kann.

    habe ich keine Probleme mit dem mutex?

    Der Mutex wird wohl machen was du denkst. Ob das keine Probleme heisst, ist eine andere Frage. Mit Sperren auf dieser Ebene handekt man sich leicht Deadlocks ein. Und wenn viele Threads häufig das selbe Objekt benutzen wirds eher langsamer als mit nur einem Thread.



  • @manni66 sagte in std::mutex by value VS. by reference:

    Mit Sperren auf dieser Ebene handekt man sich leicht Deadlocks ein.

    ?



  • @hustbaer sagte in std::mutex by value VS. by reference:

    @manni66 sagte in std::mutex by value VS. by reference:

    Mit Sperren auf dieser Ebene handekt man sich leicht Deadlocks ein.

    ?

    Z.B. fällt auf, dass man noch gerne

    int add( Integer& i)
        {
          std::lock_guard<std::mutex> lock(this->mtx);
          return this->value + i.get();
        }
    

    hätte. a.add(b) und b.add(a) in zwei Threads wären Kandidaten.



  • @manni66

    Dein Integer lässt sich nicht kopieren, weil ein Mutex nicht kopiert werden kann.

    Aber in meinem Beispiel ist es ja keine Kopie sondern eine Referenz auf eine existierende Instanz. Ist das Beispiel also nicht thread safe?



  • @padmad sagte in std::mutex by value VS. by reference:

    @manni66

    Dein Integer lässt sich nicht kopieren, weil ein Mutex nicht kopiert werden kann.

    Aber in meinem Beispiel ist es ja keine Kopie sondern eine Referenz auf eine existierende Instanz. Ist das Beispiel also nicht thread safe?

    @manni66 sagte in std::mutex by value VS. by reference:

    Der Mutex wird wohl machen was du denkst.



  • @manni66 sagte in std::mutex by value VS. by reference:

    @hustbaer sagte in std::mutex by value VS. by reference:

    @manni66 sagte in std::mutex by value VS. by reference:

    Mit Sperren auf dieser Ebene handekt man sich leicht Deadlocks ein.

    ?

    Z.B. fällt auf, dass man noch gerne

    int add( Integer& i)
        {
          std::lock_guard<std::mutex> lock(this->mtx);
          return this->value + i.get();
        }
    

    hätte. a.add(b) und b.add(a) in zwei Threads wären Kandidaten.

    Ja, ist mir schon klar. Weiss aber nicht was das mit "Sperren auf dieser Ebene" zu tun haben soll. "Auf dieser Ebene" ist hier ganz unten, und ganz unten ist Locken grundsätzlich am unproblematischsten.



  • @hustbaer sagte in std::mutex by value VS. by reference:

    Auf dieser Ebene" ist hier ganz unten,

    Ja

    und ganz unten ist Locken grundsätzlich am unproblematischsten.

    Sehe ich nicht so.



  • @manni66 sagte in std::mutex by value VS. by reference:

    und ganz unten ist Locken grundsätzlich am unproblematischsten.

    Sehe ich nicht so.

    Macht nix, ist trotzdem so 😉



  • @hustbaer sagte in std::mutex by value VS. by reference:

    @manni66 sagte in std::mutex by value VS. by reference:

    und ganz unten ist Locken grundsätzlich am unproblematischsten.

    Sehe ich nicht so.

    Macht nix, ist trotzdem so 😉

    Wahrscheinlich habt ihr beide recht:

    Zunächst werden auf dieser niedrigen Ebene meist sehr simple Operationen durchgeführt, die man noch leicht überblicken kann. Im Gegensatz zu einem tiefen, weit verzweigten Call-Tree ist das leichter mental zu erfassen und deadlockfrei zu machen.

    Andererseits ist z.B. eine Baum-Datenstruktur wie etwa std::map<> simpler auf der Ebene von map::insert() zu locken anstatt auf Ebene der Knoten, wo ich mir mit rekursivem Knotengewurschtel und Rotationen leicht ein Deadlock einhandeln kann.

    Letztlich kommt wohl es eher darauf an, den Überblick zu behalten, und intuitiv eindeutige Stellen im Call-Tree zu haben, wo das Locking stattfindet. In manni66's add()-Beispiel wird nochmal eine Ebene tiefer gelockt, hier wäre es wohl für den Überblick besser die gesamte add()-Operation vollständig zu implementieren, ohne auf get() zurückzugreifen. So hat man die ganze atomare (nicht im Sinne von Atomics) Operation auf einen Blick in einem Funktionskörper vor sich und übersieht das versteckte Lock in get() nicht so leicht.



  • @finnegan sagte in std::mutex by value VS. by reference:

    Wahrscheinlich habt ihr beide recht:

    Nein. @hustbaer hat eindeutig Recht. So lokal wie möglich locken damit andere threads nicht unnötig warten müssen.



  • @padmad sagte in std::mutex by value VS. by reference:

    Wäre das so korrekt im Sinne, dass die Klasse threadsafe ist und egal wohin ich die Instanz 'x' schiebe, ich sicher sein kann, dass sie überall funktioniert und keinen crash verursacht?

    Wie manni66 schon geschreiben hat: std::mutex kann man nicht kopieren, da die Klasse keinen Kopierkonstruktor hat. Das heisst auch dass man einen std::mutex nicht by value übergeben kann, außer vielleicht in Fällen, in denen es eine garantierte copy elision gibt.

    Wahrscheinlich bezieht sich deine Frage daher auf deine Integer-Klasse. Mit dem default-Kopierkonstruktor ist diese von der Kopierbarkeit des std::mutex abhängig, daher gilt für Integer erstmal das gleiche. Was du hier allerdings machen kannst, ist einen eigenen Kopierkonstruktor und einen Zuweisungsoperator zu implementieren, in dem der std::mutex eben nicht kopiert wird, sondern ein neuer Mutex für den neuen Integer (die Kopier) initialisiert wird.

    Das könnte z.B. so aussehen:

    class Integer
    {
      private:
        mutable std::mutex mtx;
        int value;
    
      public:
          ...
    
        Integer(const Integer& other)
        : mtx{}, value{ other.get() }
        {
        }
        ...
    
        int get() const;
    };
    

    Beachte, dass ich hier get() zu einer const-Memberfunktion gemacht habe, damit diese auch aufgerufen werden kann, wenn man eine const& des Integer vorliegen hat, wie im Kopierkonstruktor. Damit das funktioniert habe ich den std::mutex auch noch mutable gemacht. Für reine Synchronisationsobjekte, die für einen Anwender von Integer nicht sichtbar sind, geht das mutable völlig in Ordnung und ist meines wissens auch die einzige Möglichkeit eine solche intern synchronisierte Klasse const-korrekt hinzubekommen. Den Zuweisungsoperator implementiert man analog.

    In dieser Form sollte sich ein Integer dann auch thread-sicher by value übergeben lassen. Beachte im weiteren aber auch solche Stolperfallen, wie das von manni66 erwähnte add() und überlege, ob es überhaupt Sinn macht einen solchen thread-sicheren Integer einzusetzen. Wenn du das Ding später überall anstelle eines builtin-int verwendest, dürfte das sehr ineffizient sein und das ganze Programm gewaltig ausbremsen. So ein Integer wie dieser macht nur in sehr speziellen Situationen Sinn, wirklich brauchen tut man sowas nur selten. Die ganz normalen int's sollten daher immer erste Wahl sein. Eine Variable, auf die immer nur ein Thread zugreift, braucht nämlich nicht mit einem Mutex synchronisiert zu werden, selbst wenn 100 Threads irgendwo im Speicher herumballern.



  • @swordfish sagte in std::mutex by value VS. by reference:

    @finnegan sagte in std::mutex by value VS. by reference:

    Wahrscheinlich habt ihr beide recht:

    Nein. @hustbaer hat eindeutig Recht. So lokal wie möglich locken damit andere threads nicht unnötig warten müssen.

    Das ist zweischneidig, man kann es auch übertreiben, z.B. wenn man überall mit einem solchen synchronisierten Integer arbeiten würde, anstatt etwas gröber zu locken. Irgendwann fällt einem da nämlich der Mutex-Overhead auf die Füsse und die CPU ist nur noch mit Synchronisation beschäftigt anstatt mit meinem Problem.

    Es stimmt schon, dass man einen Mutex nicht zu lange halten sollte, aber ein paar Anweisungen die in nur wenige Instruktionen auf Registern zerfallen dürfen es schon gerne sein.

    Z.B. die Sache mit dem std::map-Binärbaum. Ich würde mir ohne den Anwendungsfall genau zu kennen da nicht zutrauen zu sagen, ob ein insert()-Lock oder ein Knoten-Pointer-Lock besser wäre. Allerdings würde es nicht wundern, wenn man mit dem insert()-Lock in den allermeisten Fällen besser fährt (Cache-Effizienz von Binärbäumen und ähnliches mal aussen vor gelassen). Bei riesigen Datenbank-Bäumen mit vielen konkurrierenden Zugriffen mag das wieder anders aussehen (dort sind glaube ich Locks mit Page-Granularität für die B-Baum-Knoten durchaus verbreitet).



  • @finnegan sagte in std::mutex by value VS. by reference:

    aber ein paar Anweisungen die in nur wenige Instruktionen auf Registern zerfallen dürfen es schon gerne sein.

    Was wären solche gekapselten Operationen auf einen int denn?



  • @swordfish sagte in std::mutex by value VS. by reference:

    @finnegan sagte in std::mutex by value VS. by reference:

    aber ein paar Anweisungen die in nur wenige Instruktionen auf Registern zerfallen dürfen es schon gerne sein.

    Was wären solche gekapselten Operationen auf einen int denn?

    Das ist vielleicht nicht die richtige Frage. Bei mir wäre dieser 'int' vielleicht eine normale, nicht individuell synchronisierte Member-Variable, die ein Input für eine Berechnung ist, in die noch Werte anderer Member oder externe Werte mit einfliessen. Das Locking findet dann auf Ebene der Funktion statt, die das gesamte Objekt synchronisiert - das Lock wird so lange gehalten wie eben auf irgendwelche Member des Objekts zugegriffen wird (also vom lesen des int und anderen Membern, bis das Berechnungsergebnis vielleicht wieder in das synchronisierte Objekt zurückgeschrieben wurde).

    Das hängt aber auch sehr von dem Anwendungsfall ab. Einen so feingranular synchronisierten int würde ich vielleicht als globalen Counter einsetzen, meistens werde ich mit einem Mutex allerdings eher etwas grössere Speicherbereiche absichern.



  • @finnegan sagte in std::mutex by value VS. by reference:

    Das ist zweischneidig, man kann es auch übertreiben, z.B. wenn man überall mit einem solchen synchronisierten Integer arbeiten würde, anstatt etwas gröber zu locken. Irgendwann fällt einem da nämlich der Mutex-Overhead auf die Füsse und die CPU ist nur noch mit Synchronisation beschäftigt anstatt mit meinem Problem.

    Ja. Ich hatte auch gar nicht Performance gemeint gehabt, mir ging's darum dass es in Hinblick auf Deadlocks unproblematisch ist. Wenn ich nen privaten Lock habe, und das immer der letzte Lock ist der angezogen wird, dann kann ich damit keine Deadlocks basteln.

    Wenns natürlich nicht der letzte Lock ist, weil man noch eine andere Instanz des selben privaten Locks anzieht kann es natürlich ein Problem machen. Das ist aber kein Problem der "Ebene". Zumindest nicht der "Ebene" des gezeigten Codes, da dieser sowas nicht macht, und daher eben "ganz unten" ist. Und damit unproblematisch.

    Und um nochmal auf die Performance zurückzukommen: ja, natürlich ist das doof wenn man so fein lockt. Für nen Integer locken ist sowieso fragwürdig. Manchmal braucht man genau das und nicht mehr, aber man sollte es auf keinen Fall als Standardwerkzeug ansehen mit/auf dem man seine Software aufbaut.



  • Vielen Dank für den Einblick. Das Beispiel war wirklich nur als Beispiel gedacht, um ein besseres Verständnis von Thread Safety zu bekommen. Mir ist std::atomic natürlich bekannt.

    Aber ich denke, ich verstehe das so langsam.