list :: empty () comportement multi-thread?

9

J'ai une liste dont je veux que différents threads récupèrent des éléments. Afin d'éviter de verrouiller le mutex gardant la liste lorsqu'elle est vide, je vérifie empty()avant de verrouiller.

Ce n'est pas grave si l'appel list::empty()n'est pas correct dans 100% des cas. Je veux seulement éviter de planter ou de perturber les appels list::push()et les list::pop()appels simultanés .

Suis-je sûr de supposer que VC ++ et Gnu GCC ne se tromperont que parfois empty()et rien de pire?

if(list.empty() == false){ // unprotected by mutex, okay if incorrect sometimes
    mutex.lock();
    if(list.empty() == false){ // check again while locked to be certain
         element = list.back();
         list.pop_back();
    }
    mutex.unlock();
}
Anne Quinn
la source
1
Non, vous ne pouvez pas supposer cela. Vous pouvez utiliser un conteneur simultané comme concurrent_queue
Panagiotis Kanavos
2
@Fureeish Cela devrait être une réponse. J'ajouterais que cela std::list::sizea garanti une complexité temporelle constante, ce qui signifie essentiellement que la taille (nombre de nœuds) doit être stockée dans une variable distincte; appelons cela size_. std::list::emptypuis renvoie probablement quelque chose comme size_ == 0, et la lecture et l'écriture simultanées size_provoqueraient la course aux données, donc UB.
Daniel Langr
@DanielLangr Comment le "temps constant" est-il mesuré? S'agit-il d'un seul appel de fonction ou du programme complet?
curiousguy
1
@curiousguy: DanielLangr a répondu à votre question par "indépendamment du nombre de nœuds de liste", c'est-à-dire la définition exacte de O (1), ce qui signifie que chaque appel est exécuté en moins de temps constant, quel que soit le nombre d'éléments. en.wikipedia.org/wiki/Big_O_notation#Orders_of_common_functions L'autre option (jusqu'à C ++ 11) serait linéaire = O (n) ce qui signifie que cette taille devrait compter les éléments (liste chaînée), ce qui serait encore pire pour la concurrence (course de données plus évidente que la lecture / écriture non atomique sur le compteur).
firda
1
@curiousguy: En prenant votre propre exemple avec dV, la complexité temporelle est la même mathématique - limites. Toutes ces choses sont soit définies de manière récursive, soit sous la forme "Il existe C tel que f (N) <C pour chaque N" - c'est la définition de O (1) (pour donné / chaque HW il existe une constante C telle que l'algo se termine en moins de temps C sur n'importe quelle entrée). Amorti signifie en moyenne , ce qui signifie que certaines entrées peuvent prendre plus de temps à traiter (par exemple, re-hachage / réallocation nécessaire), mais il est toujours constant en moyenne (en supposant toutes les entrées possibles).
firda

Réponses:

10

Ce n'est pas grave si l'appel list::empty()n'est pas correct dans 100% des cas.

Non, ça ne va pas. Si vous vérifiez si la liste est vide en dehors d'un mécanisme de synchronisation (verrouillage du mutex), vous avez une course aux données. Avoir une course aux données signifie que vous avez un comportement indéfini. Avoir un comportement indéfini signifie que nous ne pouvons plus raisonner sur le programme et toute sortie que vous obtenez est "correcte".

Si vous appréciez votre santé mentale, vous prendrez le coup de la performance et verrouillerez le mutex avant de vérifier. Cela dit, la liste n'est peut-être même pas le bon conteneur pour vous. Si vous pouvez nous dire exactement ce que vous en faites, nous pourrons peut-être vous proposer un meilleur conteneur.

NathanOliver
la source
Perspective personnelle, l'appel list::empty()est une action de lecture qui n'a rien à voir avecrace-condition
Ngọc Khánh Nguyễn
3
@ NgọcKhánhNguyễn S'ils ajoutent des éléments dans la liste, cela provoque certainement une course aux données lorsque vous écrivez et lisez la taille en même temps.
NathanOliver
6
@ NgọcKhánhNguyễn C'est faux. Une condition de concurrence est read-writeou write-write. Si vous ne me croyez pas, lisez la section standard sur les courses de données
NathanOliver
1
@ NgọcKhánhNguyễn: Parce que ni l'écriture ni la lecture ne sont garanties d'être atomiques, donc peuvent être exécutées simultanément, donc la lecture peut obtenir quelque chose de totalement faux (appelé lecture déchirée). Imaginez que l'écriture change 0x00FF en 0x0100 dans un petit MCU endian 8 bits, en commençant par réécrire le faible 0xFF en 0x00 et la lecture obtient maintenant exactement ce zéro, en lisant les deux octets (écriture ralentie ou suspendue), l'écriture se poursuit en mettant à jour l'octet haut en 0x01, mais le thread de lecture a déjà une mauvaise valeur (ni 0x00FF, ni 0x0100 mais 0x0000 inattendu).
firda
1
@ NgọcKhánhNguyễn Cela peut sur certaines architectures mais la machine virtuelle C ++ n'offre pas une telle garantie. Même si votre matériel le faisait, il serait légal pour le compilateur d'optimiser le code d'une manière que vous ne verriez jamais de changement car à moins qu'il y ait une synchronisation de thread, il peut supposer qu'il n'exécute qu'un seul thread et l'optimiser en conséquence.
NathanOliver
6

Il y a une lecture et une écriture (très probablement pour le sizemembre de std::list, si nous supposons qu'il est nommé ainsi) qui ne sont pas synchronisées entre elles . Imaginez qu'un fil appelle empty()(dans votre extérieur if()) tandis que l'autre fil entre dans l'intérieur if()et s'exécute pop_back(). Vous lisez alors une variable en cours de modification. Il s'agit d'un comportement indéfini.

Fureeish
la source
2

Comme exemple de la façon dont les choses pourraient mal tourner:

Un compilateur suffisamment intelligent pourrait voir que mutex.lock()cela ne peut pas changer la list.empty()valeur de retour et ainsi ignorer ifcomplètement la vérification interne , ce qui conduirait finalement à un pop_backsur une liste dont le dernier élément aurait été supprimé après le premier if.

Pourquoi peut-il faire ça? Il n'y a pas de synchronisation list.empty(), donc si elle était modifiée simultanément, cela constituerait une course aux données. La norme dit que les programmes ne doivent pas avoir de races de données, donc le compilateur prendra cela pour acquis (sinon il ne pourrait effectuer quasiment aucune optimisation). Par conséquent, il peut supposer une perspective monothread sur le non synchronisé list.empty()et conclure qu'il doit rester constant.

Il ne s'agit que d'une parmi plusieurs optimisations (ou comportements matériels) susceptibles de casser votre code.

Max Langhof
la source
Les compilateurs actuels ne semblent même pas vouloir optimiser a.load()+a.load()...
curiousguy
1
@curiousguy Comment souhaiteriez-vous que cela soit optimisé? Vous demandez une cohérence séquentielle complète là-bas, donc vous obtiendrez cela ...
Max Langhof
@MaxLanghof Vous ne pensez pas que l'optimisation a.load()*2est évidente? Même pas a.load(rel)+b.load(rel)-a.load(rel)optimisé de toute façon. Rien n'est. Pourquoi vous attendez-vous à ce que les verrous (qui, par essence, ont principalement une cohérence seq) soient plus optimisés?
curiousguy
@curiousguy Parce que l'ordre de la mémoire des accès non atomiques (ici avant et après le verrou) et atomiques sont entièrement différents? Je ne m'attends pas à ce que le verrou soit optimisé "plus", je m'attends à ce que les accès non synchronisés soient optimisés plus que les accès séquentiellement cohérents. La présence de la serrure est sans importance pour mon propos. Et non, le compilateur ne peut pas optimiser a.load() + a.load()à 2 * a.load(). N'hésitez pas à poser une question à ce sujet si vous souhaitez en savoir plus.
Max Langhof,
@MaxLanghof Je n'ai aucune idée de ce que vous essayez même de dire. Les verrous sont essentiellement séquentiellement cohérents. Pourquoi l'implémentation essaierait-elle de faire des optimisations sur certaines primitives de thread (verrous) et pas sur d'autres (atomiques)? Vous attendez-vous à ce que les accès non atomiques soient optimisés autour des utilisations de l'atomique?
curiousguy