0%

More Than One Client · practice

A Thread per Connection

Since Chapter 4, every pl_net.py run has had two sides talking at once inside one program. pl_net, the helper written for this course that plays the program on the other end, played one side; your code played the other. A program normally does one thing at a time, so how did both sides run together? pl_net ran your code in a thread: a second line of work inside the same program, with its own place in the code and its own waits, sharing the program’s . While your server sat in accept() or recv, pl_net’s own line of work went on sending and reading. That is also what the last line of every transcript, pl_net: every thread has stopped, has been telling you: before it finished, pl_net checked that no line of work was left running.

Your server can do the same: give each client its own thread, so a silent client holds up only itself.

A thread runs a function

The threading from the standard library makes threads. Here is the shape, with a small standing in for real work:

import threading


def count_down(name, start):
    while start > 0:
        print(name, start)
        start -= 1


worker = threading.Thread(target=count_down, args=("worker", 3))
worker.start()
print("start() has returned")
worker.join(2.0)

threading.Thread(...) only prepares the thread. target is the function the thread will run, passed as a , without parentheses, the way Chapter 4 passed connect. args is a of the it will be called with. start() begins the thread and returns at once, so print("start() has returned") runs while count_down may still be counting, and its line can appear before, between or after the countdown’s lines. join(2.0) waits until the thread has finished or 2 seconds have passed, whichever comes first. A join without a timeout could wait forever, which no program in this course does.

What goes wrong with threading.Thread(target=serve_client(conn, board, outcomes))?

One thread per client

serve keeps its , its count and its idle timeout. The one change is what happens after accept() and conn.settimeout(idle_timeout). Instead of calling serve_client(conn, board, outcomes) and waiting for it, serve starts a thread that will call it:

thread = threading.Thread(target=serve_client, args=(conn, board, outcomes))
thread.start()
threads.append(thread)

Nothing inside serve_client or handle_client changes. serve_client still closes the connection and appends the outcome; it now does so in its own thread. serve keeps every thread in a , and the loop counts the threads it started, so max_connections still bounds how many clients are served and how many threads exist. This course keeps every run to eight client threads or fewer, and pl_net’s scenarios in this chapter pass max_connections=2.

The target is serve_client, not handle_client, because the thread now owns the connection. serve_client is the one place a connection is closed, so serve must not close conn or use with conn: itself: it would close the connection while the thread is still reading from it.

After the loop, serve joins each thread in the list with JOIN, the 2-second constant at the top of board_server.py, and then returns the outcomes. With pl_net’s idle_timeout=1.0, a silent client’s thread ends after its one-second idle timeout, inside that bound. A client that keeps sending requests could keep its thread busy for longer; then join returns after 2 seconds anyway and serve returns without that client’s outcome. Lesson 5 makes serve report such a thread.

Follow each client from the listener to the thread that serves it:

The listener hands each of three accepted connections to its own thread running serve_client, and the three threads run at the same time, all using one board and one outcomes list.

In words: the loop hands each accepted connection to a thread of its own and goes straight back to accept(). Three threads work at the same time, and all of them use the same board and append to the same outcomes list.

serve starts each thread and joins it before the next accept(). What happens?

One list, many threads

Every thread appends its outcome to the same outcomes list. That is safe to share. In CPython, the Python this course runs, one list.append is a single step that no other thread can interrupt halfway, and the Python documentation’s FAQ lists it among the operations that are safe to share. What changes is the order: outcomes now follow which client finished first, not which connected first.

Lesson 3 shows a change to shared data that takes two steps, and what a second thread can do between them.

Why does serve join every thread before it returns?

Your task

Rewrite serve in board_server.py:

  1. Keep the listener’s timeout, the outcomes list and the loop. Make an empty list for the threads, and loop while fewer than max_connections threads have been started.

  2. After accept() and conn.settimeout(idle_timeout), start a threading.Thread with target=serve_client and args=(conn, board, outcomes), and add it to the list.

  3. After the loop, join every thread with JOIN, then return outcomes.

Keep import threading at the top of the file and write threading.Thread(...). Check my work replaces the module with a stand-in that records every thread, which a from threading import Thread line would hide.

Run the scenario from Lesson 1 again:

python pl_net.py server slow-client
client 1: connected and sends nothing
client 2 -> server  frame {"op": "post", "text": "still here"}  (36-byte body)
server -> client 2  frame {"ok": true, "id": 1}  (21-byte body)
client 2: closed the connection
client 1: the server closed my connection
client 2 was answered while client 1 was still connected
--- result ---
your serve() returned ['ok', 'timed out']

Client 2 was answered while client 1 sat silent, and its "ok" now comes first because it finished first. Client 1 still got its idle timeout, in its own thread. python -m pytest still passes the outcomes test, whose posting client hangs up before serve runs.

Then press Check my work.

Several threads now change one board at the same time. The next lesson shows what two posts at the same moment can do to next_id.

Task

Rewrite serve(listener, board, *, max_connections=5, idle_timeout=WAIT) in board_server.py so it serves each client in a thread of its own:

  • Keep listener.settimeout(idle_timeout) before the , stop when accept() raises TimeoutError, and call conn.settimeout(idle_timeout) on every accepted connection.

  • For each accepted client, start one threading.Thread(target=serve_client, args=(conn, board, outcomes)), with the same outcomes for every thread. Start no more than max_connections threads.

  • Start every thread before joining any. After the loop, join each thread with a timeout of JOIN, then return outcomes.

  • Leave the listener open.

Keep import threading and write threading.Thread(...), with no from threading import line. Keep serve_client and handle_client as they are.

In the , run python pl_net.py server slow-client and python -m pytest.

Then press Check my work. The check hands serve a stand-in listener with clients you have not seen, more clients than max_connections, and a stand-in for the threading that records every thread. It checks one thread per accepted client, the target and its , one shared outcomes list, that every thread started before the first join, that every join has a timeout of at most 2 seconds, both timeouts, and that the listener was left open. It does not read your terminal or the pl_net.py transcript.