Hoe moet ik omgaan met mutexen in verplaatsbare typen in C++?

Door het ontwerp is std::mutexniet verplaatsbaar of kopieerbaar. Dit betekent dat een klasse Amet een mutex geen standaard move-constructor ontvangt.

Hoe kan ik dit type Aop een thread-safe manier verplaatsbaar maken?


Antwoord 1, autoriteit 100%

Laten we beginnen met een stukje 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:
    ...

Ik heb er nogal suggestieve type-aliassen in gestopt waarvan we niet echt gebruik zullen maken in C++11, maar die veel nuttiger worden in C++14. Heb geduld, we komen er wel.

Uw vraag komt neer op:

Hoe schrijf ik de move-constructor en de move-toewijzingsoperator voor deze klas?

We beginnen met de move-constructor.

Configurator verplaatsen

Merk op dat het lid mutexmutableis gemaakt. Strikt genomen is dit niet nodig voor de verhuisleden, maar ik neem aan dat je ook kopieerleden wilt. Als dat niet het geval is, is het niet nodig om de mutex mutablete maken.

Als je Amaakt, hoef je this->mut_niet te vergrendelen. Maar je moet de mut_van het object waarvan je aan het bouwen bent wel vergrendelen (verplaatsen of kopiëren). Dit kan als volgt worden gedaan:

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

Houd er rekening mee dat we eerst de leden van thismoesten construeren en ze pas waarden moesten toewijzen nadat a.mut_is vergrendeld.

Opdracht verplaatsen

De verplaatsingstoewijzingsoperator is aanzienlijk gecompliceerder omdat u niet weet of een andere thread toegang heeft tot de lhs of rhs van de toewijzingsexpressie. En in het algemeen moet u zich beschermen tegen het volgende scenario:

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

Hier is de operator voor verplaatsingstoewijzing die het bovenstaande scenario correct bewaakt:

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

Merk op dat men std::lock(m1, m2)moet gebruiken om de twee mutexen te vergrendelen, in plaats van ze gewoon achter elkaar te vergrendelen. Als u ze de een na de ander vergrendelt, kunt u een deadlock krijgen wanneer twee threads twee objecten in tegengestelde volgorde toewijzen, zoals hierboven weergegeven. Het punt van std::lockis om die impasse te vermijden.

Configurator kopiëren

Je hebt niet gevraagd naar de kopieleden, maar we kunnen er nu net zo goed over praten (als jij dat niet bent, heeft iemand ze nodig).

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

De copy-constructor lijkt veel op de move-constructor, behalve dat de alias ReadLockwordt gebruikt in plaats van de WriteLock. Momenteel zijn deze beide alias std::unique_lock<std::mutex>en dus maakt het niet echt uit.

Maar in C++14 heb je de mogelijkheid om dit te zeggen:

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

Dit kaneen optimalisatie zijn, maar niet zeker. Je zult moeten meten om te bepalen of dit het geval is. Maar met deze wijziging kan men constructie uitdezelfde rhs in meerdere threads tegelijk kopiëren. De C++11-oplossing dwingt je om dergelijke threads sequentieel te maken, ook al wordt de rhs niet gewijzigd.

Opdracht kopiëren

Voor de volledigheid, hier is de kopieeropdrachtoperator, die vrij duidelijk zou moeten zijn na het lezen van al het andere:

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

En enz.

Andere leden of gratis functies die toegang hebben tot de status van A, moeten ook worden beschermd als u verwacht dat meerdere threads ze tegelijk kunnen aanroepen. Hier is bijvoorbeeld 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_);
        }
    }

Merk op dat als je gewoon afhankelijk bent van std::swapdie het werk doet, de vergrendeling de verkeerde granulariteit heeft, vergrendelen en ontgrendelen tussen de drie bewegingen die std::swapintern zou presteren.

Inderdaad, als u aan swapdenkt, kunt u inzicht krijgen in de API die u mogelijk nodig heeft voor een “thread-safe” A, die in het algemeen anders zal zijn dan een “niet-thread-safe” API, vanwege het probleem met de “locking granularity”.

Let ook op de noodzaak om te beschermen tegen “self-swap”. “self-swap” zou een no-op moeten zijn. Zonder de zelfcontrole zou men dezelfde mutex recursief vergrendelen. Dit kan ook worden opgelost zonder de zelfcontrole door std::recursive_mutexte gebruiken voor MutexType.

Bijwerken

In de onderstaande opmerkingen is Yakk behoorlijk ongelukkig over het feit dat hij standaard dingen moet construeren in de kopieer- en verplaatsingsconstructors (en hij heeft een punt). Mocht u zich sterk genoeg voelen over dit probleem, zo erg dat u bereid bent er geheugen aan te besteden, dan kunt u het als volgt vermijden:

  • Voeg alle vergrendelingstypes toe die je nodig hebt als gegevensleden. Deze leden moeten vóór de gegevens komen die worden beschermd:

    mutable MutexType mut_;
    ReadLock  read_lock_;
    WriteLock write_lock_;
    // ... other data members ...
    
  • En doe dan in de constructors (bijvoorbeeld de copy-constructor) dit:

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

Oeps, Yakk heeft zijn commentaar gewist voordat ik de kans had om deze update te voltooien. Maar hij verdient de eer voor het pushen van dit probleem en het vinden van een oplossing voor dit antwoord.

Update 2

En dyp kwam met deze goede suggestie:

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

Antwoord 2, autoriteit 8%

Aangezien er geen mooie, duidelijke, gemakkelijke manier lijkt te zijn om dit te beantwoorden – Anton’s oplossing denk ikcorrect is, maar het is zeker discutabel, tenzij er een beter antwoord komt, raad ik aan om zo’n klasse op de stapel en er voor zorgen via een std::unique_ptr:

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

Het is nu een volledig verplaatsbaar type en iedereen die een slot op de interne mutex heeft terwijl een beweging plaatsvindt, is nog steeds veilig, zelfs als het de vraag is of dit een goede zaak is om te doen

Als je kopieersemantiek nodig hebt, gebruik dan

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

Antwoord 3, autoriteit 4%

Dit is een omgekeerd antwoord. In plaats van “deze objecten moeten worden gesynchroniseerd” als basis van het type in te sluiten, injecteert u het in plaats daarvan onderelk type.

Je gaat heel anders om met een gesynchroniseerd object. Een groot probleem is dat u zich zorgen moet maken over deadlocks (meerdere objecten vergrendelen). Het zou in principe ook nooit je “standaardversie van een object” moeten zijn: gesynchroniseerde objecten zijn voor objecten die in conflict zijn, en je doel zou moeten zijn om de strijd tussen threads te minimaliseren, niet onder het tapijt te vegen.

Maar het synchroniseren van objecten is nog steeds nuttig. In plaats van te erven van een synchronisatieprogramma, kunnen we een klasse schrijven die een willekeurig type in synchronisatie omhult. Gebruikers moeten door een paar hoepels springen om bewerkingen op het object uit te voeren nu het is gesynchroniseerd, maar ze zijn niet beperkt tot een handgecodeerde beperkte reeks bewerkingen op het object. Ze kunnen meerdere bewerkingen op het object samenvoegen tot één, of een bewerking uitvoeren op meerdere objecten.

Hier is een gesynchroniseerde wrapper rond een willekeurig type 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)};
}

C++14- en C++1z-functies inbegrepen.

dit veronderstelt dat constbewerkingen veilig zijn voor meerdere lezers (wat is wat stdcontainers aannemen).

Gebruik ziet eruit als:

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

voor een intmet gesynchroniseerde toegang.

Ik raad af om synchronized(synchronized const&)te gebruiken. Het is zelden nodig.

Als je synchronized(synchronized const&)nodig hebt, zou ik in de verleiding komen om T t;te vervangen door std::aligned_storage, handmatige plaatsing mogelijk maken en handmatige vernietiging uitvoeren. Dat maakt een goed beheer van de levensduur mogelijk.

Behalve dat, kunnen we de bron tkopiëren en er dan uit lezen:

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

voor opdracht:

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

de versies voor plaatsing en uitgelijnde opslag zijn wat rommeliger. De meeste toegang tot tzou worden vervangen door een lidfunctie T&t()en T const&t()const, behalve bij constructie waar je zou door hoepels moeten springen.

Door synchronizedeen wrapper te maken in plaats van een deel van de klasse, hoeven we er alleen voor te zorgen dat de klasse intern constrespecteert als zijnde meervoudige lezer, en deze te schrijven op een single-threaded manier.

In de zeldzamegevallen hebben we een gesynchroniseerde instantie nodig, we springen door hoepels zoals hierboven.

Excuses voor eventuele typefouten in het bovenstaande. Er zijn er waarschijnlijk een paar.

Een bijkomend voordeel van het bovenstaande is dat n-ary willekeurige bewerkingen op synchronizedobjecten (van hetzelfde type) samenwerken, zonder dat deze vooraf hard moeten worden gecodeerd. Voeg een vriendverklaring toe en n-ary synchronizedobjecten van meerdere typen kunnen samenwerken. In dat geval moet ik misschien accessverplaatsen als inline-vriend om overbelastingsconflicten aan te pakken.

live voorbeeld


Antwoord 4, autoriteit 3%

Allereerst moet er iets mis zijn met je ontwerp als je een object met een mutex wilt verplaatsen.

Maar als u besluit het toch te doen, moet u een nieuwe mutex maken in de move-constructor, dat wil zeggen:

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

Dit is thread-safe, omdat de move-constructor veilig kan aannemen dat zijn argument nergens anders wordt gebruikt, dus het vergrendelen van het argument is niet vereist.


Antwoord 5, autoriteit 3%

Het gebruik van mutexen en C++ move-semantiek is een uitstekende manier om veilig en efficiënt gegevens tussen threads over te dragen.

Stel je een ‘producer’-thread voor die batches strings maakt en deze aan (een of meer) consumenten levert. Die batches kunnen worden weergegeven door een object dat (mogelijk grote) std::vector<std::string>-objecten bevat.
We willen absoluut de interne toestand van die vectoren ‘verplaatsen’ naar hun consumenten zonder onnodige duplicatie.

Je herkent de mutex gewoon als onderdeel van het object en niet als onderdeel van de staat van het object. Dat wil zeggen, u wilt de mutex niet verplaatsen.

Welke vergrendeling u nodig heeft, hangt af van uw algoritme of hoe algemeen uw objecten zijn en welk gebruik u toestaat.

Als je alleen maar van een ‘producer’-object met gedeelde status naar een thread-local ‘consumerend’ object gaat, is het misschien goed om alleen het verplaatste object vante vergrendelen.

Als het een algemener ontwerp is, moet u beide vergrendelen. In zo’n geval moet u dead-locking overwegen.

Als dat een potentieel probleem is, gebruik dan std::lock()om sloten op beide mutexen op een deadlock-vrije manier te verkrijgen.

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

Als laatste opmerking moet je ervoor zorgen dat je de bewegingssemantiek begrijpt.
Bedenk dat het verplaatste object zich in een geldige maar onbekende staat bevindt.
Het is heel goed mogelijk dat een thread die de verplaatsing niet uitvoert een geldige reden heeft om toegang te krijgen tot het verplaatste object wanneer het die geldige maar onbekende status kan vinden.

Nogmaals, mijn producer bonkt gewoon de snaren uit en de consument neemt de hele lading weg. In dat geval kan elke keer dat de producent probeert toe te voegen aan de vector, de vector niet-leeg of leeg zijn.

Kortom, als de potentiële gelijktijdige toegang tot het verplaatste object neerkomt op schrijven, is het waarschijnlijk in orde. Als het een read is, bedenk dan waarom het oké is om een ​​willekeurige toestand te lezen.

Other episodes