Our Loop vs asyncio
A Go-Kart and a Factory Car
Imagine you build a go-kart in the garage. You pick every bolt, you know what each lever does, and when it rattles you know exactly why. Next door, a factory car rolls off the production line: heated seats, airbags, a roof, a thousand safety tests. Both have an engine, a steering wheel and four wheels. Both get you down the road.
PyNet's event loop is the go-kart. Python's built-in asyncio is the
factory car. We built the go-kart so that we would understand the car.
The ideas are the same; the names are very close; the factory car has
many extras that a learner does not need on day one.
Go-kart (pynet.loop) Factory car (asyncio)
┌─────────────────────┐ ┌─────────────────────────────┐
│ engine: select() │ │ engine: select / epoll / │
│ wheel: Task │ │ kqueue / IOCP │
│ brake: Cancelled │ │ wheel: Task, TaskGroup │
│ no roof, no radio │ │ brake: CancelledError │
└─────────────────────┘ │ roof (TLS), radio (many │
~ a few small files │ protocols), airbags ... │
└─────────────────────────────┘
Side by Side
Every name in the left column is a real public name you can import from
pynet.loop, pynet.streams or pynet.process.
| Idea | PyNet | asyncio |
|---|---|---|
| The waiter | EventLoop |
the event loop (asyncio.get_running_loop()) |
| Start the waiter | run(main()) |
asyncio.run(main()) |
| A promise | Future |
asyncio.Future |
| A running coroutine | Task, made by spawn(coro) |
asyncio.Task, made by asyncio.create_task(coro) |
| Stopped on purpose | Cancelled |
asyncio.CancelledError |
| Nap | sleep(seconds) |
asyncio.sleep(seconds) |
| Time limit on a block | async with timeout(seconds): |
async with asyncio.timeout(seconds): |
| Time limit on one await | wait_for(aw, seconds) |
asyncio.wait_for(aw, seconds) |
| Many at once | gather(*aws, return_exceptions=...) |
asyncio.gather(*aws, return_exceptions=...) |
| Event, Lock, Semaphore, Queue | Event, Lock, Semaphore, Queue |
the same four names in asyncio |
| Blocking call in a thread | run_in_thread(fn, *args) |
asyncio.to_thread(fn, *args) |
| Read from a socket | StreamReader |
asyncio.StreamReader |
| Write to a socket | StreamWriter |
asyncio.StreamWriter |
| Connect | open_connection(host, port) |
asyncio.open_connection(host, port) |
| Serve | start_server(handler, host, port) |
asyncio.start_server(handler, host, port) |
| Run a child program | create_process(args) |
asyncio.create_subprocess_exec(*args) |
| Read exactly n bytes | read_exactly(n) |
readexactly(n) |
| Read up to a separator | read_until(sep) |
readuntil(sep) |
A few differences worth noticing:
- Our
Taskis made withspawn, notcreate_task. - Our
gatherandwait_forare plainasync deffunctions, and cancelling agathercancels every child it started. create_processtakes a list (["echo", "hi"]), and gives you pipes asStreamReader/StreamWriterobjects pluskill_groupfor stopping a child and everything it started.run()installs the child watcher for you (see The Self-Pipe Trick and Subprocesses).
The Same Program, Twice
This program runs on both loops. Only the imports and two names change.
import asyncio
from pynet.loop import gather, run, sleep, spawn, wait_for
async def worker(name, delay):
await sleep(delay)
return f"{name} done"
async def ours():
task = spawn(worker("kart", 0.01))
others = await gather(worker("a", 0.02), worker("b", 0.01))
return [await wait_for(task, 1.0), *others]
async def theirs():
async def worker(name, delay):
await asyncio.sleep(delay)
return f"{name} done"
task = asyncio.create_task(worker("car", 0.01))
others = await asyncio.gather(worker("a", 0.02), worker("b", 0.01))
return [await asyncio.wait_for(task, 1.0), *others]
print(run(ours()))
print(asyncio.run(theirs()))
Output:
What asyncio Adds That We Skipped
The factory car has features we left out on purpose, to keep the go-kart small enough to read in an afternoon:
- TLS on the event loop.
asynciocan wrap a connection in encryption (ssl=onopen_connectionandstart_server). Our streams are plain TCP; the TLS page is a separate simulation of the handshake. - Windows. asyncio has a "proactor" loop built on Windows I/O
completion ports. PyNet is macOS and Linux only (it uses
select, POSIX signals and process groups). - Faster engines. asyncio can swap in other loops, such as the
C-based
uvloop, and its own loop has C-accelerated parts forFutureandTask. Ours is plain Python, written to be read, not raced. - Structured concurrency.
asyncio.TaskGroup(andasyncio.timeoutnested inside it) starts a group of tasks and waits for all of them, cancelling the rest if one fails. We havegatherandtimeout, but noTaskGroup. - More ways to wait.
asyncio.wait,as_completed,shieldand friends. We keptgather,wait_forandtimeout. - More transports and protocols. UDP datagram endpoints, Unix domain
sockets, the low-level
Protocol/Transportcallback API, andloop.sock_*helpers. Our streams sit directly on non-blocking sockets and pipes. - More sync tools.
Condition,Barrier,PriorityQueue,LifoQueueand more. - Debug mode and tooling. Slow-callback warnings, task introspection,
contextvarssupport in every task and a large test suite built over many years.
Building the go-kart shows that none of these is magic: each one is "a bit more of the same ideas".
When to Use
- Use PyNet's loop to learn, to read, and to experiment. Put a
printinside the reactor and watch it work. - Use asyncio for real programs: it is faster, safer, runs on every platform and works with the whole ecosystem.
What We Test
Each name in the table above is exercised by the loop, streams and process test suites. The "Same Program, Twice" snippet was run on both loops and printed the output shown.
Next Up
That is the end of the real-networking path. Go back to the
Event Loop and watch the waiter again with new eyes, or
open the examples in examples/ and run them.