Aller au contenu
English

Travail en parallèle

  • Leçon 13
  • 45 min
  • Module 5 : Plusieurs exécutants et intégrations

Ce que vous saurez faire

Exécutez des lectures indépendantes en même temps, puis fusionnez leurs résultats selon une règle écrite. Ainsi, la vitesse ne cause jamais de résultat erroné ou perdu.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 45 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur

  • Le projet après le module 4. python -m pytest affiche 60 passed (60 tests réussis).
  • Si vous avez sauté le module 4, copiez l’instantané end-of-m04 du dossier des laboratoires. Créez son environnement virtuel selon le README des instantanés.

Cette leçon ajoute resolver/parallel.py et ses tests. Aucun compte ni aucune clé : chaque décision du modèle est un enregistrement. Testé avec Python 3.12.13 sur macOS; les commandes Windows n’ont pas été exécutées.

Deux amis font les courses pour un souper. L’un va à la boulangerie, l’autre au marché de légumes, puis ils se retrouvent à la voiture. Ils terminent en deux fois moins de temps. Mais leur plan fonctionne seulement si trois choses sont claires avant leur départ.

  • Ce que chacun achète. « Vous prenez le pain et le fromage, je prends les légumes. » Si chacun pense devoir acheter le vin, ils reviennent avec deux bouteilles, ou aucune.
  • Ce que chacun rapporte. Les sacs, et une explication sur ce qu’ils n’ont pas trouvé. « La boulangerie était fermée » est utile. Un sac vide sans explication ne l’est pas.
  • Ce qui arrive en cas de désaccord. L’un a acheté du vin rouge et l’autre, du blanc. Qui décide? Si personne ne l’a précisé, le dernier à mettre sa bouteille dans la voiture gagne par hasard.

Certaines tâches ne se divisent pas. Vous ne pouvez pas choisir le dessert avant de connaître le plat principal. Chaque décision attend la précédente.

Revenez au billet de soutien T-90201, dans Modèles courants. Le client pose une question sur deux commandes dans un seul billet. Dans sa première réponse, l’agent chat-small a demandé deux lectures à la fois :

1 model chat-small: get_order({"order_id": "LK-640763"}), get_order({"order_id": "LK-640872"}) (2167 in, 55 out, 1.04 s)
1 tool get_order({"order_id": "LK-640763"}) -> ok
1 tool get_order({"order_id": "LK-640872"}) -> ok

Les deux lectures ne dépendent pas l’une de l’autre. Aucune n’a besoin du résultat de l’autre pour commencer. La boucle les exécute encore l’une après l’autre, dans l’ordre donné par le modèle.

Ce cas n’est pas rare dans les enregistrements. Dans les trois répétitions de l’agent chat-small, 12 de ses 444 étapes du modèle demandaient au moins deux lectures à la fois. Elles concernaient 5 billets : T-80007, T-90201, T-90207, T-90209 et T-90602. Chaque fois, les lectures étaient indépendantes.

Exécuter les lectures en même temps semble facile. Lancez-les toutes, attendez qu’elles terminent, puis rassemblez les résultats. Deux problèmes surviennent quand « rassembler les résultats » n’a pas de règle. Les deux cas ci-dessous sont construits pour cette leçon; aucune exécution enregistrée ne les a produits.

Un résultat erroné gagne. Amira essaie une conception avec un exécutant par commande. Chaque exécutant fait un appel de modèle distinct, examine une commande et propose quoi en faire. Pour T-90201, un exécutant lit LK-640763 et propose de rembourser les frais de livraison de 9,95. C’est la règle W2, car la commande a 13 jours ouvrables de retard. Un deuxième exécutant lit la même commande et la déclare perdue. Il propose un remboursement complet de 45,95, comme le prévoit la règle W3 pour plus de 15 jours. Son premier code de fusion garde la dernière réponse reçue. La dernière réponse dépend de l’exécutant qui termine en premier. L’écran d’approbation de Grace affiche donc un remboursement de 45,95 un jour et de 9,95 le lendemain, pour le même billet.

Une lecture échouée disparaît. Le service de paiements est en panne, donc get_payments échoue. Une fusion qui recueille seulement les résultats reçus n’en garde aucune trace. La proposition semble complète, mais un de ses faits n’a jamais été lu.

Aucun des deux problèmes ne concerne la vitesse. Les deux viennent d’une fusion dont personne n’a écrit la règle.

L’idée : répartir, puis fusionner selon une règle écrite

Section intitulée « L’idée : répartir, puis fusionner selon une règle écrite »

Le travail en parallèle exécute des parties d’une tâche en même temps, puis combine leurs résultats. La première moitié est la répartition (fan-out) : une entrée se divise en plusieurs parties indépendantes, comme les branches d’un éventail. La deuxième moitié est la fusion : l’étape qui rassemble les résultats. Quand les résultats divergent, la fusion doit les concilier : décider, selon une règle, quel sera le résultat combiné. Ce n’est pas la conciliation de Effets de bord et nouvelles tentatives, qui demande à un service si une écriture a eu lieu. Ici, on compare deux résultats.

Les parties sont indépendantes si aucune n’a besoin du résultat d’une autre pour commencer, et si elles ne modifient pas la même chose. Les lectures de deux commandes sont indépendantes. Une lecture et la décision qui l’utilise ne le sont pas.

Avant d’exécuter quoi que ce soit en parallèle, écrivez donc trois choses :

  1. Les entrées de chaque partie. Ici : un appel d’outil chacune, avec le client défini par l’application, jamais par une partie.
  2. La sortie de chaque partie. Ici : un résultat par appel, ok (réussi) ou échoué, mais jamais une absence de résultat.
  3. La règle de fusion. Ici : les résultats reviennent dans l’ordre des appels, et les propositions sur des commandes différentes se combinent. Deux propositions différentes pour une même commande constituent un conflit, transmis à une personne.
Le billet T-90201, avec deux commandes pour le client C-50866, se répartit en trois lectures que gather() exécute en même temps : get_order LK-640763, get_order LK-640872 et get_payments LK-640763. Les résultats reviennent dans l'ordre des appels, et une lecture échouée reste une lecture échouée. Ils donnent deux parties : pour LK-640763, rembourser 9,95 selon W2; pour LK-640872, répondre seulement selon W1. La règle de fusion, merge_proposals(), a trois issues : une partie indique hand_to_person, donc une personne décide; une commande a deux actions différentes, donc un Conflict est transmis à une personne; des commandes différentes ou des actions identiques donnent une proposition, soumise à l'approbation.
Répartition et conciliation dans resolver/parallel.py : les lectures s'exécutent en même temps, et la fusion suit une règle écrite.
Description textuelle du diagramme

Le billet T-90201 contient deux commandes. L’application répartit trois lectures, et gather() les exécute en même temps. Ce sont get_order pour LK-640763 et LK-640872, et get_payments pour LK-640763.

Les résultats reviennent dans l’ordre des appels. Une lecture échouée reste dans la liste comme lecture échouée.

Les résultats donnent deux parties. Pour LK-640763 : rembourser les frais de 9,95, selon la règle W2. Pour LK-640872 : une réponse seulement, selon la règle W1.

La règle de fusion, merge_proposals(), a trois issues. Si une partie indique hand_to_person, le billet entier est transmis à une personne. Si une commande a deux actions différentes, elle lève une exception Conflict, et une personne décide. Si les parties concernent des commandes différentes ou proposent les mêmes actions, elles se combinent en une proposition. Celle-ci est soumise à l’approbation comme d’habitude.

Remarquez ce qui manque dans l’image : une issue où le code choisit un gagnant. Le code ne choisit jamais au hasard entre deux propositions pour une commande. Une personne, qui peut lire les deux, décide.

  1. Créez un fichier, resolver/parallel.py, avec ce code :

    resolver/parallel.py
    """Parallel work with explicit inputs, outputs and merge rules.
    `gather` runs independent reads at the same time. It is safe because the reads do not depend on each
    other and change nothing; the merge rule is explicit: one result per call, in the order of the calls,
    a failed read stays a failed read (it is never dropped silently).
    `merge_proposals` merges the work of two workers that each looked at part of a ticket (for example one
    order each). The rule: actions on different orders are combined; two different proposals for the same
    order are a conflict, and a conflict goes to a person. Code never picks a winner by guessing.
    """
    import time
    from concurrent.futures import ThreadPoolExecutor
    from .schema import Resolution
    from .systems import World
    from .tools import ToolResult, run_read
    def gather(calls: list[tuple[str, dict]], customer_id: str, world: World, max_workers: int = 3,
    delay_s: float = 0.0) -> list[ToolResult]:
    """Run the read calls in parallel; the results come back in the order of `calls`.
    `delay_s` simulates a slow service (each read waits that long), to see the time saved."""
    def one(call):
    if delay_s:
    time.sleep(delay_s)
    return run_read(call[0], call[1], customer_id, world)
    with ThreadPoolExecutor(max_workers) as pool:
    return list(pool.map(one, calls))
    class Conflict(Exception):
    pass
    def merge_proposals(a: Resolution, b: Resolution) -> Resolution:
    """Combine two partial resolutions. Raises Conflict when they disagree about the same order."""
    if "hand_to_person" in (a.outcome, b.outcome):
    keep = a if a.outcome == "hand_to_person" else b
    return keep
    by_order: dict[str, list] = {}
    for source in (a, b):
    for action in source.actions:
    by_order.setdefault(action.order_id, []).append((source, action))
    for order_id, items in by_order.items():
    sources = {id(s) for s, _ in items}
    if len(sources) > 1:
    first = sorted(x.model_dump_json() for s, x in items if s is a)
    second = sorted(x.model_dump_json() for s, x in items if s is b)
    if first != second:
    raise Conflict(f"the two workers propose different actions for {order_id}")
    actions, seen = [], set()
    for source in (a, b):
    for action in source.actions:
    key = action.model_dump_json()
    if key not in seen:
    seen.add(key)
    actions.append(action)
    outcome = "resolve" if actions else ("ask_customer" if "ask_customer" in (a.outcome, b.outcome) else "reply_only")
    return Resolution(outcome=outcome, rule=f"{a.rule}+{b.rule}", actions=actions,
    reply=f"{a.reply}\n\n{b.reply}".strip()[:2000], reason=f"{a.reason} {b.reason}".strip()[:600])
  2. Créez un fichier, tests/test_parallel.py, avec ces trois tests :

    tests/test_parallel.py
    """Parallel reads with an explicit merge rule; conflicting workers go to a person."""
    import time
    import pytest
    from resolver.parallel import Conflict, gather, merge_proposals
    from resolver.schema import parse_resolution
    def test_parallel_reads_keep_the_order_and_save_time(world):
    calls = [("get_order", {"order_id": "LK-640763"}), ("get_order", {"order_id": "LK-640872"}),
    ("get_payments", {"order_id": "LK-640763"})]
    start = time.monotonic()
    results = gather(calls, "C-50866", world("T-90201"), delay_s=0.2)
    took = time.monotonic() - start
    assert [r.order_ids for r in results] == [["LK-640763"], ["LK-640872"], ["LK-640763"]]
    assert took < 0.5 # three simulated 0.2 s reads, at the same time
    def res(actions, outcome="resolve"):
    flat = [dict(a, reason=a.get("reason") or a.get("reason_code")) for a in actions]
    return parse_resolution({"outcome": outcome, "rule": "W", "actions": flat, "reply": "r", "reason": "x"})
    def test_workers_on_different_orders_merge():
    a = res([{"tool": "request_refund", "order_id": "LK-640763", "amount": 9.95, "reason_code": "RFD-LATE"}])
    b = res([], "reply_only")
    assert merge_proposals(a, b).actions == a.actions
    def test_workers_that_disagree_about_one_order_conflict():
    a = res([{"tool": "request_refund", "order_id": "LK-640763", "amount": 9.95, "reason_code": "RFD-LATE"}])
    b = res([{"tool": "request_refund", "order_id": "LK-640763", "amount": 45.95, "reason_code": "RFD-LATE"}])
    with pytest.raises(Conflict):
    merge_proposals(a, b)
  3. Exécutez les tests :

    Fenêtre de terminal
    python -m pytest

    La dernière ligne est 63 passed (63 tests réussis).

Lisez les deux fonctions en les comparant aux trois choses que vous avez écrites.

  • Entrées. gather reçoit une liste d’appels et un seul customer_id, fourni par l’application. Aucune partie ne peut choisir un autre client. Chaque partie exécute run_read, la même lecture vérifiée que la boucle, avec toutes les vérifications de Contrats d’outils.
  • Sorties. pool.map renvoie un résultat par appel, dans l’ordre des appels, peu importe lequel termine en premier. run_read ne lève jamais d’exception : un échec est un ToolResult dont ok est faux. Une lecture échouée garde donc sa place dans la liste.
  • La règle de fusion. merge_proposals transmet le billet à une personne si l’une des parties le demande. Elle compare les actions par commande. Deux listes d’actions différentes pour une même commande lèvent l’exception Conflict. Sinon, elle garde chaque action une seule fois.

ThreadPoolExecutor exécute chaque appel dans son propre fil d’exécution : une deuxième voie de travail dans le même programme Python. Les fils d’exécution conviennent aux lectures, car une lecture passe son temps à attendre un service. Python peut en attendre plusieurs à la fois. Son paramètre max_workers est le nombre de fils d’exécution. Ces exécutants ne sont pas les exécutants de modèle de la prochaine leçon.

Chronométrez les trois lectures de T-90201. Une vraie lecture de la base de données d’exercice prend moins d’une milliseconde, trop peu pour observer la différence. L’exemple ajoute donc un délai simulé de 0,2 seconde à chaque lecture, comme le ferait un service de commandes lent. Lancez Python dans le dossier du projet avec python, puis collez ces lignes. Appuyez sur Entrée jusqu’au retour de l’invite >>> :

import time
from resolver.parallel import gather
from resolver.systems import World
calls = [("get_order", {"order_id": "LK-640763"}),
("get_order", {"order_id": "LK-640872"}),
("get_payments", {"order_id": "LK-640763"})]
for workers in (1, 2, 3):
start = time.monotonic()
results = gather(calls, "C-50866", World(), max_workers=workers, delay_s=0.2)
print(f"{workers} worker(s): {time.monotonic() - start:.2f} s", [r.order_ids for r in results])
1 worker(s): 0.62 s [['LK-640763'], ['LK-640872'], ['LK-640763']]
2 worker(s): 0.41 s [['LK-640763'], ['LK-640872'], ['LK-640763']]
3 worker(s): 0.21 s [['LK-640763'], ['LK-640872'], ['LK-640763']]

Vos durées diffèrent un peu. Avec un exécutant, les trois lectures s’exécutent l’une après l’autre : trois fois 0,2 seconde. Avec deux, deux lectures s’exécutent ensemble et la troisième attend. Avec trois, elles s’exécutent toutes ensemble, et l’ensemble prend autant de temps que la lecture la plus lente. L’ordre des résultats ne change jamais.

  • l'une après l'autre
  • en même temps
Trois lectures de T-90201 avec un délai simulé de 0,2 s chacune, mesurées une fois avec gather(). Un exécutant les exécute l'une après l'autre.
Description textuelle du graphique

Un graphique à colonnes de la durée de trois lectures. Un exécutant : 0,62 seconde. Deux exécutants : 0,41 seconde. Trois exécutants : 0,21 seconde. La durée descend à environ celle d’une lecture quand toutes les lectures s’exécutent en même temps.

Passons à la règle de sortie : un échec reste un échec. Faites échouer le service de paiements une fois (une panne simulée), puis exécutez les trois mêmes appels :

from resolver.systems import Faults
down = World(faults=Faults([{"tool": "get_payments", "mode": "unavailable"}]))
for (name, args), r in zip(calls, gather(calls, "C-50866", down)):
print(name, args["order_id"], "->", "ok" if r.ok else f"FAILED: {r.error}")
get_order LK-640763 -> ok
get_order LK-640872 -> ok
get_payments LK-640763 -> FAILED: The service failed: get_payments: HTTP 503 Service Unavailable (simulated). You may try again once.

Trois appels, trois résultats. Le troisième indique ce qui a échoué et pourquoi, à sa propre place. Le modèle ou le code qui lira ensuite cette liste pourra voir que les paiements sont inconnus.

Enfin, la règle de fusion. Construisez les deux parties de T-90201 à la main, puis le conflit construit au début de la leçon :

from resolver.parallel import Conflict, merge_proposals
from resolver.schema import Resolution
late = Resolution(outcome="resolve", rule="W2", reply="We will refund the 9.95 delivery fee.", reason="13 days late",
actions=[{"tool": "request_refund", "order_id": "LK-640763", "amount": 9.95, "reason_code": "RFD-LATE"}])
status = Resolution(outcome="reply_only", rule="W1", reply="LK-640872 is being prepared.", reason="processing")
merged = merge_proposals(late, status)
print(merged.outcome, merged.rule, [(a.tool, a.order_id, a.amount) for a in merged.actions])
lost = Resolution(outcome="resolve", rule="W3", reply="We will refund your order.", reason="read as lost",
actions=[{"tool": "request_refund", "order_id": "LK-640763", "amount": 45.95, "reason_code": "RFD-LATE"}])
try:
merge_proposals(late, lost)
except Conflict as e:
print("Conflict:", e)
resolve W2+W1 [('request_refund', 'LK-640763', 9.95)]
Conflict: the two workers propose different actions for LK-640763

La première fusion combine un remboursement pour une commande avec une réponse sur l’autre. Le résultat est une proposition, avec le remboursement de 9,95, soumise à l’approbation. La deuxième fusion contient deux remboursements différents pour une même commande, donc elle lève l’exception Conflict. Aucun remboursement n’arrive à l’écran de Grace par hasard.

Ce que les lectures en parallèle font gagner à Larkfield

Section intitulée « Ce que les lectures en parallèle font gagner à Larkfield »

Dans ce projet, très peu. Deux vraies lectures de la base de données d’exercice ont pris environ 0,6 milliseconde sur un portable, ensemble ou successivement. Les appels de modèle de T-90201 ont pris 2,814 secondes. Le temps d’une tâche se passe dans les appels de modèle, pas dans les lectures.

Et les appels de modèle d’une même boucle ne peuvent pas s’exécuter en parallèle. Chaque appel a besoin des résultats du précédent : le modèle choisit sa prochaine lecture après avoir vu le dernier résultat. C’est le dessert qui attend le plat principal.

Le projet garde donc la boucle telle quelle : elle exécute les lectures d’une étape l’une après l’autre. gather est prête pour le jour où une lecture sera lente. Le vrai service de commandes de Mei répondrait par le réseau, et un service de paiements peut prendre une seconde. Trois lectures à la fois font alors vraiment gagner du temps, et la règle de fusion est déjà écrite et testée.

Quand utiliser le travail en parallèle, et à quel coût

Section intitulée « Quand utiliser le travail en parallèle, et à quel coût »

Utilisez-le quand les parties sont vraiment indépendantes et que chacune est lente : lectures de services lents, plusieurs commandes, plusieurs recherches de politiques. Mesurez d’abord. Si les parties prennent des millisecondes, les fils d’exécution ajoutent seulement du code.

Ne l’utilisez pas pour :

  • Les écritures. Deux écritures simultanées sur une commande peuvent toutes deux réussir, ou l’une peut annuler l’autre. Chaque changement dans ce projet est une proposition, approuvée par une personne, puis exécutée une fois avec un identifiant d’opération. C’est ce que présente Effets de bord et nouvelles tentatives. Gardez les écritures l’une après l’autre.
  • Les étapes qui dépendent l’une de l’autre. Une décision a d’abord besoin de ses preuves.

Ce que cela coûte :

  • Une règle de fusion, écrite et testée. Sans elle, l’ordre d’arrivée décide.
  • La gestion des échecs pour chaque partie. Une partie peut échouer pendant que les autres réussissent; le résultat doit l’indiquer.
  • La charge sur les services. Trois appels à la fois imposent trois fois plus de charge à un service, et les fournisseurs de modèles limitent cela. Pendant l’enregistrement des exécutions du cours, les déploiements Azure partagés ont refusé 887 requêtes avec HTTP 429, « too many requests » (trop de requêtes). Personne n’avait provoqué ces refus : une autre tâche utilisait les mêmes déploiements. Limitez le nombre d’appels simultanés et attendez avant de réessayer.

Erreur courante

La fusion garde la dernière réponse

Symptôme : le même billet donne une proposition différente d’une exécution à l’autre, sans changement du code ou des enregistrements. Pour T-90201, l’écran d’approbation affiche un remboursement de 45,95 à une exécution et de 9,95 à la suivante. (Cas construit : la première fusion d’Amira, avant merge_proposals.)

Cause : la fusion garde un résultat par commande, et elle garde le dernier arrivé. Les fils d’exécution terminent dans un ordre différent chaque fois, donc le hasard décide.

Diagnostic : exécutez la fusion avec les parties dans les deux ordres. Si merge(a, b) et merge(b, a) donnent des propositions différentes, la fusion dépend de l’ordre d’arrivée. Cherchez ensuite dans le code de fusion une règle pour deux résultats concernant une même commande. S’il n’y en a pas, c’est le bogue.

Correction : écrivez la règle et testez-la. Dans merge_proposals, deux listes d’actions différentes pour une même commande lèvent l’exception Conflict. Le billet est alors transmis à une personne avec les deux propositions. test_workers_that_disagree_about_one_order_conflict garantit ce comportement.

Le client de T-90201 ajoute une phrase : « Aussi, le colis que j’ai reçu de vous la semaine dernière sent le gaz. » Une partie qui lit cette phrase applique la règle H1, un cas de sécurité, et propose hand_to_person (transmettre à une personne). L’autre partie propose toujours le remboursement de 9,95. (Cas construit : le vrai billet ne contient pas cette phrase.)

Prédisez d’abord : que renvoie merge_proposals(late, safety), et le remboursement de 9,95 est-il conservé? Puis construisez safety dans votre session Python et vérifiez :

safety = Resolution(outcome="hand_to_person", rule="H1", reply="A colleague will contact you.", reason="smell of gas")
Afficher la réponse
print(merge_proposals(late, safety).outcome, merge_proposals(late, safety).actions)
hand_to_person []

La fusion renvoie la partie hand_to_person, et le remboursement disparaît. C’est la règle : si une partie a besoin d’une personne, le billet entier est transmis à une personne. Cette personne peut encore décider du remboursement, avec le billet complet devant elle. Une fusion qui garderait le remboursement et supprimerait le cas de sécurité laisserait une action automatique peu coûteuse masquer l’action importante.

Vérifiez votre compréhension

Lesquels peuvent s'exécuter en parallèle, et pourquoi? (a) get_order pour deux commandes d'un même billet (b) get_order, puis search_policy pour la règle applicable à la commande (c) deux actions request_refund sur la même commande

Afficher la réponse

Seulement (a). Les deux lectures n’ont pas besoin des résultats l’une de l’autre et ne changent rien. Elles sont donc indépendantes. Dans (b), la recherche de politique dépend du contenu de la commande. Elle doit donc attendre. Dans (c), les écritures touchent une même commande. Elles passent par l’approbation et s’exécutent l’une après l’autre avec des identifiants d’opération. Deux remboursements différents pour une même commande constituent un conflit qu’une personne doit régler.

Vérifiez votre compréhension

Avec trois exécutants, gather() a pris 0,21 s pour trois lectures avec un délai simulé de 0,2 s. Omar demande : « Donc, les lectures en parallèle rendent Larkfield trois fois plus rapide? » Que répondez-vous?

Afficher la réponse

Non. Le délai de 0,2 s est simulé. Deux lectures réelles de la base de données d’exercice prennent environ 0,6 ms. Les appels au modèle pour T-90201 ont pris 2,814 s. Les appels au modèle prennent l’essentiel du temps d’une tâche. Ils dépendent les uns des autres et ne peuvent pas s’exécuter en parallèle. Les lectures en parallèle sont utiles seulement avec des services réels lents. Vous mesurez alors le temps gagné.

Vérifiez votre compréhension

Exercice pratique : dans votre session Python, appelez merge_proposals(lost, late), avec les deux parties dans l'ordre inverse. Que se passe-t-il, et pourquoi est-ce important?

Afficher la réponse

L’appel déclenche le même Conflict : « the two workers propose different actions for LK-640763 » (les deux exécutants proposent des actions différentes pour LK-640763). La règle compare les actions pour chaque commande. Elle ne dépend donc pas de la partie arrivée en premier. Si le résultat d’une fusion dépendait de l’ordre d’arrivée, un même billet produirait des propositions différentes d’une exécution à l’autre.

Récapitulatif

  • Le travail en parallèle divise une tâche en parties indépendantes, puis fusionne leurs résultats. Les parties sont indépendantes si aucune n’a besoin du résultat d’une autre et qu’aucune ne modifie la même chose.
  • Avant toute exécution en parallèle, notez trois choses : les entrées de chaque partie, sa sortie et la règle de fusion. Un échec reste un échec.
  • gather exécute les lectures en même temps et renvoie un résultat par appel, dans l’ordre des appels. merge_proposals combine les parties portant sur des commandes différentes. Il déclenche Conflict (conflit) si deux propositions différentes concernent une même commande : une personne décide, jamais le hasard.
  • Dans ce projet, les lectures en parallèle font gagner très peu de temps. Les lectures prennent des millisecondes, et les appels au modèle, des secondes. N’exécutez jamais d’écritures en parallèle.
  • python -m pytest affiche 63 passed (63 tests réussis).

Leçon suivante : Compromis des systèmes multiagents