Une "construction universelle" est une classe wrapper pour un objet séquentiel qui permet de le linéariser (une condition de cohérence forte pour les objets concurrents). Par exemple, voici une construction adaptée sans attente, en Java, de [1], qui suppose l'existence d'une file d'attente sans attente qui satisfait l'interface WFQ
(qui ne nécessite qu'un consensus unique entre les threads) et suppose une Sequential
interface:
public interface WFQ<T> // "FIFO" iteration
{
int enqueue(T t); // returns the sequence number of t
Iterable<T> iterateUntil(int max); // iterates until sequence max
}
public interface Sequential
{
// Apply an invocation (method + arguments)
// and get a response (return value + state)
Response apply(Invocation i);
}
public interface Factory<T> { T generate(); } // generate new default object
public interface Universal extends Sequential {}
public class SlowUniversal implements Universal
{
Factory<? extends Sequential> generator;
WFQ<Invocation> wfq = new WFQ<Invocation>();
Universal(Factory<? extends Sequential> g) { generator = g; }
public Response apply(Invocation i)
{
int max = wfq.enqueue(i);
Sequential s = generator.generate();
for(Invocation invoc : wfq.iterateUntil(max))
s.apply(invoc);
return s.apply(i);
}
}
Cette implémentation n'est pas très satisfaisante car elle est vraiment lente (vous vous souvenez de chaque appel, et vous devez la rejouer à chaque application - nous avons un temps d'exécution linéaire dans la taille de l'historique). Existe-t-il un moyen d'étendre les interfaces WFQ
et Sequential
(de manière raisonnable) pour nous permettre d'enregistrer certaines étapes lors de l'application d'un nouvel appel?
Pouvons-nous rendre cela plus efficace (pas d'exécution linéaire dans la taille de l'historique, de préférence l'utilisation de la mémoire diminue également) sans perdre la propriété sans attente?
Clarification
Une "construction universelle" est un terme qui, j'en suis sûr, a été inventé par [1] qui accepte un objet thread-unsafe mais compatible thread, qui est généralisé par l' Sequential
interface. À l'aide d'une file d'attente sans attente, la première construction offre une version thread-safe, linéarisable de l'objet qui est également sans attente (cela suppose un déterminisme et des apply
opérations d' arrêt ).
Cela est inefficace, car la méthode consiste à faire démarrer chaque thread local à partir d'une table blanche et à lui appliquer toutes les opérations jamais enregistrées. Dans tous les cas, cela fonctionne car il réalise la synchronisation efficacement en utilisant le WFQ
pour déterminer l'ordre dans lequel toutes les opérations doivent être appliquées: chaque thread appelant apply
verra le même Sequential
objet local , avec la même séquence de Invocation
s qui lui sera appliquée.
Ma question est de savoir si nous pouvons (par exemple) introduire un processus de nettoyage en arrière-plan qui met à jour "l'état de départ" afin que nous n'ayons pas à redémarrer à partir de zéro. Ce n'est pas aussi simple que d'avoir un pointeur atomique avec un pointeur de départ - ces types d'approches perdent facilement la garantie sans attente. Je soupçonne qu'une autre approche basée sur la file d'attente pourrait fonctionner ici.
Jargon:
- sans attente - quel que soit le nombre de threads ou la prise de décision du planificateur,
apply
se terminera par un nombre limité d'instructions exécutables pour ce thread. - sans verrou - comme ci-dessus, mais admet la possibilité d'un temps d'exécution illimité, uniquement dans le cas où un nombre illimité d'
apply
opérations sont effectuées dans d'autres threads. En règle générale, les schémas de synchronisation optimistes entrent dans cette catégorie. - blocage - efficacité à la merci de l'ordonnanceur.
Un exemple de travail, comme demandé (maintenant sur une page qui n'expirera pas)
[1] Herlihy et Shavit, L'art de la programmation multiprocesseur .
CopyableSequential
soient valides - la linéarisation devrait alors découler du fait qu'elle l'estSequential
.Réponses:
Voici une explication et un exemple de la façon dont cela est accompli. Faites-moi savoir s'il y a des parties qui ne sont pas claires.
Gist avec source
Universel
Initialisation:
Les index de threads sont appliqués de manière atomiquement incrémentée. Ceci est géré à l'aide d'un
AtomicInteger
nomménextIndex
. Ces index sont affectés aux threads via uneThreadLocal
instance qui s'initialise en récupérant l'index suivantnextIndex
et en l'incrémentant. Cela se produit la première fois que l'index de chaque thread est récupéré la première fois. UnThreadLocal
est créé pour suivre la dernière séquence créée par ce thread. Il est initialisé à 0. La référence d'objet de fabrique séquentielle est transmise et stockée. DeuxAtomicReferenceArray
instances sont créées de taillen
. L'objet de queue est affecté à chaque référence, après avoir été initialisé avec l'état initial fourni par l'Sequential
usine.n
est le nombre maximal de threads autorisés. Chaque élément de ces tableaux «appartient» à l'index de thread correspondant.Appliquer la méthode:
C'est la méthode qui fait le travail intéressant. Il fait ce qui suit:
Ensuite, la boucle de séquençage commence. Elle se poursuivra jusqu'à ce que l'invocation en cours soit séquencée:
decideNext()
La clé de la boucle imbriquée décrite ci-dessus est la
decideNext()
méthode. Pour comprendre cela, nous devons regarder la classe Node.Classe de noeud
Cette classe spécifie les nœuds dans une liste à double liaison. Il n'y a pas beaucoup d'action dans cette classe. La plupart des méthodes sont de simples méthodes de récupération qui devraient être assez explicites.
méthode de la queue
cela renvoie une instance de nœud spéciale avec une séquence de 0. Elle agit simplement comme un espace réservé jusqu'à ce qu'une invocation la remplace.
Propriétés et initialisation
seq
: le numéro de séquence, initialisé à -1 (signifiant non séquencé)invocation
: la valeur de l'invocation deapply()
. Mis en construction.next
:AtomicReference
pour la liaison directe. une fois attribué, cela ne sera jamais changéprevious
:AtomicReference
pour le lien arrière attribué lors du séquençage et effacé partruncate()
Décider ensuite
Cette méthode n'est qu'une dans Node avec une logique non triviale. En résumé, un nœud est proposé comme candidat pour être le nœud suivant dans la liste chaînée. La
compareAndSet()
méthode vérifiera si sa référence est nulle et si oui, définissez la référence au candidat. Si la référence est déjà définie, elle ne fait rien. Cette opération est atomique donc si deux candidats sont proposés en même temps, un seul sera sélectionné. Cela garantit qu'un seul nœud sera jamais sélectionné comme prochain. Si le nœud candidat est sélectionné, sa séquence est définie sur la valeur suivante et son lien précédent est défini sur ce nœud.Retour à la méthode d'application de la classe universelle ...
Après avoir appelé
decideNext()
le dernier nœud séquencé (lorsqu'il est vérifié) avec notre nœud ou un nœud de laannounce
baie, il y a deux occurrences possibles: 1. Le nœud a été séquencé avec succès 2. Un autre thread a anticipé ce thread.L'étape suivante consiste à vérifier si le nœud créé pour cette invocation. Cela peut se produire parce que ce thread l'a séquencé avec succès ou qu'un autre thread l'a récupéré du
announce
tableau et l'a séquencé pour nous. S'il n'a pas été séquencé, le processus est répété. Sinon, l'appel se termine en effaçant le tableau d'annonces de l'index de ce thread et en renvoyant la valeur de résultat de l'invocation. Le tableau d'annonce est effacé pour garantir qu'il n'y a aucune référence au nœud laissé autour qui empêcherait le nœud d'être récupéré et donc garder tous les nœuds de la liste liée à partir de ce point en vie sur le tas.Évaluer la méthode
Maintenant que le nœud de l'invocation a été correctement séquencé, l'invocation doit être évaluée. Pour ce faire, la première étape consiste à s'assurer que les invocations précédant celle-ci ont été évaluées. S'ils n'ont pas ce fil, ils n'attendront pas, mais ils le feront immédiatement.
Méthode EnsurePrior
La
ensurePrior()
méthode effectue ce travail en vérifiant le nœud précédent dans la liste chaînée. Si son état n'est pas défini, le nœud précédent sera évalué. Nœud que c'est récursif. Si le nœud avant le nœud précédent n'a pas été évalué, il appellera évaluer pour ce nœud et ainsi de suite.Maintenant que le nœud précédent est connu pour avoir un état, nous pouvons évaluer ce nœud. Le dernier nœud est récupéré et affecté à une variable locale. Si cette référence est nulle, cela signifie qu'un autre thread a anticipé celui-ci et a déjà évalué ce nœud; mettre son état. Sinon, l'état du nœud précédent est transmis à la
Sequential
méthode apply de l' objet avec l'invocation de ce nœud. L'état renvoyé est défini sur le nœud et latruncate()
méthode est appelée, supprimant le lien arrière du nœud car il n'est plus nécessaire.Méthode MoveForward
La méthode Move Forward tentera de déplacer toutes les références de tête vers ce nœud si elles ne pointent pas déjà vers quelque chose plus loin. Cela permet de garantir que si un thread cesse d'appeler, sa tête ne conservera pas de référence à un nœud qui n'est plus nécessaire. La
compareAndSet()
méthode s'assurera que nous ne mettons à jour le nœud que si un autre thread ne l'a pas modifié depuis qu'il a été récupéré.Annoncer la baie et aider
La clé pour rendre cette approche sans attente par opposition à simplement sans verrouillage est que nous ne pouvons pas supposer que le planificateur de threads donnera la priorité à chaque thread quand il en aura besoin. Si chaque thread tente simplement de séquencer ses propres nœuds, il est possible qu'un thread soit continuellement préempté sous charge. Pour tenir compte de cette possibilité, chaque thread essaiera d'abord d'aider les autres threads qui ne pourront peut-être pas être séquencés.
L'idée de base est que chaque thread créant avec succès des nœuds, les séquences affectées augmentent de façon monotone. Si un thread ou des threads préemptent continuellement un autre thread, l'index utilisé pour rechercher les nœuds non séquencés dans le
announce
tableau avancera. Même si chaque thread qui essaie actuellement de séquencer un nœud donné est continuellement préempté par un autre thread, tous les threads tenteront éventuellement de séquencer ce nœud. Pour illustrer, nous allons construire un exemple avec trois threads.Au point de départ, la tête et les éléments d'annonce des trois threads sont pointés vers le
tail
nœud. LelastSequence
pour chaque thread est 0.À ce stade, le thread 1 est exécuté avec une invocation. Il vérifie le tableau d'annonce pour sa dernière séquence (zéro) qui est le nœud qu'il est actuellement programmé pour indexer. Il séquence le nœud et il
lastSequence
est défini sur 1.Le thread 2 est maintenant exécuté avec une invocation, il vérifie le tableau d'annonces à sa dernière séquence (zéro) et voit qu'il n'a pas besoin d'aide et tente donc de séquencer son invocation. Il réussit et maintenant il
lastSequence
est réglé sur 2.Le thread 3 est maintenant exécuté et il voit également que le nœud à
announce[0]
est déjà séquencé et séquence sa propre invocation. IllastSequence
est désormais réglé sur 3.Le thread 1 est à nouveau invoqué. Il vérifie le tableau d'annonces à l'index 1 et constate qu'il est déjà séquencé. Simultanément, le thread 2 est appelé. Il vérifie le tableau d'annonces à l'index 2 et constate qu'il est déjà séquencé. Les deux Discussion 1 et Discussion 2 tentent maintenant de séquencer leurs propres noeuds. Le thread 2 gagne et il séquence son invocation. Il
lastSequence
est défini sur 4. Pendant ce temps, le thread trois a été appelé. Il vérifie l'indexlastSequence
(mod 3) et constate que le nœud àannounce[0]
n'a pas été séquencé. Le thread 2 est à nouveau invoqué en même temps que le thread 1 en est à sa deuxième tentative. Fil 1trouve une invocation non séquencée àannounce[1]
laquelle se trouve le nœud qui vient d'être créé par le thread 2 . Il tente de séquencer l' appel de Thread 2 et réussit. Le thread 2 trouve son propre nœudannounce[1]
et il a été séquencé. Il est réglélastSequence
sur 5. Le thread 3 est ensuite appelé et trouve que le nœud sur lequel le thread 1 est placéannounce[0]
n'est toujours pas séquencé et tente de le faire. Pendant ce temps, Thread 2 a également été invoqué et préempte Thread 3. Il séquence son nœud et le définitlastSequence
sur 6.Mauvais fil 1 . Même si Thread 3 essaie de le séquencer, les deux threads ont été continuellement contrecarrés par le planificateur. Mais à ce stade. Le thread 2 pointe également vers
announce[0]
(6 mod 3). Les trois threads sont définis pour tenter de séquencer le même appel. Quel que soit le thread qui réussit, le prochain nœud à séquencer sera l'invocation en attente du thread 1, c'est-à-dire le nœud référencé parannounce[0]
.C'est inévitable. Pour que les threads soient anticipés, les autres threads doivent être des nœuds de séquençage et, ce faisant, ils avanceront continuellement
lastSequence
. Si le nœud d'un thread donné n'est pas continuellement séquencé, tous les threads pointeront finalement vers son index dans le tableau d'annonce. Aucun thread ne fera quoi que ce soit tant que le noeud qu'il essaie d'aider n'a pas été séquencé, le pire des cas est que tous les threads pointent vers le même noeud non séquencé. Par conséquent, le temps requis pour séquencer toute invocation est fonction du nombre de threads et non de la taille de l'entrée.la source
previous
etnext
pour être valide. Il est difficile de maintenir et de créer un historique valide sans attente.Ma réponse précédente ne répond pas vraiment à la question correctement, mais comme le PO la juge utile, je la laisse telle quelle. Basé sur le code dans le lien dans la question, voici ma tentative. Je n'ai fait que des tests vraiment basiques à ce sujet, mais il semble calculer correctement les moyennes. Les commentaires sont les bienvenus pour savoir si cela est correctement sans attente.
REMARQUE : j'ai supprimé l'interface universelle et en ai fait une classe. Faire en sorte qu'Universal soit composé de séquences et en être un semble une complication inutile, mais il me manque peut-être quelque chose. Dans la classe moyenne, j'ai marqué la variable d'état comme étant
volatile
. Ce n'est pas nécessaire pour faire fonctionner le code. Pour être prudent (une bonne idée avec le filetage) et empêcher chaque fil de faire tous les calculs (une fois).Séquentiel et usine
Universel
Moyenne
Code démo
J'ai apporté quelques modifications au code pendant que je le publiais ici. Ça devrait aller, mais faites-moi savoir si vous avez des problèmes avec ça.
la source
wfq
, vous devez donc parcourir toute l'historique - le temps d'exécution ne s'est amélioré que d'un facteur constant.