- 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.