blog

git clone https://git.ce9e.org/blog.git

commit
600082203e23f686c82c00f7de55443431c5e1cd
parent
cc57ba266c868a0fcb88a9da57dd76dc5481ad6f
Author
Tobias Bengfort <tobias.bengfort@posteo.de>
Date
2026-04-20 16:31
post: more async loops

Diffstat

A _content/posts/2026-04-19-more-async-loops/index.md 131 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

1 files changed, 131 insertions, 0 deletions


diff --git a/_content/posts/2026-04-19-more-async-loops/index.md b/_content/posts/2026-04-19-more-async-loops/index.md

@@ -0,0 +1,131 @@
   -1     1 ---
   -1     2 title: Three more ways to think about asyncronous loop
   -1     3 date: 2026-04-19
   -1     4 tags: [code, async, python]
   -1     5 description: I created a coroutine-only, unix-only, stateless alternative to asyncio.
   -1     6 ---
   -1     7 
   -1     8 At the core of async programming is the idea that you move all I/O to a central
   -1     9 main loop that might look something like this:
   -1    10 
   -1    11 ```python
   -1    12 def main():
   -1    13     while True:
   -1    14         events = perform_blocking_io()
   -1    15         handle_events(events)
   -1    16 ```
   -1    17 
   -1    18 While the general structure is always the same, there are a lot of details that
   -1    19 can be done differently. I have written about [my thoughts on async
   -1    20 programming](https://blog.ce9e.org/posts/2023-01-29-python-async-loops/),
   -1    21 specifically python's asyncio, before. Recently I sat down and did my own [toy
   -1    22 implementation](https://github.com/xi/xiio). As always, this was a valuable
   -1    23 experience and I learned a lot. In this post, I want to share three
   -1    24 distinguishing features which I found helpful to categorize async loops.
   -1    25 
   -1    26 ## Callbacks vs. Suspended Coroutines
   -1    27 
   -1    28 In my previous post I concentrated on the difference between callbacks and
   -1    29 suspended coroutines (async/await). The main benefit of suspended coroutines is
   -1    30 that error handling and resource cleanup just works. Consider this example:
   -1    31 
   -1    32 ```python
   -1    33 async def process_file(path):
   -1    34     with open(path) as fh:
   -1    35         content = await read(fh, 1024)
   -1    36     ...
   -1    37 ```
   -1    38 
   -1    39 If there is any error during the blocking I/O phase, it gets re-raised at the
   -1    40 suspension point, and the file gets closed. Proper cleanup is much harder to do
   -1    41 with callbacks. The downside is that many languages lack a syntax for
   -1    42 suspending execution.
   -1    43 
   -1    44 If a language does have support for both, it is not that hard to convert one
   -1    45 approach to the other. You can call a callback when a coroutine is resumed; and
   -1    46 you can resume a suspended coroutine in a callback.
   -1    47 
   -1    48 Still, idiomatic code for a specific async loop will favor one or the other
   -1    49 approach.
   -1    50 
   -1    51 ## Number of Primitives
   -1    52 
   -1    53 I built my toy implementation around the
   -1    54 [selectors](https://docs.python.org/3/library/selectors.html) module. The idea
   -1    55 is that all blocking I/O can be expressed as waiting for a *file descriptor* to
   -1    56 be ready. After that, you can do different things with that file descriptor,
   -1    57 for example reading a file or receiving from a socket.
   -1    58 
   -1    59 Not every action on can be expressed this way though. The workaround in that
   -1    60 case is to perform the action in a separate thread and then write to a self
   -1    61 pipe. This has some overhead, but it allows to keep the loop itself simple.
   -1    62 
   -1    63 However, half way through development I learned that selectors are not really
   -1    64 a thing on Windows. While Unix has one syscall to wait for resources and then
   -1    65 many different syscalls to use them, Windows has a system called
   -1    66 [IOCP](https://learn.microsoft.com/en-us/windows/win32/fileio/i-o-completion-ports)
   -1    67 that does not have this single function for waiting. Instead, it has many
   -1    68 different functions that wait for a resource and also use it in the same step.
   -1    69 
   -1    70 The proper cross-platform abstraction is probably to expose a large set of
   -1    71 primitives. This might also allow to add better abstractions for the cases on
   -1    72 Unix that cannot directly be expressed as file descriptors.
   -1    73 
   -1    74 In my case, I was quite proud of my small API surface and didn't want to
   -1    75 rewrite everything from scratch. So I just declared my implementation as
   -1    76 Unix-only.
   -1    77 
   -1    78 # Stateful vs. Stateless Loop
   -1    79 
   -1    80 The big decision I made for my implementation was that I wanted to have a
   -1    81 *stateless* loop. Consider this JavaScript snippet:
   -1    82 
   -1    83 ```js
   -1    84 setTimeout(myCallback, 10);
   -1    85 ```
   -1    86 
   -1    87 This only works because there is a global main loop that is used implicitly.
   -1    88 It is *stateful* because it needs to store which callbacks are registered and
   -1    89 under which conditions they should be executed. You can execute multiple
   -1    90 functions concurrently by registering them all on the main loop. The same
   -1    91 structure is also used in asyncio:
   -1    92 
   -1    93 ```python
   -1    94 loop = asyncio.get_running_loop()
   -1    95 loop.call_later(10, my_callback)
   -1    96 ```
   -1    97 
   -1    98 With a stateless loop, on the other hand, each callback must explicitly return
   -1    99 the next callback as well as the I/O instructions that need to be performed:
   -1   100 
   -1   101 ```python
   -1   102 def main():
   -1   103     callback, io = initialize()
   -1   104     while True:
   -1   105         result = perform_blocking_io(io)
   -1   106         callback, io = callback(result)
   -1   107 ```
   -1   108 
   -1   109 An interesting aspect of this approach is that the main loop is only
   -1   110 responsible for performing I/O and then returning control to the application
   -1   111 code. Concurrency is implemented separately by specific functions like
   -1   112 `gather()`.
   -1   113 
   -1   114 In my mind, this is obviously the much cleaner approach. On the other hand,
   -1   115 there are also some benefits to the stateful approach, because it provides a
   -1   116 natural place to handle additional global state like signal handlers or a
   -1   117 central self-pipe. So I understand why other async loops prefer the stateful
   -1   118 approach.
   -1   119 
   -1   120 # Conclusion
   -1   121 
   -1   122 I feel like asyncio has taken the messy approach every step of the way: It
   -1   123 supports both callbacks and suspended coroutines, has a large set of primitives
   -1   124 to abstract over platform differences, and uses a stateful loop.
   -1   125 
   -1   126 My toy implementation is a radical counter example: It is coroutine-only,
   -1   127 unix-only, and stateless, with a small core and modular abstractions on top.
   -1   128 
   -1   129 This experiment taught me a lot about the design of async loops. But it was
   -1   130 ultimately about the lessons, not the product. Those insights now inform how I
   -1   131 use asyncio in a more mindful way.