J'ai implémenté la classe SpinLock, comme suit
struct Node {
int number;
std::atomic_bool latch;
void add() {
lock();
number++;
unlock();
}
void lock() {
bool unlatched = false;
while(!latch.compare_exchange_weak(unlatched, true, std::memory_order_acquire));
}
void unlock() {
latch.store(false , std::memory_order_release);
}
};
J'ai implémenté la classe ci-dessus et créé deux threads qui appellent la méthode add () d'une même instance de la classe Node 10 millions de fois par thread.
le résultat n'est malheureusement pas de 20 millions. Qu'est-ce que j'oublie ici?
Le problème est que compare_exchange_weak
met à jour la variable unlatched
en cas d'échec. D'après la documentation de compare_exchange_weak
:
Compare le contenu de la valeur contenue de l'objet atomique avec la valeur attendue: - si vrai, il remplace la valeur contenue par val (comme store). - si faux, il remplace attendu par la valeur contenue.
C'est-à-dire, après le premier échec compare_exchange_weak
, unlatched
sera mis à jour vers true
, donc la prochaine itération de la boucle essaiera de compare_exchange_weak
true
avec true
. Cela réussit et vous venez de prendre un verrou qui était détenu par un autre thread.
Solution: assurez-vous de remettre unlatched
sur false
avant chaque compare_exchange_weak
, par exemple.:
while(!latch.compare_exchange_weak(unlatched, true, std::memory_order_acquire)) {
unlatched = false;
}
Comme mentionné par @gexicide, le problème est que les fonctions compare_exchange
Mettent à jour la variable expected
avec la valeur actuelle de la variable atomique. C'est aussi la raison pour laquelle vous devez utiliser la variable locale unlatched
en premier lieu. Pour résoudre ce problème, vous pouvez redéfinir unlatched
sur false dans chaque itération de boucle.
Cependant, au lieu d'utiliser compare_exchange
Pour quelque chose que son interface est plutôt mal adaptée, il est beaucoup plus simple d'utiliser std::atomic_flag
À la place:
class SpinLock {
std::atomic_flag locked = ATOMIC_FLAG_INIT ;
public:
void lock() {
while (locked.test_and_set(std::memory_order_acquire)) { ; }
}
void unlock() {
locked.clear(std::memory_order_release);
}
};
Source: cppreference
Spécifier manuellement l'ordre de la mémoire n'est qu'un Tweak de performance potentielle mineur, que j'ai copié à partir de la source. Si la simplicité est plus importante que le dernier bit de performance, vous pouvez vous en tenir aux valeurs par défaut et appeler simplement locked.test_and_set() / locked.clear()
.
Btw .: std::atomic_flag
Est le seul type qui est garanti sans verrouillage, bien que je ne connaisse aucune plate-forme, où les opérations sur std::atomic_bool
Ne sont pas sans verrouillage.
Mise à jour: Comme expliqué dans les commentaires de @David Schwartz, @Anton et @Technik Empire, la boucle vide a des effets indésirables comme la mauvaise prédiction de branche, la famine de threads sur les processeurs HT et une consommation d'énergie trop élevée - donc en bref, c'est une façon assez inefficace d'attendre. L'impact et la solution sont spécifiques à l'architecture, à la plate-forme et à l'application. Je ne suis pas un expert, mais la solution habituelle semble être d'ajouter une cpu_relax()
sur linux ou YieldProcessor()
sur windows au corps de la boucle.
EDIT2: Juste pour être clair: la version portable présentée ici (sans les instructions spéciales cpu_relax etc.) devrait déjà être assez bonne pour de nombreuses applications. Si votre SpinLock
tourne beaucoup parce que quelqu'un d'autre tient le verrou depuis longtemps (ce qui pourrait déjà indiquer un problème de conception général), il est probablement préférable d'utiliser un mutex normal de toute façon.