Slow Clients and Lost Connections · practice
Keep Only the Latest Snapshot
Lesson 1 settled on a policy: for each viewer, keep only the newest snapshot. A snapshot holds the whole board, so when a newer one arrives before the older one was sent, the older one can go. This lesson builds the place where that snapshot waits, an outbox, with four small
One slot
An outbox is a
"snapshot": the snapshot waiting to be sent, orNonewhen nothing waits;"lock": athreading.Lockof its own, for the moments the slot changes;"event": athreading.Event, which the next section introduces, set exactly while a snapshot waits.
offer(outbox, snapshot) puts a snapshot in the slot, replacing whatever was there, and take(outbox) returns what waits and leaves the slot empty. Here is one outbox through three offers and two takes:
| Step | Slot afterwards | Event afterwards | take returns |
|---|---|---|---|
offer version 1 | version 1 | set | |
offer version 2 | version 2 | set | |
take | empty | clear | version 2 |
offer version 3 | version 3 | set | |
take | empty | clear | version 3 |
Version 1 was never taken: version 2 replaced it while it waited. Keeping only the newest offer appends to and take reads from the front. A queue would hand over every version in turn, and while its viewer is slow it would grow by one snapshot per score. A slot holds one snapshot however far behind its viewer falls, so a slow viewer costs the server the same memory as a fast one.
Versions 4, 5 and 6 are offered before the viewer's thread takes. What does take return?
An event wakes the sender
The thread that will send a viewer its snapshots needs to sleep while the slot is empty and wake as soon as something arrives. threading.Event does that. An event is a flag that starts clear: set() raises it, clear() lowers it, is_set() reports it, and wait(timeout=...) blocks until the flag is set or the timeout runs out. wait returns True if the event was set and False if the time ran out first. In CPython a wait with a timeout returns as soon as another thread calls set(); it does not sleep out the whole timeout. The outbox relies on exactly that.
In Networks with Python, rendezvous in fakes.py used an event, and that lesson said you would not need one yourself. Now you do. Here is one thread waiting and another setting:
import threading
ready = threading.Event()
def sender():
if ready.wait(timeout=2.0):
print("woken by set()")
else:
print("two seconds passed and nothing came")
thread = threading.Thread(target=sender)
thread.start()
ready.set()
thread.join(timeout=3.0)
sender prints woken by set() at once. Remove the ready.set() line and it prints the other line after two seconds.
Nothing sets an event for half a second. What does event.wait(timeout=0.5) return, and when?
wait_and_take(outbox, timeout) is the sender’s half: wait on the outbox’s event with the timeout it was given, then return take(outbox). If nothing came, the slot is empty and take returns None, so the caller learns that the time ran out. The timeout matters even though the event wakes the thread early: Lesson 3 uses the quiet moments to look at the viewer’s connection.
Store and set together
The event must be set exactly when a snapshot waits. If it were clear while a snapshot waited, the sender could sleep through a score it should send. So offer stores the snapshot and sets the event inside one with outbox["lock"]: block, and take reads the slot, empties it and clears the event inside one block too. Then no other thread can see the slot and the event disagree.
Consider a take that clears the event after its with block ends. Between the end of the block and the clear(), another thread can run an offer: it stores a new snapshot and sets the event. Then the late clear() lowers it. A snapshot now waits under a clear event, and the viewer’s thread may sleep instead of sending it.
take clears the event after its with block ends. What can go wrong?
wait_and_take waits outside the lock. While it waits, an offer must be able to take the lock and fill the slot.
Two locks, one order
The board has its lock, and every outbox now has its own. Two threads that take two locks in opposite orders can wait for each other forever. That cannot happen here, because the order is always the same. offer will run while apply_score holds the board lock: board lock first, then an outbox lock. A viewer’s thread takes the board lock only to join and to leave, never while it holds its outbox’s lock. No thread ever holds an outbox lock and then asks for the board lock.
Your task
In scoreboard_server.py, write the four Lesson 2 functions:
make_outbox(): a new dict with"snapshot": None, a lock fromthreading.Lock()and an event fromthreading.Event();offer(outbox, snapshot): insidewith outbox["lock"]:, store the snapshot and set the event;take(outbox): insidewith outbox["lock"]:, read the slot, set it toNone, clear the event and return what you read;wait_and_take(outbox, timeout): wait on the event withtimeout=timeout, then returntake(outbox).
Call threading.Lock() and threading.Event() through the make_outbox. The check replaces the threading module with a stand-in that records every lock and event, and it cannot see one made when the file is imported or one imported with from threading import.
Run
Press Run. The two Lesson 2 sections of scoreboard_demo.py now use your functions. The first offers three snapshots to one outbox and takes twice; the second starts a thread in wait_and_take(outbox, 2.0) and then offers version 9. A finished version, run on the site, printed:
Lesson 2: an outbox keeps only the newest snapshot
after three offers, the event is set: True
take: {'type': 'snapshot', 'version': 3, 'scores': {}}
take again: None and the event is set: False
Lesson 2: a waiting thread wakes up
the waiting thread took version 9
The waiting thread took version 9 without sleeping out its two seconds: once the event was set, wait returned. Then press Submit.
Lesson 3 gives every viewer its own outbox and moves each send onto that viewer’s own thread.
Task
In scoreboard_server.py, write the four Lesson 2
make_outbox(): return{"snapshot": None, "lock": threading.Lock(), "event": threading.Event()}, a new lock and a new event on every call.offer(outbox, snapshot): insidewith outbox["lock"]:, putsnapshotinoutbox["snapshot"], replacing anything waiting, and setoutbox["event"].take(outbox): insidewith outbox["lock"]:, readoutbox["snapshot"], set it toNone, clear the event, and return what you read (Nonewhen nothing waited).wait_and_take(outbox, timeout): calloutbox["event"].wait(timeout=timeout)outside the lock, then returntake(outbox).
Write threading.Lock() and threading.Event() through the make_outbox: the check replaces scoreboard_server’s threading module with a stand-in that records every lock and event, and it refuses a lock or event made when the file is imported, or one brought in with from threading import.
Press Run: the two Lesson 2 sections print take: {'type': 'snapshot', 'version': 3, 'scores': {}}, take again: None and the event is set: False and the waiting thread took version 9.
Then press Submit. The check offers snapshots you have not seen (versions 17, 18 and 19) to a new outbox and expects take to return version 19, a second take to return None, the event to be set after each offer and clear after each take, and every set and clear to happen while that outbox’s lock is held. It offers a thousand snapshots and expects one in the slot, never a wait_and_take(outbox, 0.75) and expects 0.75 to reach wait, the waiting snapshot to come back, and None when nothing waits. acquire() and release() in place of with are fine, and so is a wait_and_take that returns None without taking when wait returns False.