Ein Server, viele Clients · Übung
Ein Register der Zuschauer führen
Nach Lektion 2 empfängt ein Zuschauer seinen Snapshot, und dann vergisst ihn der Server. Wenn eine Wertung ankommt, weiß nichts, welche Zuschauer zu benachrichtigen sind. Diese Lektion führt ein Register der verbundenen Zuschauer, schließt einen Wettlauf (Race Condition) in dem Moment, in dem ein Zuschauer beitritt, und sorgt dafür, dass jeder Zuschauer das Register verlässt, egal wie seine Verbindung endet.
Ein Register mit IDs als Schlüssel
Das Register ist board["viewers"], ein connection.id, ein send aufrufen kann. Die ID ist der Schlüssel und nicht die Verbindung, weil eine mit types.SimpleNamespace gebaute Stellvertreter-Verbindung, wie sie die Demo und die Tests verwenden, selbst kein
add_viewer(board, connection) speichert die Verbindung und sendet ihren Beitritts-Snapshot. remove_viewer(board, connection) nimmt sie wieder heraus. Zwischen diesen beiden Aufrufen steckt der Handler des Zuschauers in watch_until_closed, und jeder andere Thread, der ins Register schaut, findet ihn dort. Schauen heißt, das Dictionary zu durchlaufen, zum Beispiel mit .items(), das jede ID mit ihrer Verbindung liefert:
for viewer_id, viewer in board["viewers"].items():
print(viewer_id, viewer.request.path)
Das Register lebt auf der Tafel, neben den Punkteständen, aus demselben Grund wie die Punktestände: Jede
Der Wettlauf an der Tür
Mehrere Threads verwenden die Tafel gleichzeitig: einer pro Zuschauer und der des Punktezählers. Beitreten braucht drei Schritte: die Tafel in einen Snapshot lesen, registrieren und den Snapshot senden. Das Bild folgt links dem Thread des Zuschauers und rechts dem des Punktezählers, die Zeit läuft nach unten; die obere Hälfte hat kein Lock. Finde den Moment, in dem Version 6 hinausgeht: Ist der Zuschauer schon registriert?

In Worten: Der Thread des Zuschauers liest Version 5; der Thread des Punktezählers wendet eine Wertung an, erzeugt damit Version 6 und sendet sie an jeden registrierten Zuschauer; erst dann registriert sich der Zuschauer und sendet seinen Snapshot mit Version 5. Er zeigt Version 5 und hört bis zur nächsten Wertung nichts mehr.
Verschiebe die Wertung um einen Schritt nach hinten, und etwas anderes geht schief: Der Zuschauer liest Version 5 und registriert sich, Version 6 wird an ihn gesendet, und danach wird der früher gelesene Snapshot mit Version 5 gesendet, nach dem neueren.
Die Antwort kennst du aus Networks with Python: ein Lock, das um ein Lesen und ein Schreiben gehalten wird, damit kein anderer Thread dazwischen handeln kann. Hier kommen das Lesen, die Registrierung und das Senden alle in einen einzigen with board["lock"]:-Block in add_viewer. Das ist die untere Hälfte des Bildes: Der Punktezähler wartet auf das Lock, wendet dann Version 6 an und sendet sie. Der Thread des Punktezählers nimmt dasselbe Lock, um eine Wertung anzuwenden (Lektion 4). Eine Wertung passiert also entweder ganz vor dem Beitritt des Zuschauers, und der Snapshot enthält sie schon, oder ganz danach, und der Zuschauer ist registriert, um von ihr zu hören. remove_viewer nimmt das Lock ebenfalls.
Ein Zuschauer liest den Snapshot innerhalb des Locks, gibt es frei und registriert sich dann in einem zweiten Halten. Was kann schiefgehen?
finally: Aufräumen, das immer läuft
Ein Zuschauer muss das Register verlassen, egal wie sein Handler endet: durch ein sauberes Schließen, eine abgebrochene Verbindung, ein gescheitertes Senden oder einen Fehler, der etwas Unerwartetes ausgelöst hat. Python hat genau dafür einen Block. Dieser Kurs braucht ihn jetzt, also kommt er hier; Python Fundamentals II behandelt ihn ausführlich.
Eine try-Anweisung kann mit finally: enden. Sein Block läuft auf jedem Weg aus dem try heraus: nach der letzten Zeile, nach einem finally-Block weiterläuft.
def show(how):
try:
if how == "return":
return "returned"
if how == "raise":
raise RuntimeError("broken")
print("reached the end")
finally:
print("finally ran")
Lies ihn mit jedem der drei show("end") gibt beide Zeilen aus. show("return") gibt finally ran aus und kehrt dann zurück. show("raise") gibt finally ran aus, und dann erreicht der RuntimeError den Aufrufer.
Was gibt das aus?
def step():
try:
print("try")
return
finally:
print("finally")
step()
print("after")
Führe es aus, um deine Antwort zu prüfen:
Der Handler des Zuschauers wird zu:
def viewer_handler(connection, board):
try:
add_viewer(board, connection)
watch_until_closed(connection)
except ConnectionClosed:
pass
finally:
remove_viewer(board, connection)
add_viewer steht innerhalb des try, weil schon sein eigenes Senden die Verbindung verschwunden vorfinden kann; dann fängt except ConnectionClosed: das ab, und das finally entfernt den Zuschauer trotzdem. Diese Klausel hält außerdem ein normales Ende aus dem Log der Bibliothek heraus. Jede andere Ausnahme wird nicht abgefangen: Sie läuft durch finally, das aufräumt, und erreicht die Bibliothek, die sie protokolliert und die Verbindung mit 1011 schließt. Genau das willst du bei einem echten Fehler. Würdest du hier jede Ausnahme abfangen, würdest du ihn verstecken, und der Zuschauer würde gehen, ohne dass jemand erfährt, warum.
Im Handler folgt nichts auf die try-Anweisung. Sobald der finally-Block gelaufen ist, kehrt der Handler zurück, und die Bibliothek schließt die Verbindung vollständig.
remove_viewer verwendet board["viewers"].pop(connection.id, None). Das zweite Argument ist das, was pop zurückgibt, wenn der Schlüssel fehlt, und damit ist ein fehlender Schlüssel kein Fehler. Der Schlüssel kann fehlen: Ab Lektion 4 entfernt ein Broadcast einen Zuschauer, dessen Senden gescheitert ist, und der Handler dieses Zuschauers erreicht danach sein eigenes finally.
add_viewer löst ConnectionClosedError aus, weil der Zuschauer sofort gegangen ist. Welche Zeilen laufen?
Ein Lock halten, während gesendet wird
add_viewer sendet den Beitritts-Snapshot, während es das Lock hält. Das hält die Reihenfolge einfach und sicher: Nichts kann die Tafel ändern, bis der Snapshot unterwegs ist. Der Preis ist, dass jeder andere Thread auf dieses Senden wartet. Kapitel 8 misst, was das kostet, und bezahlt es.
Halte das Lock nur innerhalb von add_viewer und remove_viewer, niemals um watch_until_closed herum: Ein Handler, der es hielte, während er auf den Zuschauer wartet, würde jede Wertung stoppen, bis dieser Zuschauer geht.
Ausführen
Klicke auf Run. Eine fertige Version hat auf der Seite ausgegeben:
Lesson 3: the registry
while A is connected, the registry holds: ['A']
after A's handler returned, the registry holds: []
after B failed at its first send, the registry holds: []
Der Abschnitt gibt Zuschauer A ein Skript, in dem ein recv-Aufruf ins Register schaut, während A verbunden ist, und die Verbindung dann sauber schließt. Zuschauer A steht im Register, solange er verbunden ist, und ist weg, sobald sein Handler zurückkehrt. Das Beitritts-Senden für Zuschauer B scheitert; die except-Klausel fängt das ab, und finally entfernt B, also ist die Fehlerzeile aus Lektion 2 verschwunden. Klicke dann auf Submit.
Das Register kennt jetzt jeden Zuschauer. Die nächste Lektion ändert die Tafel und sendet jede Änderung an alle.
Aufgabe
Schreibe in scoreboard_server.py zwei
add_viewer(board, connection): Speichere in einem einzigenwith board["lock"]:-Block die Verbindung mitboard["viewers"][connection.id] = connectionund sende ihrscoreboard_messages.encode_message(snapshot_message(board)).remove_viewer(board, connection): Rufe innerhalb vonwith board["lock"]:board["viewers"].pop(connection.id, None)auf.viewer_handler(connection, board): Rufe in einemtry-Blockadd_viewer(board, connection)und dannwatch_until_closed(connection)auf; fügeexcept ConnectionClosed:mitpasshinzu; und ruferemove_viewer(board, connection)in einemfinally-Block auf.
Behalte import threading und verwende board["lock"]; erstelle kein eigenes Lock.
Klicke auf Run, lies den Abschnitt zu Lektion 3 und klicke dann auf Submit. Die Prüfung beobachtet das Lock der Tafel, statt Threads laufen zu lassen. Sie führt den Beitritt eines Zuschauers einmal aus, um die Momente zu zählen, in denen ein anderer Thread handeln könnte (jedes Mal, wenn das Lock genommen oder freigegeben wird, und jedes Senden), und dann noch einmal pro Moment, wobei genau dort eine Wertung aus dem Thread des Punktezählers angewendet wird. Jedes Mal muss die erste Nachricht des Zuschauers ein Snapshot sein, seine Versionen dürfen nur steigen, die Punkte jedes Snapshots müssen zu seiner Version passen, die neueste Version vor dem Gehen des Zuschauers muss ihn erreicht haben, und das Register muss am Ende leer sein. Das Beitritts-Senden muss passieren, während das Lock gehalten wird, und kein recv, während es gehalten wird. Danach beendet sie einen Zuschauer auf sechs Arten: ein sauberes Schließen, eine Verbindung ohne Close-RuntimeError aus recv, ein recv, das ewig warten würde, und ein Zuschauer, den ein Broadcast schon entfernt hatte. Jede muss das Register leer und das Lock frei lassen, und eine geschlossene Verbindung darf nicht aus dem Handler entkommen. Ein Zuschauer, der geht, darf keinen anderen entfernen. acquire() und release() in einem eigenen try und finally sind so gut wie with.