Wie soll ich mit Mutexen in beweglichen Typen in C ++ umgehen?


85

Ist von Natur aus std::mutexweder beweglich noch kopierkonstruierbar. Dies bedeutet, dass eine Klasse A, die einen Mutex enthält, keinen Standard-Verschiebungskonstruktor erhält.

Wie würde ich diesen Typ Afadensicher beweglich machen ?


4
Die Frage hat eine Besonderheit: Soll die Verschiebeoperation selbst auch threadsicher sein oder reicht es aus, wenn andere Zugriffe auf das Objekt threadsicher sind?
Jonas Schäfer

2
@paulm Das hängt wirklich vom Design ab. Ich habe oft gesehen, dass eine Klasse eine Mutex-Mitgliedsvariable hat, dann nur die std::lock_guardMethode.
Cory Kramer

2
@ Jonas Wielicki: Zuerst dachte ich, das Verschieben sollte auch threadsicher sein. Allerdings, dass ich nicht noch einmal darüber nachdenke, macht dies nicht viel Sinn, da das Verschieben eines Objekts normalerweise den Zustand des alten Objekts ungültig macht. Andere Threads dürfen also nicht auf das alte Objekt zugreifen können, wenn es verschoben werden soll. Andernfalls greifen sie möglicherweise bald auf ein ungültiges Objekt zu. Habe ich recht?
Jack Sabbath

2
Bitte folgen Sie diesem Link kann voll dafür verwenden justsoftwaresolutions.co.uk/threading/…
Ravi Chauhan

1
@Dieter Lücking: Ja, das ist die Idee. Mutex M schützt Klasse B. Wo speichere ich jedoch beide, um ein threadsicheres, zugängliches Objekt zu haben? Sowohl M als auch B könnten in Klasse A gehen. In diesem Fall hätte Klasse A einen Mutex im Klassenbereich.
Jack Sabbath

Antworten:


103

Beginnen wir mit ein bisschen Code:

class A
{
    using MutexType = std::mutex;
    using ReadLock = std::unique_lock<MutexType>;
    using WriteLock = std::unique_lock<MutexType>;

    mutable MutexType mut_;

    std::string field1_;
    std::string field2_;

public:
    ...

Ich habe dort einige eher suggestive Typ-Aliase eingefügt, die wir in C ++ 11 nicht wirklich nutzen, aber in C ++ 14 viel nützlicher werden. Sei geduldig, wir werden dort ankommen.

Ihre Frage lautet:

Wie schreibe ich den Verschiebungskonstruktor und den Verschiebungszuweisungsoperator für diese Klasse?

Wir beginnen mit dem Verschiebungskonstruktor.

Konstruktor verschieben

Beachten Sie, dass das Mitglied mutexerstellt wurde mutable. Genau genommen ist dies für die Umzugsmitglieder nicht erforderlich, aber ich gehe davon aus, dass Sie auch Kopiermitglieder möchten. Ist dies nicht der Fall, muss der Mutex nicht erstellt werden mutable.

Beim Konstruieren Amüssen Sie nicht sperren this->mut_. Sie müssen jedoch mut_das Objekt sperren, aus dem Sie erstellen (verschieben oder kopieren). Dies kann folgendermaßen geschehen:

    A(A&& a)
    {
        WriteLock rhs_lk(a.mut_);
        field1_ = std::move(a.field1_);
        field2_ = std::move(a.field2_);
    }

Beachten Sie, dass wir die Mitglieder von thiszuerst standardmäßig erstellen und ihnen dann erst Werte zuweisen mussten, nachdem sie a.mut_gesperrt wurden.

Zuordnung verschieben

Der Verschiebungszuweisungsoperator ist wesentlich komplizierter, da Sie nicht wissen, ob ein anderer Thread auf die lhs oder rhs des Zuweisungsausdrucks zugreift. Im Allgemeinen müssen Sie sich vor dem folgenden Szenario schützen:

// Thread 1
x = std::move(y);

// Thread 2
y = std::move(x);

Hier ist der Verschiebungszuweisungsoperator, der das obige Szenario korrekt schützt:

    A& operator=(A&& a)
    {
        if (this != &a)
        {
            WriteLock lhs_lk(mut_, std::defer_lock);
            WriteLock rhs_lk(a.mut_, std::defer_lock);
            std::lock(lhs_lk, rhs_lk);
            field1_ = std::move(a.field1_);
            field2_ = std::move(a.field2_);
        }
        return *this;
    }

Beachten Sie, dass Sie std::lock(m1, m2)die beiden Mutexe sperren müssen , anstatt sie nur nacheinander zu sperren. Wenn Sie sie nacheinander sperren, können Sie einen Deadlock erhalten, wenn zwei Threads zwei Objekte in entgegengesetzter Reihenfolge wie oben gezeigt zuweisen. Es std::lockgeht darum, diesen Deadlock zu vermeiden.

Konstruktor kopieren

Sie haben nicht nach den Kopienmitgliedern gefragt, aber wir könnten genauso gut jetzt darüber sprechen (wenn nicht Sie, wird jemand sie brauchen).

    A(const A& a)
    {
        ReadLock  rhs_lk(a.mut_);
        field1_ = a.field1_;
        field2_ = a.field2_;
    }

Der Kopierkonstruktor ReadLockähnelt stark dem Verschiebungskonstruktor, außer dass der Alias ​​anstelle des verwendet wird WriteLock. Derzeit sind diese beiden Alias std::unique_lock<std::mutex>und so macht es keinen wirklichen Unterschied.

In C ++ 14 haben Sie jedoch die Möglichkeit, Folgendes zu sagen:

    using MutexType = std::shared_timed_mutex;
    using ReadLock  = std::shared_lock<MutexType>;
    using WriteLock = std::unique_lock<MutexType>;

Dies kann eine Optimierung sein, aber nicht definitiv. Sie müssen messen, um festzustellen, ob dies der Fall ist. Mit dieser Änderung kann man jedoch Konstrukte aus denselben rhs in mehreren Threads gleichzeitig kopieren . Die C ++ 11-Lösung zwingt Sie, solche Threads sequentiell zu machen, obwohl die rhs nicht geändert werden.

Zuordnung kopieren

Der Vollständigkeit halber hier der Kopierzuweisungsoperator, der nach dem Lesen von allem anderen ziemlich selbsterklärend sein sollte:

    A& operator=(const A& a)
    {
        if (this != &a)
        {
            WriteLock lhs_lk(mut_, std::defer_lock);
            ReadLock  rhs_lk(a.mut_, std::defer_lock);
            std::lock(lhs_lk, rhs_lk);
            field1_ = a.field1_;
            field2_ = a.field2_;
        }
        return *this;
    }

Und soweiter und sofort.

Alle anderen Mitglieder oder freien Funktionen, die auf Aden Status zugreifen , müssen ebenfalls geschützt werden, wenn Sie erwarten, dass mehrere Threads sie gleichzeitig aufrufen können. Zum Beispiel hier swap:

    friend void swap(A& x, A& y)
    {
        if (&x != &y)
        {
            WriteLock lhs_lk(x.mut_, std::defer_lock);
            WriteLock rhs_lk(y.mut_, std::defer_lock);
            std::lock(lhs_lk, rhs_lk);
            using std::swap;
            swap(x.field1_, y.field1_);
            swap(x.field2_, y.field2_);
        }
    }

Beachten Sie, dass, wenn Sie nur von std::swapder std::swapAusführung des Auftrags abhängig sind , die Sperrung in der falschen Granularität erfolgt und zwischen den drei Bewegungen, die intern ausgeführt werden , gesperrt und entsperrt wird .

Wenn Sie darüber nachdenken, erhalten swapSie möglicherweise einen Einblick in die API, die Sie möglicherweise benötigen, um eine "thread-sichere" API bereitzustellen A, die sich aufgrund des Problems der "Sperrgranularität" im Allgemeinen von einer "nicht thread-sicheren" API unterscheidet.

Beachten Sie auch die Notwendigkeit, sich vor "Selbsttausch" zu schützen. "Selbsttausch" sollte ein No-Op sein. Ohne den Selbsttest würde man denselben Mutex rekursiv sperren. Dies könnte auch ohne Selbsttest mit std::recursive_mutexfor gelöst werden MutexType.

Aktualisieren

In den Kommentaren unten ist Yakk ziemlich unglücklich darüber, dass er standardmäßig Dinge in den Kopier- und Verschiebungskonstruktoren konstruieren muss (und er hat einen Punkt). Wenn Sie sich zu diesem Thema so stark fühlen, dass Sie bereit sind, sich daran zu erinnern, können Sie es folgendermaßen vermeiden:

  • Fügen Sie alle Sperrtypen hinzu, die Sie als Datenelemente benötigen. Diese Mitglieder müssen vor den zu schützenden Daten stehen:

    mutable MutexType mut_;
    ReadLock  read_lock_;
    WriteLock write_lock_;
    // ... other data members ...
    
  • Und dann tun Sie dies in den Konstruktoren (z. B. dem Kopierkonstruktor):

    A(const A& a)
        : read_lock_(a.mut_)
        , field1_(a.field1_)
        , field2_(a.field2_)
    {
        read_lock_.unlock();
    }
    

Hoppla, Yakk hat seinen Kommentar gelöscht, bevor ich die Gelegenheit hatte, dieses Update abzuschließen. Aber er verdient Anerkennung dafür, dass er dieses Problem vorangetrieben und eine Lösung für diese Antwort gefunden hat.

Update 2

Und dyp kam auf diesen guten Vorschlag:

    A(const A& a)
        : A(a, ReadLock(a.mut_))
    {}
private:
    A(const A& a, ReadLock rhs_lk)
        : field1_(a.field1_)
        , field2_(a.field2_)
    {}

2
Ihr Kopierkonstruktor weist Felder zu, kopiert sie jedoch nicht. Das bedeutet, dass sie standardmäßig konstruierbar sein müssen, was eine unglückliche Einschränkung darstellt.
Yakk - Adam Nevraumont

@Yakk: Ja, das Einordnen mutexesin Klassentypen ist nicht der " einzig wahre Weg". Es ist ein Werkzeug in der Toolbox, und wenn Sie es verwenden möchten, gehen Sie wie folgt vor.
Howard Hinnant

@ Yakk: Suche meine Antwort nach der Zeichenfolge "C ++ 14".
Howard Hinnant

ah, sorry, ich habe das C ++ 14 Bit verpasst.
Yakk - Adam Nevraumont

2
tolle Erklärung @HowardHinnant! In C ++ 17 können Sie auch die Sperre std :: scoped_lock (x.mut_, y_mut_) verwenden. So können Sie bei der Umsetzung verlassen mehrere mutexes in der richtigen Reihenfolge zu sperren
Fen

7

Angesichts der Tatsache, dass es keinen guten, sauberen und einfachen Weg gibt, dies zu beantworten - Antons Lösung ist meiner Meinung nach richtig, aber definitiv umstritten. Wenn keine bessere Antwort gefunden wird, würde ich empfehlen, eine solche Klasse auf den Haufen zu legen und sich darum zu kümmern über a std::unique_ptr:

auto a = std::make_unique<A>();

Es ist jetzt ein voll beweglicher Typ und jeder, der den internen Mutex während einer Bewegung sperrt, ist immer noch sicher, auch wenn es fraglich ist, ob dies eine gute Sache ist

Wenn Sie eine Kopiersemantik benötigen, verwenden Sie einfach

auto a2 = std::make_shared<A>();

5

Dies ist eine verkehrte Antwort. Anstatt "Diese Objekte müssen synchronisiert werden" als Basis des Typs einzubetten, fügen Sie sie stattdessen unter einen beliebigen Typ ein.

Sie gehen sehr unterschiedlich mit einem synchronisierten Objekt um. Ein großes Problem ist, dass Sie sich um Deadlocks sorgen müssen (Sperren mehrerer Objekte). Grundsätzlich sollte es auch niemals Ihre "Standardversion eines Objekts" sein: Synchronisierte Objekte sind für Objekte gedacht, die in Konflikt geraten, und Ihr Ziel sollte es sein, Konflikte zwischen Threads zu minimieren und nicht unter den Teppich zu kehren.

Das Synchronisieren von Objekten ist jedoch weiterhin nützlich. Anstatt von einem Synchronisierer zu erben, können wir eine Klasse schreiben, die einen beliebigen Typ in die Synchronisation einschließt. Benutzer müssen jetzt, da es synchronisiert ist, durch einige Rahmen springen, um Operationen an dem Objekt auszuführen, aber sie sind nicht auf einige handcodierte begrenzte Operationen an dem Objekt beschränkt. Sie können mehrere Operationen für das Objekt zu einer zusammensetzen oder eine Operation für mehrere Objekte ausführen.

Hier ist ein synchronisierter Wrapper um einen beliebigen Typ T:

template<class T>
struct synchronized {
  template<class F>
  auto read(F&& f) const&->std::result_of_t<F(T const&)> {
    return access(std::forward<F>(f), *this);
  }
  template<class F>
  auto read(F&& f) &&->std::result_of_t<F(T&&)> {
    return access(std::forward<F>(f), std::move(*this));
  }
  template<class F>
  auto write(F&& f)->std::result_of_t<F(T&)> {
    return access(std::forward<F>(f), *this);
  }
  // uses `const` ness of Syncs to determine access:
  template<class F, class... Syncs>
  friend auto access( F&& f, Syncs&&... syncs )->
  std::result_of_t< F(decltype(std::forward<Syncs>(syncs).t)...) >
  {
    return access2( std::index_sequence_for<Syncs...>{}, std::forward<F>(f), std::forward<Syncs>(syncs)... );
  };
  synchronized(synchronized const& o):t(o.read([](T const&o){return o;})){}
  synchronized(synchronized && o):t(std::move(o).read([](T&&o){return std::move(o);})){}  
  // special member functions:
  synchronized( T & o ):t(o) {}
  synchronized( T const& o ):t(o) {}
  synchronized( T && o ):t(std::move(o)) {}
  synchronized( T const&& o ):t(std::move(o)) {}
  synchronized& operator=(T const& o) {
    write([&](T& t){
      t=o;
    });
    return *this;
  }
  synchronized& operator=(T && o) {
    write([&](T& t){
      t=std::move(o);
    });
    return *this;
  }
private:
  template<class X, class S>
  static auto smart_lock(S const& s) {
    return std::shared_lock< std::shared_timed_mutex >(s.m, X{});
  }
  template<class X, class S>
  static auto smart_lock(S& s) {
    return std::unique_lock< std::shared_timed_mutex >(s.m, X{});
  }
  template<class L>
  static void lock(L& lockable) {
      lockable.lock();
  }
  template<class...Ls>
  static void lock(Ls&... lockable) {
      std::lock( lockable... );
  }
  template<size_t...Is, class F, class...Syncs>
  friend auto access2( std::index_sequence<Is...>, F&&f, Syncs&&...syncs)->
  std::result_of_t< F(decltype(std::forward<Syncs>(syncs).t)...) >
  {
    auto locks = std::make_tuple( smart_lock<std::defer_lock_t>(syncs)... );
    lock( std::get<Is>(locks)... );
    return std::forward<F>(f)(std::forward<Syncs>(syncs).t ...);
  }

  mutable std::shared_timed_mutex m;
  T t;
};
template<class T>
synchronized< T > sync( T&& t ) {
  return {std::forward<T>(t)};
}

Funktionen für C ++ 14 und C ++ 1z enthalten.

Dies setzt voraus, dass constVorgänge für mehrere Leser sicher sind (was stdContainer voraussetzen).

Verwendung sieht aus wie:

synchronized<int> x = 7;
x.read([&](auto&& v){
  std::cout << v << '\n';
});

für einen intmit synchronisiertem Zugriff.

Ich würde davon abraten synchronized(synchronized const&). Es wird selten benötigt.

Wenn Sie benötigen synchronized(synchronized const&), würde ich zu ersetzen versucht sein , T t;mit std::aligned_storage, so dass manuelle Platzierung Konstruktion und manuelle Zerstörung tun. Dies ermöglicht eine ordnungsgemäße Lebensdauerverwaltung.

Ansonsten könnten wir die Quelle kopieren Tund dann daraus lesen:

synchronized(synchronized const& o):
  t(o.read(
    [](T const&o){return o;})
  )
{}
synchronized(synchronized && o):
  t(std::move(o).read(
    [](T&&o){return std::move(o);})
  )
{}

zur Zuordnung:

synchronized& operator=(synchronized const& o) {
  access([](T& lhs, T const& rhs){
    lhs = rhs;
  }, *this, o);
  return *this;
}
synchronized& operator=(synchronized && o) {
  access([](T& lhs, T&& rhs){
    lhs = std::move(rhs);
  }, *this, std::move(o));
  return *this;
}
friend void swap(synchronized& lhs, synchronized& rhs) {
  access([](T& lhs, T& rhs){
    using std::swap;
    swap(lhs, rhs);
  }, *this, o);
}

Die Platzierung und die ausgerichteten Speicherversionen sind etwas unordentlicher. Der meiste Zugriff auf twürde durch eine Mitgliedsfunktion ersetzt T&t()und T const&t()const, außer bei Bauarbeiten, bei denen Sie durch einige Reifen springen müssten.

Indem wir synchronizedeinen Wrapper anstelle eines Teils der Klasse erstellen, müssen wir nur sicherstellen, dass die Klasse intern constals Mehrfachleser gilt, und ihn in einem Thread schreiben.

In den seltenen Fällen, in denen wir eine synchronisierte Instanz benötigen, springen wir durch Reifen wie oben.

Entschuldigung für Tippfehler oben. Es gibt wahrscheinlich einige.

Ein Nebeneffekt des oben Gesagten besteht darin, dass n-ary beliebige Operationen an synchronizedObjekten (desselben Typs) zusammenarbeiten, ohne dass sie vorher hart codiert werden müssen. Fügen Sie eine Freunddeklaration hinzu, und n-ary synchronizedObjekte mehrerer Typen können zusammenarbeiten. accessIn diesem Fall muss ich möglicherweise nicht mehr ein Inline-Freund sein, um mit Überlastungskonflikten fertig zu werden.

Live-Beispiel


4

Die Verwendung von Mutexen und C ++ - Verschiebungssemantik ist eine hervorragende Möglichkeit, Daten sicher und effizient zwischen Threads zu übertragen.

Stellen Sie sich einen "Produzenten" -Thread vor, der Stapel von Zeichenfolgen erstellt und diese (einem oder mehreren) Verbrauchern zur Verfügung stellt. Diese Stapel könnten durch ein Objekt dargestellt werden, das (möglicherweise große) std::vector<std::string>Objekte enthält. Wir möchten unbedingt den internen Zustand dieser Vektoren ohne unnötige Doppelarbeit in ihre Verbraucher verschieben.

Sie erkennen den Mutex einfach als Teil des Objekts und nicht als Teil des Objektzustands. Das heißt, Sie möchten den Mutex nicht verschieben.

Welche Sperre Sie benötigen, hängt von Ihrem Algorithmus ab oder davon, wie allgemein Ihre Objekte sind und welchen Verwendungsbereich Sie zulassen.

Wenn Sie immer nur von einem "Produzenten" -Objekt mit gemeinsamem Status zu einem threadlokalen "konsumierenden" Objekt wechseln, können Sie möglicherweise nur das vom Objekt verschobene Objekt sperren .

Wenn es sich um ein allgemeineres Design handelt, müssen Sie beide sperren. In einem solchen Fall müssen Sie dann ein Deadlocking in Betracht ziehen.

Wenn dies ein potenzielles Problem ist, verwenden Sie std::lock()diese Option, um Sperren für beide Mutexe auf Deadlock-freie Weise zu erwerben.

http://en.cppreference.com/w/cpp/thread/lock

Abschließend müssen Sie sicherstellen, dass Sie die Verschiebungssemantik verstehen. Denken Sie daran, dass das vom Objekt verschobene Objekt in einem gültigen, aber unbekannten Zustand belassen wird. Es ist durchaus möglich, dass ein Thread, der die Verschiebung nicht ausführt, einen gültigen Grund hat, auf das verschobene Objekt zuzugreifen, wenn er diesen gültigen, aber unbekannten Status findet.

Wieder schlägt mein Produzent nur Saiten aus und der Verbraucher nimmt die ganze Ladung weg. In diesem Fall kann der Produzent jedes Mal, wenn er versucht, dem Vektor etwas hinzuzufügen, feststellen, dass der Vektor nicht leer oder leer ist.

Kurz gesagt, wenn der potenzielle gleichzeitige Zugriff auf das vom Objekt verschobene Objekt einen Schreibvorgang darstellt, ist dies wahrscheinlich in Ordnung. Wenn es sich um einen Lesevorgang handelt, überlegen Sie, warum es in Ordnung ist, einen beliebigen Status zu lesen.


3

Zunächst muss etwas mit Ihrem Design nicht stimmen, wenn Sie ein Objekt mit einem Mutex verschieben möchten.

Wenn Sie sich dennoch dazu entschließen, müssen Sie im Verschiebungskonstruktor einen neuen Mutex erstellen, z. B.:

// movable
struct B{};

class A {
    B b;
    std::mutex m;
public:
    A(A&& a)
        : b(std::move(a.b))
        // m is default-initialized.
    {
    }
};

Dies ist threadsicher, da der Verschiebungskonstruktor davon ausgehen kann, dass sein Argument nirgendwo anders verwendet wird, sodass das Sperren des Arguments nicht erforderlich ist.


2
Das ist nicht threadsicher. Was ist, wenn das a.mutexgesperrt ist?: Sie verlieren diesen Zustand. -1

2
@ DieterLücking Solange das Argument der einzige Verweis auf das verschobene Objekt ist, gibt es keinen vernünftigen Grund, seinen Mutex zu sperren. Und selbst wenn dies der Fall ist, gibt es keinen Grund, einen Mutex eines neu erstellten Objekts zu sperren. Und wenn ja, ist dies ein Argument für das insgesamt schlechte Design von beweglichen Objekten mit Mutexen.
Anton Savin

1
@ DieterLücking Das stimmt einfach nicht. Können Sie einen Code bereitstellen, der das Problem veranschaulicht? Und nicht in der Form A a; A a2(std::move(a)); do some stuff with a.
Anton Savin

2
Wenn dies jedoch der beste Weg wäre, würde ich trotzdem empfehlen new, die Instanz hochzufahren und in eine zu platzieren std::unique_ptr, was sauberer erscheint und wahrscheinlich nicht zu Verwirrungsproblemen führt. Gute Frage.
Mike Vine

1
@ MikeVine Ich denke, Sie sollten es als Antwort hinzufügen.
Anton Savin
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.