Friday, 18 July 2025

Kotlin Coroutines Revisited. Part 3: Event Loops and More

Part III of this series of posts [1] and [2] revisiting some Kotlin coroutines concepts (that' I've managed to better understand thanks to some GPT (Claude) discussions). This time we'll see a pretty interesting low level detail about the Kotlin asynchronous system. JavaScript and Python asynchrony revolves around an event loop. This event loop is not accessible from JavaScript, but in Python we even can interact with it (though this is rarely necessary unless we are developing some low level library).

Main idea: We can say that in asynchronous systems there are 2 main actions. Waiting for IO events to happen, and managing a queue with the tasks to be invoked (the callbacks that were set in our awaitables (Promises, Futures), resuming continuations...) when those IO events (new data available) happen. JavaScript and Python asyncio manage both aspects, while kotlinx.coroutines manages the task to be invoked, but the awaiting of IO events is done by additional libraries.

There are many articles about the node.js event loop, describing it with different levels of detail (which makes it confusing some times), but the very basic idea is that we have a loop that waits for IO events (a network response, a file read, a timer...) using epoll (or kqueue or whatever the OS provides). When those events happen, the callback for the event goes to the macrotask queue, and when that callback is executed it will normally resolve or reject a Promise, and the "then-catch callbacks" for that Promise go to the microtask queue. The mechanism in Python is similar. When the last function in a chain of async calls performs a real asynchronous operation, it will obtain a file descriptor (fd) and add it to the event loop list of "events to watch" with ev_loop.add_reader(fd, callback_fn). It also creates a Future object (fut1). That callback_fn will take care of invoking fut1.set_result() sometime in the future, which in turn will invoke Future._schedule_callbacks. This _schedule_callbacks will add to the event loop queue of actions the callbacks that had been set in that Future via add_done_callback(). This is done via loop.call_soon(callback, self, context=ctx) (that instructs the event loop to run those callbacks in its next iteration).

The thing is that in both JavaScript and Python a single event loop (running in a single thread) manages, in the different phases of each of its iterations, both awaiting for the IO events that will resolve/complete Promises/Futures (awaitables) and executing the callbacks for the resolution/completion of those Promises/Futures (callbacks that will take care of resuming our paused/suspended async functions/coroutines).

In Kotlin this is a bit different. Let's say that the awaiting for the IO events and the "execution of the callbacks" (that in Kotlin means resuming the suspend functions (the coroutine) via the continuation mechanism), happen in 2 different levels. We should already know about Coroutine Dispatchers, that are an essential part of the Kotlin coroutines machinery. These Dispatchers decide where to start the coroutine and where to resume it. "Coroutine Dispatchers determine the thread or thread pool where a coroutine executes. They play a critical role in managing how and where coroutines run". It can be in a thread pool or in a thread running an event loop. Dispatcher.IO and Dispatcher.Default use a thread pool. Dispatcher.Main will use the event loop in the Main UI Thread (in Android, Java FX, Swing... applications), and a coroutine created with the runBlocking coroutine builder will also run in an event loop (an EventLoop is indeed a Coroutine Dispatcher, as you can see here):
internal abstract class EventLoop : CoroutineDispatcher()

So those dispatchers (part of the kotlinx.coroutines library) manage the start and resumption of our coroutines, but what about waiting for the IO events (IO asynchronous calls)? That depends on the different libraries that we use for those IO operations, it's not part of the kotlinx.coroutines library, but a lower level layer. If we use netty for performing http requests I think it will manage the low level IO via some kind of low level event loop based on NIO (something like this). Other libraries performing non blocking IO will use its own event loop (or a thread pool). When the data is available, their callbacks will invoke continuation.resume, and we'll move back into our Kotlin dispatchers (the upper level layer). This means that in a same process we can have multiple event loops running, a high level one for the Kotlin dispatchers layer and 1 or more low level ones (using low level mechanisms like NIO, epoll) for the different non blocking IO libraries. The same happens for thread pools. This can feel like a bit of redundant.

On one hand the JavaScript and Python async systems seem more integrated. Python IO libraries have access not just to Futures (on which to set results), but to the asyncio event loop itself where they can register file descriptors for the IO operations, as the asyncio event loop watches for IO events on those descriptors (via epoll or whatever). In Kotlin that's not like that because kotlinx.coroutines does not provide a mechanism for watching for the IO events (it stays in an upper level, it does not go down to the epoll and file descriptors underworld), it's the libraries who have to take care of that at that lower level stuff, and then jump into the upper level by invoking continuation.resume through a callback.

On the other hand, the Kotlin asynchronous model is more versatile, with its powerful dispatchers, that can use a single thread event loop and/or thread pools. Indeed, Dispatcher.IO uses a Thread pool optimized for IO operations and Dispatcher.Default a thread pool optimized for CPU bound operations.

No comments:

Post a Comment