Come devo gestire i mutex nei tipi mobili in C ++?

In base alla progettazione, std::mutex non è mobile né copy-constructable. Ciò significa che una class A , che contiene un mutex, non riceverà un costruttore di mosse predefinito.

Come faccio a rendere questo tipo A mobile in modo thread-safe?

Iniziamo con un po ‘di codice:

 class A { using MutexType = std::mutex; using ReadLock = std::unique_lock; using WriteLock = std::unique_lock; mutable MutexType mut_; std::string field1_; std::string field2_; public: ... 

Ho inserito alias alcuni alias piuttosto suggestivi che non sfrutteremo in C ++ 11, ma che diventano molto più utili in C ++ 14. Sii paziente, ci arriveremo.

La tua domanda si riduce a:

Come posso scrivere il costruttore di movimento e spostare l’operatore di assegnazione per questa class?

Inizieremo con il costruttore di mosse.

Sposta Costruttore

Si noti che il membro mutex è stato reso mutable . A rigor di termini questo non è necessario per i membri del trasloco, ma suppongo che vogliate anche copiare membri. Se questo non è il caso, non è necessario rendere mutex mutex.

Durante la costruzione di A , non è necessario bloccare this->mut_ . Ma è necessario bloccare il mut_ dell’object da cui si sta costruendo (spostare o copiare). Questo può essere fatto in questo modo:

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

Si noti che per impostazione predefinita è necessario a.mut_ i membri di this e quindi assegnare loro i valori solo dopo che a.mut_ è bloccato.

Sposta Assegnazione

L’operatore di assegnazione del movimento è sostanzialmente più complicato perché non si sa se qualche altro thread sta accedendo al lhs o al rhs dell’espressione di assegnazione. E in generale, è necessario evitare il seguente scenario:

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

Ecco l’operatore di assegnazione del movimento che protegge correttamente lo scenario precedente:

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

Nota che devi usare std::lock(m1, m2) per bloccare i due mutex, invece di bloccarli uno dopo l’altro. Se li blocchi uno dopo l’altro, quando due thread assegnano due oggetti nell’ordine opposto, come mostrato sopra, puoi ottenere un deadlock. Il punto di std::lock è di evitare quel deadlock.

Copia il costruttore

Non hai chiesto dei membri della copia, ma potremmo anche parlarne ora (se non tu, qualcuno ne avrà bisogno).

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

Il costruttore di copie assomiglia molto al costruttore di mosse, tranne che l’alias ReadLock viene usato al posto di WriteLock . Attualmente entrambi questi alias std::unique_lock e quindi non fa alcuna differenza.

Ma in C ++ 14, avrai la possibilità di dire questo:

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

Questa potrebbe essere un’ottimizzazione, ma non sicuramente. Dovrai misurare per determinare se lo è. Ma con questa modifica, è ansible copiare il costrutto dallo stesso rh in più thread contemporaneamente. La soluzione C ++ 11 ti obbliga a rendere sequenziali tali thread, anche se i rh non vengono modificati.

Copia l’incarico

Per completezza, ecco l’operatore di assegnazione delle copie, che dovrebbe essere abbastanza auto esplicativo dopo aver letto di tutto il resto:

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

E così via.

Tutti gli altri membri o le funzioni gratuite che accedono allo stato di A dovranno anche essere protetti se si prevede che più thread possano chiamarli contemporaneamente. Ad esempio, ecco lo 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_); } } 

Si noti che se si dipende solo da std::swap fa il lavoro, il blocco avrà la granularità, il blocco e lo sblocco errati tra le tre mosse che std::swap eseguirà internamente.

In effetti, pensare allo swap può darti un’idea dell’API che potresti dover fornire per una “thread-safe” A , che in generale sarà diversa da un’API “non thread-safe”, a causa della “granularità di blocco” problema.

Si noti inoltre la necessità di proteggere da “self-swap”. “self-swap” dovrebbe essere un no-op. Senza l’autocontrollo si bloccherebbe ricorsivamente lo stesso mutex. Questo potrebbe anche essere risolto senza l’autocontrollo utilizzando std::recursive_mutex per MutexType .

Aggiornare

Nei commenti qui sotto Yakk è piuttosto scontento di dover predefinire build cose nella copia e spostare i costruttori (e ha un punto). Se ti senti abbastanza forte su questo problema, così tanto che sei disposto a spendere memoria su di esso, puoi evitarlo in questo modo:

  • Aggiungi tutti i tipi di lock necessari come membri dei dati. Questi membri devono venire prima dei dati che vengono protetti:

     mutable MutexType mut_; ReadLock read_lock_; WriteLock write_lock_; // ... other data members ... 
  • E poi nei costruttori (es. Il costruttore di copie) fai questo:

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

Oops, Yakk ha cancellato il suo commento prima che avessi la possibilità di completare questo aggiornamento. Ma merita credito per aver spinto questo problema e ottenere una soluzione in questa risposta.

Aggiornamento 2

E dyp ha avuto questo buon suggerimento:

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

Questa è una risposta capovolta. Invece di incorporare “questo object deve essere sincronizzato” come base del tipo, invece lo inietti sotto qualsiasi tipo.

Hai a che fare con un object sincronizzato in modo molto diverso. Un grosso problema è che devi preoccuparti dei deadlock (bloccare più oggetti). Inoltre, in pratica non dovrebbe mai essere la tua “versione predefinita di un object”: gli oggetti sincronizzati sono per gli oggetti che saranno in conflitto, e il tuo objective dovrebbe essere quello di minimizzare la contesa tra i thread, non di spazzarli sotto il tappeto.

Ma la sincronizzazione degli oggetti è ancora utile. Invece di ereditare da un sincronizzatore, possiamo scrivere una class che avvolge un tipo arbitrario in sincronizzazione. Gli utenti devono passare attraverso alcuni canvasi per eseguire operazioni sull’object ora che è sincronizzato, ma non sono limitati ad alcune serie di operazioni limitate a mano sull’object. Possono comporre più operazioni sull’object in una o eseguire un’operazione su più oggetti.

Ecco un wrapper sincronizzato attorno a un tipo arbitrario T :

 template struct synchronized { template auto read(F&& f) const&->std::result_of_t { return access(std::forward(f), *this); } template auto read(F&& f) &&->std::result_of_t { return access(std::forward(f), std::move(*this)); } template auto write(F&& f)->std::result_of_t { return access(std::forward(f), *this); } // uses `const` ness of Syncs to determine access: template friend auto access( F&& f, Syncs&&... syncs )-> std::result_of_t< F(decltype(std::forward(syncs).t)...) > { return access2( std::index_sequence_for{}, std::forward(f), std::forward(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 static auto smart_lock(S const& s) { return std::shared_lock< std::shared_timed_mutex >(sm, X{}); } template static auto smart_lock(S& s) { return std::unique_lock< std::shared_timed_mutex >(sm, X{}); } template static void lock(L& lockable) { lockable.lock(); } template static void lock(Ls&... lockable) { std::lock( lockable... ); } template friend auto access2( std::index_sequence, F&&f, Syncs&&...syncs)-> std::result_of_t< F(decltype(std::forward(syncs).t)...) > { auto locks = std::make_tuple( smart_lock(syncs)... ); lock( std::get(locks)... ); return std::forward(f)(std::forward(syncs).t ...); } mutable std::shared_timed_mutex m; T t; }; template synchronized< T > sync( T&& t ) { return {std::forward(t)}; } 

Funzionalità C ++ 14 e C ++ 1z incluse.

ciò presuppone che le operazioni const siano sicure per più lettori (che è ciò che assumono i contenitori std ).

Usa sembra:

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

per un int con accesso sincronizzato.

Ti consiglio di non avere synchronized(synchronized const&) . Raramente è necessario.

Se hai bisogno di synchronized(synchronized const&) , sarei tentato di sostituire T t; con std::aligned_storage , che consente la costruzione del posizionamento manuale e la distruzione manuale. Ciò consente una corretta gestione della durata.

Escludendo, potremmo copiare la sorgente T , quindi leggere da essa:

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

per incarico:

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

il posizionamento e le versioni di archiviazione allineate sono un po 'più confuse. La maggior parte dell'accesso a t verrebbe sostituito da una funzione membro T&t() e T const&t()const , tranne che nella costruzione in cui dovresti saltare attraverso alcuni cerchi.

Rendendo synchronized un wrapper invece di parte della class, tutto ciò che dobbiamo assicurare è che la class rispetti internamente const come lettore multiplo e scriva in un modo a thread singolo.

Nei rari casi abbiamo bisogno di un'istanza sincronizzata, saltiamo attraverso i cerchi come sopra.

Ci scusiamo per qualsiasi refuso in quanto sopra. Probabilmente ce ne sono alcuni.

Un vantaggio collaterale di cui sopra è che le operazioni arbitrarie di n-ary su oggetti synchronized (dello stesso tipo) funzionano insieme, senza doverli codificare prima della mano. Aggiungi una dichiarazione di amicizia e oggetti synchronized n-a di più tipi potrebbero funzionare insieme. Potrei dover spostare l' access dall'essere un amico in linea per gestire i sovraccarichi in quel caso.

esempio dal vivo

Dato che non sembra essere un modo carino, pulito, facile per rispondere a questa – la soluzione di Anton credo sia corretta ma è sicuramente discutibile, a meno che non si presenti una risposta migliore, consiglierei di mettere una tale class sul mucchio e prendersene cura tramite uno std::unique_ptr :

 auto a = std::make_unique(); 

Ora è un tipo completamente mobile e chiunque abbia un blocco sul mutex interno mentre una mossa accade è ancora sicuro, anche se è discutibile se questa sia una buona cosa da fare

Se hai bisogno di copiare la semantica basta usare

 auto a2 = std::make_shared(); 

L’uso di mutex e C ++ move semantics è un modo eccellente per trasferire in modo sicuro ed efficiente i dati tra i thread.

Immagina un thread “produttore” che crea lotti di stringhe e li fornisce a (uno o più) consumatori. Questi batch potrebbero essere rappresentati da un object contenente (potenzialmente grandi) oggetti std::vector . Vogliamo assolutamente “spostare” lo stato interno di tali vettori nei loro consumatori senza inutili duplicazioni.

Semplicemente riconosci il mutex come parte dell’object non parte dello stato dell’object. Cioè, non vuoi spostare il mutex.

Il blocco di cui hai bisogno dipende dal tuo algoritmo o da quanto sono generalizzati i tuoi oggetti e da quale gamma di usi autorizzate.

Se ti sposti solo da un object “produttore” di stato condiviso a un object “consumato” thread-local, potresti essere in grado di bloccare solo il mosso dall’object.

Se si tratta di un disegno più generale, sarà necessario bloccarli entrambi. In tal caso, è necessario considerare il dead-locking.

Se questo è un potenziale problema, utilizzare std::lock() per acquisire i blocchi su entrambi i mutex in un modo deadlock libero.

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

Come nota finale è necessario assicurarsi di aver compreso la semantica del movimento. Ricorda che lo spostamento dall’object è rimasto in uno stato valido ma sconosciuto. È del tutto ansible che un thread che non esegue la mossa abbia un motivo valido per tentare di accedere alla mossa dall’object quando può trovare quello stato valido ma sconosciuto.

Ancora una volta il mio produttore sta battendo le corde e il consumatore sta portando via tutto il carico. In tal caso, ogni volta che il produttore tenta di aggiungere al vettore, può trovare il vettore non vuoto o vuoto.

In breve, se il potenziale accesso simultaneo all’object spostato da un object corrisponde a una scrittura, è probabile che sia OK. Se equivale a una lettura, pensa al motivo per cui è corretto leggere uno stato arbitrario.

Prima di tutto, ci deve essere qualcosa di sbagliato nel tuo progetto se vuoi spostare un object che contiene un mutex.

Ma se decidi di farlo comunque, devi creare un nuovo mutex in move constructor, ad esempio:

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

Questo è thread-safe, perché il costruttore di move può tranquillamente assumere che il suo argomento non è usato da nessun’altra parte, quindi il blocco dell’argomento non è richiesto.