Nous avons constaté que nous avons plusieurs endroits dans notre code où les lectures simultanées de données protégées par un mutex sont plutôt courantes, tandis que les écritures sont rares. Nos mesures semblent indiquer que l'utilisation d'un simple mutex entrave sérieusement les performances du code lisant ces données. Donc, ce dont nous aurions besoin, c'est d'un mutex à lecture multiple/écriture unique. Je sais que cela peut être construit au sommet de primitives plus simples, mais avant de m'y essayer, je préfère demander les connaissances existantes:
Quelle est la méthode approuvée pour créer un verrou à lecture multiple/écriture unique à partir de primitives de synchronisation plus simples?
J'ai une idée de comment le faire, mais je préfère avoir des réponses impartiales par ce que j'ai (probablement à tort) trouvé. (Remarque: ce que j'attends est une explication sur la façon de le faire, probablement en pseudo-code, pas une implémentation à part entière. Je peux certainement écrire le code moi-même.)
Mises en garde:
Cela doit avoir des performances raisonnables. (Ce que j'ai en tête nécessiterait deux opérations de verrouillage/déverrouillage par accès. Maintenant, cela pourrait ne pas être suffisant, mais en avoir besoin de plusieurs à la place semble déraisonnable.)
Généralement, les lectures sont plus nombreuses, mais les écritures sont plus importantes et sensibles aux performances que les lectures. Les lecteurs ne doivent pas affamer les écrivains.
Nous sommes coincés sur une plate-forme embarquée plutôt ancienne (variante propriétaire de VxWorks 5.5), avec un compilateur assez ancien (GCC 4.1.2) et boost 1.52 - à l'exception de la plupart des parties de boost reposant sur POSIX, car POSIX n'est pas entièrement implémenté sur cette plate-forme. Les primitives de verrouillage disponibles sont essentiellement plusieurs types de sémaphores (binaires, comptage, etc.), au-dessus desquels nous avons déjà créé des mutex, des variables de conditions et des moniteurs.
C'est IA32, monocœur.
Il semble que vous n'ayez que mutex et condition_variable comme primitives de synchronisation. par conséquent, j'écris ici un verrou de lecture-écriture, qui affame les lecteurs. il utilise un mutex, deux conditional_variable et trois integer.
readers - readers in the cv readerQ plus the reading reader
writers - writers in cv writerQ plus the writing writer
active_writers - the writer currently writing. can only be 1 or 0.
Cela affame les lecteurs de cette façon. S'il y a plusieurs écrivains qui veulent écrire, les lecteurs n'auront jamais la chance de lire tant que tous les écrivains n'auront pas fini d'écrire. En effet, les lecteurs ultérieurs doivent vérifier la variable writers
. Dans le même temps, le active_writers
variable garantira qu'un seul écrivain pourrait écrire à la fois.
class RWLock {
public:
RWLock()
: shared()
, readerQ(), writerQ()
, active_readers(0), waiting_writers(0), active_writers(0)
{}
void ReadLock() {
std::unique_lock<std::mutex> lk(shared);
while( waiting_writers != 0 )
readerQ.wait(lk);
++active_readers;
lk.unlock();
}
void ReadUnlock() {
std::unique_lock<std::mutex> lk(shared);
--active_readers;
lk.unlock();
writerQ.notify_one();
}
void WriteLock() {
std::unique_lock<std::mutex> lk(shared);
++waiting_writers;
while( active_readers != 0 || active_writers != 0 )
writerQ.wait(lk);
++active_writers;
lk.unlock();
}
void WriteUnlock() {
std::unique_lock<std::mutex> lk(shared);
--waiting_writers;
--active_writers;
if(waiting_writers > 0)
writerQ.notify_one();
else
readerQ.notify_all();
lk.unlock();
}
private:
std::mutex shared;
std::condition_variable readerQ;
std::condition_variable writerQ;
int active_readers;
int waiting_writers;
int active_writers;
};
À première vue, je pensais avoir reconnu cette réponse comme le même algorithme qu'Alexander Terekhov a introduit. Mais après l'avoir étudié, je pense qu'il est défectueux. Il est possible que deux écrivains attendent simultanément sur m_exclusive_cond
. Lorsqu'un de ces auteurs se réveille et obtient le verrou exclusif, il place exclusive_waiting_blocked = false
Sur unlock
, mettant ainsi le mutex dans un état incohérent. Après cela, le mutex est probablement arrosé.
N2406 , dont la première proposition std::shared_mutex
Contient une implémentation partielle, qui est répétée ci-dessous avec une syntaxe mise à jour.
class shared_mutex
{
mutex mut_;
condition_variable gate1_;
condition_variable gate2_;
unsigned state_;
static const unsigned write_entered_ = 1U << (sizeof(unsigned)*CHAR_BIT - 1);
static const unsigned n_readers_ = ~write_entered_;
public:
shared_mutex() : state_(0) {}
// Exclusive ownership
void lock();
bool try_lock();
void unlock();
// Shared ownership
void lock_shared();
bool try_lock_shared();
void unlock_shared();
};
// Exclusive ownership
void
shared_mutex::lock()
{
unique_lock<mutex> lk(mut_);
while (state_ & write_entered_)
gate1_.wait(lk);
state_ |= write_entered_;
while (state_ & n_readers_)
gate2_.wait(lk);
}
bool
shared_mutex::try_lock()
{
unique_lock<mutex> lk(mut_, try_to_lock);
if (lk.owns_lock() && state_ == 0)
{
state_ = write_entered_;
return true;
}
return false;
}
void
shared_mutex::unlock()
{
{
lock_guard<mutex> _(mut_);
state_ = 0;
}
gate1_.notify_all();
}
// Shared ownership
void
shared_mutex::lock_shared()
{
unique_lock<mutex> lk(mut_);
while ((state_ & write_entered_) || (state_ & n_readers_) == n_readers_)
gate1_.wait(lk);
unsigned num_readers = (state_ & n_readers_) + 1;
state_ &= ~n_readers_;
state_ |= num_readers;
}
bool
shared_mutex::try_lock_shared()
{
unique_lock<mutex> lk(mut_, try_to_lock);
unsigned num_readers = state_ & n_readers_;
if (lk.owns_lock() && !(state_ & write_entered_) && num_readers != n_readers_)
{
++num_readers;
state_ &= ~n_readers_;
state_ |= num_readers;
return true;
}
return false;
}
void
shared_mutex::unlock_shared()
{
lock_guard<mutex> _(mut_);
unsigned num_readers = (state_ & n_readers_) - 1;
state_ &= ~n_readers_;
state_ |= num_readers;
if (state_ & write_entered_)
{
if (num_readers == 0)
gate2_.notify_one();
}
else
{
if (num_readers == n_readers_ - 1)
gate1_.notify_one();
}
}
L'algorithme est dérivé d'une ancienne publication de newsgroup d'Alexander Terekhov. Il ne meurt de faim ni de lecteurs ni d'écrivains.
Il y a deux "portes", gate1_
Et gate2_
. Les lecteurs et les écrivains doivent passer gate1_
Et peuvent être bloqués en essayant de le faire. Une fois qu'un lecteur a dépassé gate1_
, Il a verrouillé en lecture le mutex. Les lecteurs peuvent dépasser gate1_
Tant qu'il n'y a pas un nombre maximum de lecteurs propriétaires, et aussi longtemps qu'un écrivain n'a pas dépassé gate1_
.
Un seul écrivain à la fois peut dépasser gate1_
. Et un écrivain peut dépasser gate1_
Même si les lecteurs en sont propriétaires. Mais une fois passé gate1_
, Un écrivain n'a toujours pas la propriété. Il doit d'abord dépasser gate2_
. Un écrivain ne peut pas dépasser gate2_
Tant que tous les lecteurs propriétaires ne l'ont pas abandonné. Rappelez-vous que les nouveaux lecteurs ne peuvent pas dépasser gate1_
Pendant qu'un écrivain attend à gate2_
. Et un nouvel écrivain ne peut pas non plus dépasser gate1_
Pendant qu'un écrivain attend à gate2_
.
La caractéristique selon laquelle les lecteurs et les écrivains sont bloqués à gate1_
Avec des exigences (presque) identiques imposées pour le dépasser, est ce qui rend cet algorithme juste pour les lecteurs et les écrivains, ne mourant de faim ni l'un ni l'autre.
Le mutex "état" est intentionnellement gardé dans un seul mot afin de suggérer que l'utilisation partielle de l'atomique (comme optimisation) pour certains changements d'état est une possibilité (c'est-à-dire pour un "chemin rapide" non contraint). Cependant, cette optimisation n'est pas démontrée ici. Un exemple serait si un thread d'écrivain pouvait changer atomiquement state_
De 0 à write_entered
Alors il obtient le verrou sans avoir à bloquer ou même verrouiller/déverrouiller mut_
. Et unlock()
pourrait être implémenté avec un magasin atomique. Etc. Ces optimisations ne sont pas présentées ici car elles sont beaucoup plus difficiles à implémenter correctement que cette simple description ne le semble.
Les lectures simultanées de données protégées par un mutex sont plutôt courantes, tandis que les écritures sont rares
Cela ressemble à un scénario idéal pour RCU de l'espace utilisateur :
URCU est similaire à son homologue du noyau Linux, fournissant un remplacement pour le verrouillage du lecteur-écrivain, entre autres utilisations. Cette similitude persiste avec les lecteurs qui ne se synchronisent pas directement avec les mises à jour RCU, ce qui rend les chemins de code côté lecture RCU excessivement rapides, tout en permettant aux lecteurs RCU de faire des progrès utiles, même lorsqu'ils s'exécutent simultanément avec les mises à jour RCU, et vice versa.
Il y a de bonnes astuces pour vous aider.
Tout d'abord, bonnes performances. VxWorks est remarquable pour ses très bons temps de changement de contexte. Quelle que soit la solution de verrouillage que vous utilisez, elle impliquera probablement des sémaphores. Je n'aurais pas peur d'utiliser des sémaphores (pluriel) pour cela, ils sont assez bien optimisés dans VxWorks, et les temps de changement de contexte rapides aident à minimiser la dégradation des performances de l'évaluation de nombreux états de sémaphore, etc.
J'oublierais également d'utiliser les sémaphores POSIX, qui vont simplement être superposés aux propres sémaphores de VxWork. VxWorks fournit des comptages natifs, des sémaphores binaires et mutex; utiliser celui qui convient le rend un peu plus rapide. Les binaires peuvent parfois être très utiles; affiché à plusieurs reprises, ne jamais dépasser la valeur de 1.
Deuxièmement, l'écriture est plus importante que la lecture. Lorsque j'ai eu ce genre d'exigence dans VxWorks et que j'ai utilisé un sémaphore pour contrôler l'accès, j'ai utilisé la priorité des tâches pour indiquer quelle tâche est la plus importante et devrait obtenir le premier accès à la ressource. Cela fonctionne assez bien; littéralement, tout dans VxWorks est une tâche (enfin, thread) comme les autres, y compris tous les pilotes de périphérique, etc.
VxWorks résout également les inversions de priorité (le genre de chose que Linus Torvalds déteste). Donc, si vous implémentez votre verrouillage avec un ou des sémaphores, vous pouvez compter sur le planificateur du système d'exploitation pour dynamiser les lecteurs de priorité inférieure s'ils bloquent un écrivain de priorité supérieure. Cela peut conduire à un code beaucoup plus simple et vous tirez également le meilleur parti du système d'exploitation.
Donc ne solution potentielle est d'avoir un seul sémaphore de comptage VxWorks protégeant la ressource, initialisé à une valeur égale au nombre de lecteurs. Chaque fois qu'un lecteur veut lire, il prend le sémaphore (en réduisant le nombre de 1. Chaque fois qu'une lecture est effectuée, il affiche le sémaphore, augmentant le nombre de 1. Chaque fois que l'écrivain veut écrire, il prend le sémaphore n (n = nombre de lecteurs) fois, et l'affiche n fois une fois terminé. Enfin, donnez à la tâche d'écrivain une priorité plus élevée que n'importe quel lecteur, et comptez sur le temps de changement de contexte rapide du système d'exploitation et l'inversion de priorité.
N'oubliez pas que vous programmez sur un système d'exploitation en temps réel et non sur Linux. Prendre/publier un sémaphore VxWorks natif n'implique pas le même temps d'exécution qu'un acte similaire sur Linux, bien que même Linux soit assez bon de nos jours (j'utilise PREEMPT_RT de nos jours). Le planificateur VxWorks et tous les pilotes de périphériques peuvent être fiables pour se comporter. Vous pouvez même faire de votre tâche d'écrivain la priorité la plus élevée de tout le système si vous le souhaitez, plus élevée que tous les pilotes de périphérique!
Pour aider les choses, considérez également ce que font chacun de vos fils. VxWorks vous permet d'indiquer qu'une tâche utilise/n'utilise pas le FPU. Si vous utilisez des routines VxWorks TaskSpawn natives au lieu de pthread_create, vous avez la possibilité de le spécifier. Cela signifie que si votre thread/tâche ne fait aucun calcul en virgule flottante, et que vous l'avez dit comme tel dans votre appel à TaskSpawn, les temps de changement de contexte seront encore plus rapides car le planificateur ne prendra pas la peine de conserver/restaurer l'état du FPU.
Cela a une chance raisonnable d'être la meilleure solution sur la plate-forme sur laquelle vous développez. Il exploite les points forts du système d'exploitation (sémaphores rapides, temps de changement de contexte rapides) sans introduire une charge de code supplémentaire pour recréer une solution alternative (et peut-être plus élégante) couramment trouvée sur d'autres plates-formes.
Troisièmement, coincé avec l'ancien GCC et l'ancien Boost. Fondamentalement, je ne peux pas m'empêcher de suggérer à bas prix WindRiver et discuter de l'achat d'une mise à niveau. Personnellement, lorsque j'ai programmé pour VxWorks, j'ai utilisé l'API native de VxWork plutôt que POSIX. D'accord, le code n'est donc pas très portable, mais il a fini par être rapide; POSIX est simplement une couche au-dessus de l'API native de toute façon et cela ralentira toujours les choses.
Cela dit, les sémaphores de comptage et de mutex POSIX sont très similaires aux sémaphores de comptage et de mutex natifs de VxWork. Cela signifie probablement que la superposition POSIX n'est pas très épaisse.
Remarques générales sur la programmation pour VxWorks
Débogage J'ai toujours cherché à utiliser les outils de développement (Tornado) disponibles pour Solaris. C'est de loin le meilleur environnement de débogage multithread que j'ai jamais rencontré. Pourquoi? Il vous permet de démarrer plusieurs sessions de débogage, une pour chaque thread/tâche du système. Vous vous retrouvez avec une fenêtre de débogage par thread, et vous déboguez individuellement et indépendamment chacun. Passez sur une opération de blocage, cette fenêtre de débogage est bloquée. Déplacez le focus de la souris vers une autre fenêtre de débogage, passez par-dessus l'opération qui libérera le bloc et regardez la première fenêtre terminer son étape.
Vous vous retrouvez avec beaucoup de fenêtres de débogage, mais c'est de loin la meilleure façon de déboguer des trucs multithread. Cela a rendu très facile l'écriture de trucs vraiment assez complexes et la détection de problèmes. Vous pouvez facilement explorer les différentes interactions dynamiques dans votre application, car vous avez à tout moment un contrôle simple et puissant sur ce que fait chaque thread.
Ironiquement, la version Windows de Tornado ne vous a pas laissé faire cela; une seule misérable fenêtre de débogage par système, comme toute autre vieille ennuyeuse IDE comme Visual Studio, etc. Je n'ai jamais vu même les IDE modernes se rapprocher de Tornado sur Solaris) pour le débogage multithread.
HardDrives Si vos lecteurs et écrivains utilisent des fichiers sur disque, considérez que VxWorks 5.5 est assez ancien. Des choses comme NCQ ne seront pas prises en charge. Dans ce cas, ma solution proposée (décrite ci-dessus) pourrait être mieux réalisée avec un seul sémaphore mutex pour empêcher plusieurs lecteurs de se trébucher les uns les autres dans leur lutte pour lire différentes parties du disque. Cela dépend de ce que font exactement vos lecteurs, mais s'ils lisent des données contiguës à partir d'un fichier, cela évitera de heurter la tête de lecture/écriture de long en large sur la surface du disque (très lent).
Dans mon cas, j'utilisais cette astuce pour façonner le trafic sur une interface réseau; chaque tâche envoyait un type de données différent et la priorité de la tâche reflétait la priorité des données sur le réseau. C'était très élégant, aucun message n'a jamais été fragmenté, mais les messages importants ont obtenu la part des lions de la bande passante disponible.
Comme toujours, la meilleure solution dépendra des détails. n verrou tournant en lecture-écriture peut être ce que vous recherchez , mais d'autres approches telles que la mise à jour en lecture-copie comme suggéré ci-dessus peuvent être une solution - bien que sur une ancienne plate-forme intégrée, la mémoire supplémentaire utilisée pourrait être un problème. Avec de rares écritures, j'organise souvent le travail à l'aide d'un système de tâches de telle sorte que les écritures ne peuvent se produire que s'il n'y a pas de lecture à partir de cette structure de données, mais cela dépend de l'algorithme.
Un algorithme basé sur les sémaphores et les mutex est décrit dans Contrôle simultané avec les lecteurs et les écrivains ; PJ Courtois, F. Heymans et DL Parnas; Laboratoire de recherche MBLE; Bruxelles, Belgique .
Ceci est une réponse simplifiée basée sur mes en-têtes Boost (j'appellerais Boost une manière approuvée ). Il ne nécessite que des variables de condition et des mutex. Je l'ai réécrit en utilisant des primitives Windows parce que je les trouve descriptives et très simples, mais je considère cela comme un pseudocode.
Il s'agit d'une solution très simple, qui ne prend pas en charge des éléments tels que la mise à niveau mutex ou les opérations try_lock (). Je peux les ajouter si vous le souhaitez. J'ai également supprimé quelques fioritures comme la désactivation des interruptions qui ne sont pas strictement nécessaires.
En outre, cela vaut la peine de consulter boost\thread\pthread\shared_mutex.hpp
(ceci étant basé sur cela). C'est lisible par l'homme.
class SharedMutex {
CRITICAL_SECTION m_state_mutex;
CONDITION_VARIABLE m_shared_cond;
CONDITION_VARIABLE m_exclusive_cond;
size_t shared_count;
bool exclusive;
// This causes write blocks to prevent further read blocks
bool exclusive_wait_blocked;
SharedMutex() : shared_count(0), exclusive(false)
{
InitializeConditionVariable (m_shared_cond);
InitializeConditionVariable (m_exclusive_cond);
InitializeCriticalSection (m_state_mutex);
}
~SharedMutex()
{
DeleteCriticalSection (&m_state_mutex);
DeleteConditionVariable (&m_exclusive_cond);
DeleteConditionVariable (&m_shared_cond);
}
// Write lock
void lock(void)
{
EnterCriticalSection (&m_state_mutex);
while (shared_count > 0 || exclusive)
{
exclusive_waiting_blocked = true;
SleepConditionVariableCS (&m_exclusive_cond, &m_state_mutex, INFINITE)
}
// This thread now 'owns' the mutex
exclusive = true;
LeaveCriticalSection (&m_state_mutex);
}
void unlock(void)
{
EnterCriticalSection (&m_state_mutex);
exclusive = false;
exclusive_waiting_blocked = false;
LeaveCriticalSection (&m_state_mutex);
WakeConditionVariable (&m_exclusive_cond);
WakeAllConditionVariable (&m_shared_cond);
}
// Read lock
void lock_shared(void)
{
EnterCriticalSection (&m_state_mutex);
while (exclusive || exclusive_waiting_blocked)
{
SleepConditionVariableCS (&m_shared_cond, m_state_mutex, INFINITE);
}
++shared_count;
LeaveCriticalSection (&m_state_mutex);
}
void unlock_shared(void)
{
EnterCriticalSection (&m_state_mutex);
--shared_count;
if (shared_count == 0)
{
exclusive_waiting_blocked = false;
LeaveCriticalSection (&m_state_mutex);
WakeConditionVariable (&m_exclusive_cond);
WakeAllConditionVariable (&m_shared_cond);
}
else
{
LeaveCriticalSection (&m_state_mutex);
}
}
};
D'accord, il y a une certaine confusion sur le comportement de cet algorithme, alors voici comment cela fonctionne.
Pendant un verrouillage d'écriture - Les lecteurs et les écrivains sont bloqués.
A la fin d'un verrouillage d'écriture - Les threads de lecture et un thread d'écriture feront la course pour voir lequel commence.
Pendant un verrou en lecture - Les écrivains sont bloqués. Les lecteurs sont également bloqués si et seulement si un Writer est bloqué.
A la sortie du dernier verrou de lecture - Les threads de lecture et un thread d'écriture feront la course pour voir lequel commence.
Cela pourrait amener les lecteurs à affamer les écrivains si le processeur passe fréquemment du contexte à un m_shared_cond
thread avant un m_exclusive_cond
lors de la notification, mais je soupçonne que ce problème est théorique et non pratique car il s'agit de l'algorithme de Boost.
Maintenant que Microsoft a ouvert le code source .NET, vous pouvez regarder leur implémentation ReaderWRiterLockSlim .
Je ne suis pas sûr que les primitives les plus élémentaires qu'ils utilisent soient à votre disposition, certaines d'entre elles font également partie de la bibliothèque .NET et leur code est également disponible.
Microsoft a consacré beaucoup de temps à améliorer les performances de ses mécanismes de verrouillage, ce qui peut donc être un bon point de départ.