Door het ontwerp is std::mutex
niet verplaatsbaar of kopieerbaar. Dit betekent dat een klasse A
met een mutex geen standaard move-constructor ontvangt.
Hoe kan ik dit type A
op 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 mutex
mutable
is 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 mutable
te maken.
Als je A
maakt, 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 this
moesten 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::lock
is 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 ReadLock
wordt 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::swap
die het werk doet, de vergrendeling de verkeerde granulariteit heeft, vergrendelen en ontgrendelen tussen de drie bewegingen die std::swap
intern zou presteren.
Inderdaad, als u aan swap
denkt, 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_mutex
te 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 const
bewerkingen veilig zijn voor meerdere lezers (wat is wat std
containers aannemen).
Gebruik ziet eruit als:
synchronized<int> x = 7;
x.read([&](auto&& v){
std::cout << v << '\n';
});
voor een int
met 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 t
kopië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 t
zou worden vervangen door een lidfunctie T&t()
en T const&t()const
, behalve bij constructie waar je zou door hoepels moeten springen.
Door synchronized
een wrapper te maken in plaats van een deel van de klasse, hoeven we er alleen voor te zorgen dat de klasse intern const
respecteert 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 synchronized
objecten (van hetzelfde type) samenwerken, zonder dat deze vooraf hard moeten worden gecodeerd. Voeg een vriendverklaring toe en n-ary synchronized
objecten van meerdere typen kunnen samenwerken. In dat geval moet ik misschien access
verplaatsen als inline-vriend om overbelastingsconflicten aan te pakken.
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.