0%

Ein Server, viele Clients · Übung

Jede Änderung an alle senden

Das Register findet jeden verbundenen Zuschauer, und die Tafel ändert sich immer noch nie. Diese Lektion schreibt die Änderung: apply_score schreibt die Punkte einer Wertung auf der Tafel gut, und broadcast sendet die neue Tafel an jeden Zuschauer.

Eine Änderung, ein Halten

apply_score(board, message) bekommt einen score-Umschlag, den parse_message schon geprüft hat; sein Team ist also ein und seine Punkte liegen zwischen 1 und 10. In einem einzigen with board["lock"]:-Block erledigt die vier Dinge der Reihe nach:

  1. das Team prüfen: Wenn message["team"] kein Schlüssel von board["scores"] ist, löse ValueError("unknown team") aus, bevor sich irgendetwas ändert;

  2. die Punkte zum Punktestand dieses Teams addieren;

  3. 1 zu board["version"] addieren;

  4. den neuen Snapshot mit snapshot_message(board) bauen, ihn an broadcast(board, snapshot) übergeben und ihn zurückgeben.

Der ist für Lektion 6, in der der Handler des Punktezählers jede Wertung mit dem neuen Snapshot beantwortet. Dass der innerhalb des Haltens erstellte Snapshot zurückgegeben wird, ist wichtig: Würde der Aufrufer die Tafel erneut lesen, nachdem das Lock freigegeben wurde, hätte eine andere Wertung sie vielleicht schon geändert, und der Punktezähler würde von einer Tafel erfahren, die seine Wertung nicht erzeugt hat. Die Team-Prüfung steht hier und nicht in check_message, weil nur die Tafel weiß, welche Teams mitspielen.

Alle vier Schritte teilen sich ein Halten, aus dem Grund aus Lektion 3. Der Beitritt nimmt dasselbe Lock, also tritt ein Zuschauer entweder vor der Änderung bei und ist registriert, um sie zu empfangen, oder danach, und sein Snapshot enthält sie schon.

Einmal codieren, oft senden

broadcast(board, snapshot) wird von apply_score aufgerufen, während das Lock gehalten wird, also darf die Funktion das Lock nicht selbst nehmen: Ein Thread, der ein Lock nimmt, das er schon hält, wartet ewig auf sich selbst. Sie codiert den Snapshot einmal mit scoreboard_messages.encode_message und sendet dann denselben Text an jeden Zuschauer in board["viewers"]. Jeder Zuschauer bekommt dieselben Bytes, also gibt es keinen Grund, sie für jeden neu zu codieren. Das Codieren prüft außerdem den Umschlag, einmal, bevor irgendetwas an irgendwen gesendet wird.

Ein Zuschauer, der weg ist

Ein Zuschauer kann jederzeit verschwinden: ein zugeklappter Laptop, ein verlorenes Netzwerk. Das Register weiß es nicht, bis etwas versucht, die Verbindung zu verwenden. Dann löst send ConnectionClosed aus, das du aus Kapitel 6 kennst. Ein verschwundener Zuschauer darf die anderen nicht ihr Update kosten, also fange die um jedes Senden herum ab und mach mit dem nächsten Zuschauer weiter.

Der verschwundene Zuschauer sollte außerdem das Register verlassen. Die naheliegende Zeile, ihn innerhalb der zu entfernen, scheitert, weil ein seine Größe nicht ändern darf, während eine for-Schleife es durchläuft:

Try it

Sammle also während der Schleife die IDs der gescheiterten Zuschauer in einer und entferne sie danach. Wenn du das Register mit .items() durchläufst, bekommst du jede ID zusammen mit ihrer Verbindung:

failed = []
for viewer_id, viewer in board["viewers"].items():
    try:
        viewer.send(text)
    except ConnectionClosed:
        failed.append(viewer_id)

Jede Verbindung mit board["viewers"][viewer_id] nachzuschlagen funktioniert genauso gut. Wenn der eigene Handler dieses Zuschauers später seinen finally-Block erreicht, findet sein remove_viewer die ID nicht mehr, und pop mit einem Standardwert macht das harmlos:

Was gibt das aus?

d = {"a": 1}
print(d.pop("b", None), d.pop("a", None), d)

In broadcast stammen die IDs einen Moment zuvor aus dem Register, also ist ein einfaches pop(viewer_id) dort in Ordnung.

Was passiert mit dem Handler des verschwundenen Zuschauers? Sein Thread steckt noch in watch_until_closed. Sein nächstes recv löst ebenfalls ConnectionClosed aus, der Handler verlässt den Block über except und finally, und remove_viewer findet nichts zu entfernen. Zwei Threads versuchen beide, den Zuschauer zu entfernen, und welcher auch immer als Zweiter kommt, tut nichts.

Drei Zuschauer sind registriert, und das send des zweiten löst ConnectionClosedError aus. Was tut broadcast?

Reihenfolge gratis, vorerst

Jedes Senden an einen Zuschauer passiert jetzt, während irgendein Thread das Lock der Tafel hält: der Beitritts-Snapshot in add_viewer und jedes Update in apply_score. Nur ein Thread hält das Lock auf einmal, also passieren die Sendungen an einen bestimmten Zuschauer nacheinander, in der Reihenfolge, in der sich die Tafel geändert hat. Einem Zuschauer kann nie Version 7 vor Version 6 gesendet werden.

Warum kann ein Zuschauer beim Server dieses Kapitels nie Version 7 vor Version 6 empfangen?

Diese Reihenfolge hat ihren Preis. Während das send an einen Zuschauer läuft, wird das Lock gehalten, also warten jeder andere Zuschauer, die nächste Wertung und jeder neue Beitritt darauf. Senden unter einem Lock ist eine bewusste Vereinfachung für dieses Kapitel, und so sollte ein ausgelasteter Server nicht arbeiten. Kapitel 8 misst, was es kostet, und entfernt es.

Ausführen

Klicke auf Run. Der Abschnitt zu Lektion 4 registriert drei Zuschauer, lässt das send von B scheitern und wendet eine Wertung an. Eine fertige Version hat auf der Seite ausgegeben:

Lesson 4: one score, three viewers, one of them gone
    viewer A received versions [0, 1]
    viewer B received versions [0]
    viewer C received versions [0, 1]
    registry after the update: ['A', 'C']

Jeder Zuschauer hat seinen Beitritts-Snapshot empfangen, Version 0. A und haben dann Version 1 empfangen. Das Senden an B ist gescheitert, also hat B nur seinen Beitritts-Snapshot behalten und das Register verlassen, und C hat das Update trotzdem bekommen. Klicke dann auf Submit.

Beitritt, Register und Broadcast funktionieren jetzt, und nur die Demo und Submit haben sie ausprobiert. In der nächsten Lektion schreibst du eigene Tests, die jedes davon beweisen.

Aufgabe

Schreibe in scoreboard_server.py zwei , in dieser Reihenfolge. Submit nennt die erste, die noch NotImplementedError auslöst.

  1. broadcast(board, snapshot): Der Aufrufer hält das Lock der Tafel bereits, also nimm hier kein Lock. Codiere den Snapshot einmal mit scoreboard_messages.encode_message, sende den Text an jede Verbindung in board["viewers"] und fange ConnectionClosed um jedes Senden herum ab, wobei du die ID dieses Zuschauers zu einer hinzufügst. Entferne nach der jede ID aus der Liste aus board["viewers"].

  2. apply_score(board, message): Löse in einem einzigen with board["lock"]:-Block ValueError("unknown team") aus, wenn message["team"] nicht auf der Tafel steht, bevor du etwas änderst; addiere andernfalls message["points"] zum Punktestand dieses Teams, addiere 1 zu board["version"], erstelle den neuen Snapshot mit snapshot_message(board), rufe broadcast(board, snapshot) auf und gib den Snapshot zurück.

Klicke auf Run, lies den Abschnitt zu Lektion 4 und klicke dann auf Submit. Die Prüfung beobachtet das Lock der Tafel und zeichnet jedes Schreiben in die Version und die Punktestände der Tafel auf. Sie registriert vier Stellvertreter-Zuschauer, von denen der zweite verschwunden ist, und wendet eine Wertung an, die du nicht gesehen hast: Sie erwartet den neuen Snapshot als Rückgabe, dass jeder der drei anderen Zuschauer ihn genau einmal empfängt und dass das Register diese drei in ihrer Reihenfolge behält. Zwei Wertungen hintereinander müssen jeden Zuschauer als Versionen 1 und 2 erreichen. Eine Wertung für Bats muss auslösen und die Tafel, die Zuschauer und das Lock so lassen, wie sie waren. Jede Änderung an der Tafel und jedes Senden muss passieren, während das Lock gehalten wird, und das Lock muss danach frei sein. Eine Tafel ohne Zuschauer muss sich trotzdem ändern, und broadcast, allein mit gehaltenem Lock aufgerufen, muss jeden Zuschauer erreichen, der nicht verschwunden ist. Über board["viewers"].copy() zu iterieren, Exception statt ConnectionClosed abzufangen und einmal pro Zuschauer zu codieren wird alles akzeptiert.