Time to put the day together. Four servers hold logs. One is quick, one is a little slower, one refuses the connection, and one simply never answers. The analyzer needs whatever it can get, in about the time of a single fetch, and a clear line saying what happened to each server.
Day 6 checkpoint
Collect, count, report
sources.py is the simulated network. Don’t change it. fetch_log(server)
behaves like a real download, and records each closed connection in
sources.closed.
In collector.py, write:
fetch_safely(server, timeout), as in the last lesson: aResultwith status"ok","failed"or"timeout", and bugs allowed to escape.collect(servers, timeout): every server’sResult, fetched at the same time, in the order given.error_counts(results):{server: number of ERROR lines}, for the servers that answered.report(results): one line of text per server, in the formattest_reportshows.
Until collect works, the whole TestCollect class fails at once, in
setUpClass. It runs collect a single time, and every test in the class
checks that one result.
Hint 1: the idea
Two of the four are this week’s lessons, reused. fetch_safely is the last
lesson’s function. collect runs one fetch_safely per server at the same
time, in the given order — which is gather — and because fetch_safely never
raises for the expected failures, one bad server can’t sink the rest. The other
two are ordinary Python over a list of Results.
Hint 2: a first step
Bring your fetch_safely across, then write collect as a single gather over
fetch_safely(server, timeout) for each server. Once TestCollect stops
failing in setUpClass, its individual tests tell you exactly what
error_counts and report must produce.
Show one solution
async def fetch_safely(server, timeout):
"""One server's Result. Expected network failures become data; bugs escape."""
try:
async with asyncio.timeout(timeout):
lines = await fetch_log(server)
except TimeoutError:
return Result(server, "timeout", error=f"no answer after {timeout}s")
except ConnectionError as error:
return Result(server, "failed", error=str(error))
return Result(server, "ok", tuple(lines))
async def collect(servers, timeout):
"""A Result for every server, fetched all at once, in the order given.
Expected failures are Results. A bug escapes, and cancels the other fetches.
"""
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(fetch_safely(server, timeout)) for server in servers]
return [task.result() for task in tasks]
def error_counts(results):
"""{server: number of ERROR lines} for every server that answered."""
return {r.server: sum(1 for line in r.lines if line.split()[2] == "ERROR")
for r in results if r.status == "ok"}
def report(results):
"""One line of text per server, as the tests show."""
return [f"{r.server:<10}{r.status:<10}{f'{len(r.lines)} lines' if r.status == 'ok' else r.error}"
for r in results]The whole collection takes as long as the timeout, 0.3 seconds, however many servers there are. The servers are all fetched at once, and the slowest possible answer is “no answer after 0.3s”.
Each part stays small because each part has one job. fetch_safely decides
what a failure means. collect decides when things run. The last two
functions don’t know anything about asyncio at all: they take plain results and
return plain data. That’s the same split as rules and program on Day 1.
Why a TaskGroup rather than gather? Both run the fetches at once, and both
let a bug escape. The difference is what happens to the other fetches. The
last test asks for a server that doesn’t exist alongside payments, which
takes five seconds. Then it checks, straight away, that payments was cancelled
and its connection closed. With gather, the KeyError is raised, but
payments is still running in the background. With a TaskGroup, it’s
cancelled before collect raises. The bug arrives inside an ExceptionGroup,
which is why the test catches it with except* KeyError.
test_servers_are_fetched_at_the_same_time doesn’t time anything. sources.py
counts the fetches in progress, and the test checks that all four were in
progress at once. It checks overlap rather than elapsed speed. (The suite
still has real timeouts in it, payments against timeout=0.3 for one, so it
isn’t completely independent of how busy the computer is. It just doesn’t ask
for speed.)
The Day 6 reference solution
Today was standalone, so this is the complete collector rather than the
analyzer. main.py fetches all four servers, prints the report, then runs the
tests.
Working on your own machine? Download the Day 6 files and carry on there. You need Python 3.12 or later, and nothing else.
Tomorrow the analyzer returns, to be finished, logged and given a command line.
Fetching logs from servers would plug in as one more source of lines, since
parse_lines accepts any iterable of text.
Going further: threads, processes and real HTTP
Optional, and on your own machine
This section needs a local Python. The browser that runs this page has no threads or processes, and its network access goes through the browser. Nothing later in the course depends on it.
asyncio overlaps waiting, on one thread. Python has two other tools for doing several things at once, and each suits a different kind of work.
Threads, for blocking calls. Many libraries block: requests.get,
time.sleep, most database drivers. Run each call on its own thread and the
waits overlap, without rewriting the library. asyncio.to_thread does this from
async code. concurrent.futures.ThreadPoolExecutor does it from ordinary code:
import asyncio
import time
from concurrent.futures import ThreadPoolExecutor
SERVERS = ["auth", "payments", "search"]
def blocking_fetch(server):
time.sleep(0.3) # a blocking call, like requests.get(...)
return f"{server} log"
async def with_asyncio():
return await asyncio.gather(*(asyncio.to_thread(blocking_fetch, s) for s in SERVERS))
def with_threads():
with ThreadPoolExecutor() as pool:
return list(pool.map(blocking_fetch, SERVERS))
for name, run in [("to_thread", lambda: asyncio.run(with_asyncio())), ("thread pool", with_threads)]:
start = time.perf_counter()
print(f"{name:12} {run()} in {time.perf_counter() - start:.1f}s")
Both take 0.3 seconds for three 0.3-second waits.
Processes, for heavy calculation. In the standard build of CPython, only one thread runs Python code at a time. That rule is the GIL, the global interpreter lock. So threads overlap waits, but not calculation. Separate processes each have their own interpreter, and really do run in parallel on several CPU cores:
import time
from concurrent.futures import ProcessPoolExecutor
def crunch(n):
return sum(i * i for i in range(n))
if __name__ == "__main__": # required: each process imports this file
jobs = [20_000_000] * 4
start = time.perf_counter()
[crunch(n) for n in jobs]
print(f"one process: {time.perf_counter() - start:.1f}s")
start = time.perf_counter()
with ProcessPoolExecutor() as pool:
list(pool.map(crunch, jobs))
print(f"four processes: {time.perf_counter() - start:.1f}s")
On a four-core laptop, the second number is roughly a third of the first. Processes cost more to start, and everything sent to them has to be copied across, so they pay off only for real work.
There’s also a free-threaded build of CPython without the GIL, where threads can calculate in parallel too. It arrived as an experiment in 3.13 and is officially supported from 3.14, as a separate, optional build. It isn’t the default yet, and some libraries don’t support it yet, so check before relying on it.
| Kind of work | Use |
|---|---|
| Many waits, with async libraries | asyncio |
| Many waits, with blocking libraries | threads (to_thread, ThreadPoolExecutor) |
| Heavy calculation | processes (ProcessPoolExecutor) |
Real HTTP. The standard library’s urllib blocks, so from async code, run
it in a thread:
import asyncio
import urllib.request
URLS = ["https://www.python.org", "https://docs.python.org/3/", "https://pypi.org"]
def get_status(url):
with urllib.request.urlopen(url, timeout=5) as response:
return response.status
async def main():
results = await asyncio.gather(*(asyncio.to_thread(get_status, url) for url in URLS),
return_exceptions=True)
for url, result in zip(URLS, results):
print(url, result)
asyncio.run(main())
For real projects, the third-party httpx library has a native async client
(httpx.AsyncClient), which needs no threads at all. On macOS with Python from
python.org, a CERTIFICATE_VERIFY_FAILED error means the certificates haven’t
been installed yet. Run Install Certificates.command from the Python folder in
Applications.
What you can now do
- Fetch many sources concurrently with a time limit, and report on every one of them, whatever happened.
- Keep the async part of a program small, with plain functions around it.
- Choose between asyncio, threads and processes by the kind of work.