Intentionnellement, std::mutex
n'est ni mobile ni copiable. Cela signifie qu'une classe A
, qui contient un mutex, ne recevra pas de constructeur par défaut-move.
Comment pourrais-je rendre ce type A
mobile de manière thread-safe?
Commençons par un peu de 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:
...
J'ai ajouté des alias de type plutôt suggestifs dont nous ne tirerons pas vraiment parti en C++ 11, mais qui deviendront beaucoup plus utiles en C++ 14. Soyez patient, nous y arriverons.
Votre question se résume à:
Comment écrire le constructeur de déplacement et l'opérateur d'affectation de déplacement pour cette classe?
Nous allons commencer avec le constructeur de déplacement.
Déplacer le constructeur
Notez que le membre mutex
a été créé mutable
. À strictement parler, cela n'est pas nécessaire pour les membres de déplacement, mais je suppose que vous voulez également copier les membres. Si ce n'est pas le cas, il n'est pas nécessaire de faire le mutex mutable
.
Lors de la construction de A
, vous n'avez pas besoin de verrouiller this->mut_
. Mais vous devez verrouiller le mut_
De l'objet à partir duquel vous construisez (déplacer ou copier). Cela peut être fait comme suit:
A(A&& a)
{
WriteLock rhs_lk(a.mut_);
field1_ = std::move(a.field1_);
field2_ = std::move(a.field2_);
}
Notez que nous avons dû construire par défaut les membres de this
d'abord, puis leur affecter des valeurs uniquement après que a.mut_
Est verrouillé.
Déplacer l'affectation
L'opérateur d'affectation de déplacement est beaucoup plus compliqué car vous ne savez pas si un autre thread accède à la gauche ou à la droite de l'expression d'affectation. Et en général, vous devez vous prémunir contre le scénario suivant:
// Thread 1
x = std::move(y);
// Thread 2
y = std::move(x);
Voici l'opérateur d'affectation de déplacement qui protège correctement le scénario ci-dessus:
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;
}
Notez que l'on doit utiliser std::lock(m1, m2)
pour verrouiller les deux mutex, au lieu de simplement les verrouiller l'un après l'autre. Si vous les verrouillez l'un après l'autre, alors lorsque deux threads affectent deux objets dans l'ordre opposé comme indiqué ci-dessus, vous pouvez obtenir un blocage. Le but de std::lock
Est d'éviter ce blocage.
Copier le constructeur
Vous n'avez pas posé de questions sur les membres de la copie, mais nous pourrions aussi bien en parler maintenant (sinon vous, quelqu'un en aura besoin).
A(const A& a)
{
ReadLock rhs_lk(a.mut_);
field1_ = a.field1_;
field2_ = a.field2_;
}
Le constructeur de copie ressemble beaucoup au constructeur de déplacement sauf que l'alias ReadLock
est utilisé à la place du WriteLock
. Actuellement, ces deux alias std::unique_lock<std::mutex>
Et donc cela ne fait pas vraiment de différence.
Mais en C++ 14, vous aurez la possibilité de dire ceci:
using MutexType = std::shared_timed_mutex;
using ReadLock = std::shared_lock<MutexType>;
using WriteLock = std::unique_lock<MutexType>;
Cela peut être une optimisation, mais pas définitivement. Vous devrez mesurer pour déterminer si c'est le cas. Mais avec ce changement, on peut copier la construction from les mêmes rhs dans plusieurs threads simultanément. La solution C++ 11 vous oblige à rendre ces threads séquentiels, même si le rhs n'est pas modifié.
Copier l'affectation
Pour être complet, voici l'opérateur d'affectation de copie, qui devrait être assez explicite après avoir lu tout le reste:
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;
}
Et etc.
Tous les autres membres ou fonctions libres qui accèdent à l'état de A
devront également être protégés si vous vous attendez à ce que plusieurs threads puissent les appeler à la fois. Par exemple, voici 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_);
}
}
Notez que si vous dépendez simplement de std::swap
Pour faire le travail, le verrouillage aura la mauvaise granularité, le verrouillage et le déverrouillage entre les trois mouvements que std::swap
Effectuerait en interne.
En effet, penser à swap
peut vous donner un aperçu de l'API dont vous pourriez avoir besoin pour fournir un "thread-safe" A
, qui en général sera différent d'un "non-thread-safe" "API, en raison du problème de" granularité de verrouillage ".
Notez également la nécessité de se protéger contre le "self-swap". "auto-échange" devrait être un no-op. Sans l'auto-vérification, on verrouillerait récursivement le même mutex. Cela pourrait également être résolu sans l'auto-vérification en utilisant std::recursive_mutex
Pour MutexType
.
Mise à jour
Dans les commentaires ci-dessous, Yakk est assez mécontent d'avoir à construire des choses par défaut dans les constructeurs de copie et de déplacement (et il a raison). Si vous vous sentez assez fort sur ce problème, à tel point que vous êtes prêt à y consacrer de la mémoire, vous pouvez l'éviter ainsi:
Ajoutez les types de verrous dont vous avez besoin en tant que membres de données. Ces membres doivent précéder les données protégées:
mutable MutexType mut_;
ReadLock read_lock_;
WriteLock write_lock_;
// ... other data members ...
Et puis dans les constructeurs (par exemple le constructeur de copie) faites ceci:
A(const A& a)
: read_lock_(a.mut_)
, field1_(a.field1_)
, field2_(a.field2_)
{
read_lock_.unlock();
}
Oups, Yakk a effacé son commentaire avant d'avoir eu la chance de terminer cette mise à jour. Mais il mérite le mérite d'avoir poussé ce problème et d'avoir trouvé une solution à cette réponse.
mise à jour 2
Et dyp est venu avec cette bonne suggestion:
A(const A& a)
: A(a, ReadLock(a.mut_))
{}
private:
A(const A& a, ReadLock rhs_lk)
: field1_(a.field1_)
, field2_(a.field2_)
{}
Étant donné qu'il ne semble pas y avoir de moyen agréable, propre et facile de répondre à cette question - la solution d'Anton I pensez est correcte mais elle est certainement discutable, à moins qu'une meilleure réponse n'apparaisse, je recommanderais de mettre une telle classe sur le tas et en prendre soin via un std::unique_ptr
:
auto a = std::make_unique<A>();
C'est maintenant un type entièrement mobile et toute personne qui a un verrou sur le mutex interne pendant un mouvement est toujours en sécurité, même si c'est discutable si c'est une bonne chose à faire
Si vous avez besoin de copier la sémantique, utilisez simplement
auto a2 = std::make_shared<A>();
Ceci est une réponse à l'envers. Au lieu d'incorporer "cet objet doit être synchronisé" comme base du type, injectez-le à la place sous tout type.
Vous traitez un objet synchronisé très différemment. Un gros problème est que vous devez vous soucier des blocages (verrouillage de plusieurs objets). Il ne devrait également en principe jamais être votre "version par défaut d'un objet": les objets synchronisés sont destinés aux objets qui seront en conflit, et votre objectif devrait être de minimiser les conflits entre les threads, et non de les balayer sous le tapis.
Mais la synchronisation des objets est toujours utile. Au lieu d'hériter d'un synchroniseur, nous pouvons écrire une classe qui encapsule un type arbitraire dans la synchronisation. Les utilisateurs doivent sauter à travers quelques cercles pour effectuer des opérations sur l'objet maintenant qu'il est synchronisé, mais ils ne sont pas limités à un ensemble limité d'opérations codées à la main sur l'objet. Ils peuvent composer plusieurs opérations sur l'objet en une seule, ou avoir une opération sur plusieurs objets.
Voici un wrapper synchronisé autour d'un type arbitraire 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)};
}
Fonctionnalités C++ 14 et C++ 1z incluses.
cela suppose que les opérations const
sont sécurisées pour plusieurs lecteurs (ce que supposent les conteneurs std
).
L'utilisation ressemble à:
synchronized<int> x = 7;
x.read([&](auto&& v){
std::cout << v << '\n';
});
pour un int
avec accès synchronisé.
Je déconseille d'avoir synchronized(synchronized const&)
. Il est rarement nécessaire.
Si vous avez besoin de synchronized(synchronized const&)
, je serais tenté de remplacer T t;
Par std::aligned_storage
, Permettant la construction d'un placement manuel et de faire une destruction manuelle. Cela permet une bonne gestion de la durée de vie.
Sauf cela, nous pourrions copier la source T
, puis y lire:
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);})
)
{}
pour affectation:
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);
}
les versions de placement et de stockage aligné sont un peu plus compliquées. La plupart des accès à t
seront remplacés par une fonction membre T&t()
et T const&t()const
, sauf lors de la construction où vous devrez sauter à travers certains cercles.
En faisant de synchronized
un wrapper au lieu d'une partie de la classe, tout ce que nous devons nous assurer, c'est que la classe respecte en interne const
comme étant à lecteurs multiples, et l'écrive de manière monothread.
Dans les rares cas nous avons besoin d'une instance synchronisée, nous sautons à travers des cerceaux comme ci-dessus.
Toutes mes excuses pour les fautes de frappe ci-dessus. Il y en a probablement quelques-uns.
Un avantage secondaire à ce qui précède est que les opérations arbitraires n-aires sur les objets synchronized
(du même type) fonctionnent ensemble, sans avoir à les coder en dur au préalable. Ajoutez une déclaration d'ami et les objets n-aire synchronized
de plusieurs types peuvent fonctionner ensemble. Il se peut que je doive déplacer access
hors de la fonction d'ami en ligne pour gérer les conflits de surcharge dans ce cas.
L'utilisation de mutex et de la sémantique de déplacement C++ est un excellent moyen de transférer efficacement et en toute sécurité des données entre les threads.
Imaginez un fil "producteur" qui crée des lots de chaînes et les fournit à (un ou plusieurs) consommateurs. Ces lots peuvent être représentés par un objet contenant des objets std::vector<std::string>
(Potentiellement volumineux). Nous voulons absolument "déplacer" l'état interne de ces vecteurs chez leurs consommateurs sans double emploi inutile.
Vous reconnaissez simplement le mutex comme faisant partie de l'objet et non comme faisant partie de l'état de l'objet. Autrement dit, vous ne voulez pas déplacer le mutex.
Le verrouillage dont vous avez besoin dépend de votre algorithme ou de la généralisation de vos objets et de la gamme d'utilisations que vous autorisez.
Si vous ne vous déplacez que d'un objet "producteur" d'état partagé vers un objet "consommateur" de thread local, vous pouvez peut-être verrouiller uniquement le déplacé de objet.
S'il s'agit d'une conception plus générale, vous devrez verrouiller les deux. Dans un tel cas, vous devez alors envisager un verrouillage à mort.
Si c'est un problème potentiel, utilisez std::lock()
pour acquérir des verrous sur les deux mutex de manière sans interblocage.
http://en.cppreference.com/w/cpp/thread/lock
Pour terminer, vous devez vous assurer de bien comprendre la sémantique du mouvement. Rappelez-vous que l'objet déplacé est laissé dans un état valide mais inconnu. Il est tout à fait possible qu'un thread n'effectuant pas le déplacement ait une raison valable pour tenter d'accéder à l'objet déplacé lorsqu'il peut trouver cet état valide mais inconnu.
Encore une fois, mon producteur claque des cordes et le consommateur enlève toute la charge. Dans ce cas, chaque fois que le producteur essaie d'ajouter au vecteur, il peut trouver le vecteur non vide ou vide.
En bref, si l'accès simultané potentiel à l'objet déplacé équivaut à une écriture, il est probable que cela soit OK. Si cela équivaut à une lecture, réfléchissez à la raison pour laquelle il est acceptable de lire un état arbitraire.
Tout d'abord, il doit y avoir un problème avec votre conception si vous souhaitez déplacer un objet contenant un mutex.
Mais si vous décidez de le faire de toute façon, vous devez créer un nouveau mutex dans le constructeur de déplacement, c'est-à-dire:
// movable
struct B{};
class A {
B b;
std::mutex m;
public:
A(A&& a)
: b(std::move(a.b))
// m is default-initialized.
{
}
};
Ceci est thread-safe, car le constructeur de déplacement peut supposer en toute sécurité que son argument n'est utilisé nulle part ailleurs, donc le verrouillage de l'argument n'est pas requis.