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


86

In base alla progettazione, std::mutexnon è mobile né copiabile. Ciò significa che una classe che Apossiede un mutex non riceverà un costruttore di mosse predefinito.

Come renderei Amobile questo tipo in modo thread-safe?


4
La domanda ha una stranezza: anche l'operazione di spostamento deve essere thread-safe o è sufficiente se gli altri accessi all'oggetto sono thread-safe?
Jonas Schäfer

2
@paulm Dipende davvero dal design. Ho spesso visto una classe avere una variabile membro mutex, quindi solo il std::lock_guardmetodo è con ambito.
Cory Kramer

2
@ Jonas Wielicki: All'inizio pensavo che spostarlo dovesse anche essere thread-safe. Tuttavia, non che ci ripensi, questo non ha molto senso, poiché la costruzione di mosse di un oggetto di solito invalida lo stato del vecchio oggetto. Quindi altri thread non devono essere in grado di accedere al vecchio oggetto, se sta per essere spostato .. altrimenti potrebbero presto accedere a un oggetto non valido. Ho ragione?
Jack Sabbath

2
si prega di seguire questo collegamento potrebbe essere utilizzato per intero justsoftwaresolutions.co.uk/threading/…
Ravi Chauhan

1
@Dieter Lücking: sì, questa è l'idea .. mutex M protegge la classe B. Tuttavia, dove memorizzo entrambi per avere un oggetto thread-safe e accessibile? Sia M che B potrebbero andare alla classe A .. e in questo caso la classe A avrebbe un Mutex nell'ambito della classe.
Jack Sabbath

Risposte:


105

Cominciamo con un po 'di codice:

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:
    ...

Ho inserito alcuni alias di tipo piuttosto suggestivi che non trarremo vantaggio in C ++ 11, ma diventeranno molto più utili in C ++ 14. Sii paziente, ci arriveremo.

La tua domanda si riduce a:

Come scrivo il costruttore di spostamento e l'operatore di assegnazione dello spostamento per questa classe?

Inizieremo con il costruttore di mosse.

Move Constructor

Notare che il membro mutexè stato creato mutable. A rigor di termini questo non è necessario per i membri del trasferimento, ma presumo che tu voglia anche copiare i membri. In caso contrario, non è necessario creare il mutex mutable.

Durante la costruzione A, non è necessario bloccare this->mut_. Ma devi bloccare l' mut_oggetto da cui stai costruendo (sposta o copia). 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_);
    }

Notare che prima dovevamo costruire di default i membri di this, quindi assegnare loro i valori solo dopo che a.mut_è stato bloccato.

Sposta assegnazione

L'operatore di assegnazione dello spostamento è sostanzialmente più complicato perché non si sa se qualche altro thread accede a lhs o rhs dell'espressione di assegnazione. E in generale, è necessario proteggersi dal 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;
    }

Si noti che è necessario utilizzare 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 in ordine opposto come mostrato sopra, puoi ottenere un deadlock. Il punto std::lockè evitare quella situazione di stallo.

Copia 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 copia assomiglia molto al costruttore di spostamento tranne per il fatto che l' ReadLockalias viene utilizzato al posto del WriteLock. Attualmente sono entrambi alias std::unique_lock<std::mutex>e quindi non fa davvero alcuna differenza.

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

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

Questa potrebbe essere un'ottimizzazione, ma non definitivamente. Dovrai misurare per determinare se lo è. Ma con questa modifica, è possibile copiare il costrutto dalla stessa destra in più thread contemporaneamente. La soluzione C ++ 11 ti obbliga a rendere sequenziali tali thread, anche se le rhs non vengono modificate.

Copia compito

Per completezza, ecco l'operatore di assegnazione della copia, che dovrebbe essere abbastanza autoesplicativo 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.

Anche gli altri membri o funzioni libere che accedono allo Astato devono essere protetti se ci si aspetta che più thread siano in grado di chiamarli contemporaneamente. Ad esempio, ecco 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_);
        }
    }

Nota che se dipendi solo dal std::swapfare il lavoro, il blocco sarà con granularità sbagliata, bloccando e sbloccando tra le tre mosse che std::swapverrebbero eseguite internamente.

In effetti, pensare swappuò darti un'idea dell'API di cui potresti aver bisogno per fornire un "thread-safe" A, che in generale sarà diverso da un'API "non-thread-safe", a causa del problema della "granularità del blocco".

Notare anche la necessità di proteggersi dal "self-swap". "self-swap" dovrebbe essere un no-op. Senza l'autoverifica si bloccherebbe ricorsivamente lo stesso mutex. Questo potrebbe anche essere risolto senza l'autoverifica utilizzando std::recursive_mutexper MutexType.

Aggiornare

Nei commenti qui sotto Yakk è piuttosto scontento di dover costruire cose predefinite nei costruttori di copia e spostamento (e ha ragione). Se ti senti abbastanza forte su questo problema, tanto da essere disposto a dedicarci memoria, puoi evitarlo in questo modo:

  • Aggiungi qualsiasi tipo di blocco di cui hai bisogno come membri dati. Questi membri devono precedere i dati che vengono protetti:

    mutable MutexType mut_;
    ReadLock  read_lock_;
    WriteLock write_lock_;
    // ... other data members ...
    
  • E poi nei costruttori (ad esempio 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 aver trovato una soluzione in questa risposta.

Aggiorna 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_)
    {}

2
Il tuo costruttore di copie assegna i campi, non li copia. Ciò significa che devono essere costruibili per impostazione predefinita, il che è una sfortunata restrizione.
Yakk - Adam Nevraumont

@ Yakk: Sì, inserire mutexesi tipi di classe non è "l'unico vero modo". È uno strumento nella cassetta degli attrezzi e se vuoi usarlo, ecco come.
Howard Hinnant

@Yakk: Cerca la mia risposta per la stringa "C ++ 14".
Howard Hinnant

ah, scusa, mi mancava quel C ++ a 14 bit.
Yakk - Adam Nevraumont

2
ottima spiegazione @HowardHinnant! in C ++ 17 puoi anche usare std :: scoped_lock lock (x.mut_, y_mut_); In questo modo ti affidi all'implementazione per bloccare diversi mutex in un ordine corretto
fen

7

Dato che non sembra esserci un modo carino, pulito e semplice per rispondere a questa domanda: la soluzione di Anton penso sia corretta ma è decisamente discutibile, a meno che non venga fuori una risposta migliore, consiglierei di mettere una lezione del genere sul mucchio e di prendersene cura tramite un std::unique_ptr:

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

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

Se hai bisogno della semantica della copia, usa

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

5

Questa è una risposta capovolta. Invece di incorporare "questi oggetti devono essere sincronizzati" come base del tipo, iniettalo invece in qualsiasi tipo.

Hai a che fare con un oggetto sincronizzato in modo molto diverso. Un grosso problema è che devi preoccuparti dei deadlock (blocco di più oggetti). Inoltre, in pratica non dovrebbe mai essere la tua "versione predefinita di un oggetto": gli oggetti sincronizzati sono per oggetti che saranno in conflitto e il tuo obiettivo dovrebbe essere quello di ridurre al minimo la contesa tra i thread, non di nasconderla sotto il tappeto.

Ma la sincronizzazione degli oggetti è ancora utile. Invece di ereditare da un sincronizzatore, possiamo scrivere una classe che racchiuda un tipo arbitrario nella sincronizzazione. Gli utenti devono saltare alcuni cerchi per eseguire operazioni sull'oggetto ora che è sincronizzato, ma non sono limitati a un insieme limitato di operazioni sull'oggetto codificate manualmente. Possono comporre più operazioni sull'oggetto in una o avere un'operazione su più oggetti.

Ecco un wrapper sincronizzato attorno a un tipo arbitrario 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)};
}

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

ciò presuppone che le constoperazioni siano sicure per più lettori (che è ciò che i stdcontenitori assumono).

L'utilizzo si presenta come:

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

per un intcon accesso sincronizzato.

Lo sconsiglierei synchronized(synchronized const&). È raramente necessario.

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

A parte ciò, potremmo copiare la fonte 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ù complicate. La maggior parte degli accessi a tsarebbe stata sostituita da una funzione membro T&t()e T const&t()const, tranne nella costruzione in cui dovresti saltare alcuni cerchi.

Creando synchronizedun wrapper invece di parte della classe, tutto ciò che dobbiamo assicurarci è che la classe rispetti internamente constcome lettore multiplo e lo scriva in un modo a thread singolo.

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

Mi scuso per eventuali errori di battitura in quanto sopra. Probabilmente ce ne sono alcuni.

Un vantaggio collaterale di quanto sopra è che le operazioni arbitrarie n-arie su synchronizedoggetti (dello stesso tipo) lavorano insieme, senza doverle codificare prima. Aggiungi una dichiarazione di amicizia e synchronizedoggetti diversi di più tipi potrebbero funzionare insieme. Potrei dover accesssmettere di essere un amico in linea per affrontare i conflitti di sovraccarico in quel caso.

esempio dal vivo


4

L'uso dei mutex e della semantica di spostamento C ++ è un modo eccellente per trasferire in modo sicuro ed efficiente i dati tra i thread.

Immagina un thread "produttore" che crea batch di stringhe e li fornisce a (uno o più) consumatori. Tali batch potrebbero essere rappresentati da un oggetto contenente oggetti (potenzialmente grandi) std::vector<std::string>. Vogliamo assolutamente "spostare" lo stato interno di questi vettori nei loro consumatori senza duplicazioni inutili.

Riconosci semplicemente il mutex come parte dell'oggetto e non come parte dello stato dell'oggetto. 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 dalla gamma di usi che permetti.

Se ti sposti solo da un oggetto "produttore" di stato condiviso a un oggetto "consumante" locale del thread, potresti essere OK per bloccare solo l' oggetto spostato da .

Se si tratta di un design più generale, dovrai bloccarli entrambi. In tal caso è necessario quindi considerare il deadlock.

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

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

Come nota finale è necessario assicurarsi di aver compreso la semantica del movimento. Ricorda che l'oggetto spostato da viene lasciato in uno stato valido ma sconosciuto. È del tutto possibile che un thread che non esegue lo spostamento abbia un motivo valido per tentare di accedere all'oggetto spostato dall'oggetto quando potrebbe trovare quello stato valido ma sconosciuto.

Anche in questo caso il mio produttore sta solo suonando le corde e il consumatore sta portando via l'intero carico. In tal caso, ogni volta che il produttore cerca di aggiungere al vettore, potrebbe trovare il vettore non vuoto o vuoto.

In breve, se il potenziale accesso simultaneo all'oggetto spostato equivale a una scrittura, è probabile che sia OK. Se equivale a una lettura, pensa al motivo per cui va bene leggere uno stato arbitrario.


3

Prima di tutto, deve esserci qualcosa di sbagliato nel tuo progetto se vuoi spostare un oggetto contenente un mutex.

Ma se decidi di farlo comunque, devi creare un nuovo mutex nel costruttore di spostamenti, cioè ad esempio:

// movable
struct B{};

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

Questo è thread-safe, perché il costruttore di spostamento può tranquillamente presumere che il suo argomento non sia utilizzato da nessun'altra parte, quindi il blocco dell'argomento non è richiesto.


2
Questo non è thread-safe. E se a.mutexè bloccato: perdi quello stato. -1

2
@ DieterLücking Fintanto che l'argomento è l'unico riferimento all'oggetto spostato da, non c'è motivo ragionevole per cui il suo mutex sia bloccato. E anche se lo è, non c'è motivo per bloccare un mutex di un oggetto appena creato. E se c'è, questo è un argomento per una cattiva progettazione generale di oggetti mobili con mutex.
Anton Savin

1
@ DieterLücking Questo non è vero. Potete fornire un codice che illustri il problema? E non nella forma A a; A a2(std::move(a)); do some stuff with a.
Anton Savin

2
Tuttavia, se questo fosse il modo migliore, consiglierei comunque di neweseguire l'istanza e di inserirla in una std::unique_ptr, che sembra più pulita e non è probabile che porti a problemi di confusione. Buona domanda.
Mike Vine

1
@ MikeVine Penso che dovresti aggiungerlo come risposta.
Anton Savin
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.