> Colorless Functions
Published:
With the release (or re-release) of Zig’s New Async I/O , I wanted to throw my hat in the ring with an attempt to design colorless functions in Talos for it’s concurrency model.
Recapping Async/Await
Function coloring stems from the language syntax that describes to the runtime how a function should be executed. Functions of a particular color may then only be called from a function of the same color.
Here are some excellent posts that go into more detail that I strongly recommend:
- Futures Aren’t Ersatz Threads by Marius Eriksen (April 2, 2013)
- What Color is Your Function? by Bob Nystrom (February 1, 2015)
- What is Zig’s “Colorblind” Async/Await by Loris Cro (June 21, 2020)
- Let Futures be Futures by Without Boats (February 3, 2024)
All of these posts explain in varying yet strikingly similar terms how the asynchronous calling conventions for certain languages *cough* JavaScript *cough*, result in an upwards poisoning effect where parent functions are also forced to be asynchronous. An example of this can be found as:
function blue() { await red(); /** an error would occur here */ }
async function red() { blue(); await red(); /** both valid calls */ }This is similar to Zig’s previous model of concurrency that is described by Without Boats, which expresses coroutines with async/await but does not require the async declaration for functions.
const net = @import("std").net;
// The previous, original declaration to enable evented I/O
pub const io_mode = .evented;
pub fn main() !void {
const addr = try net.Address.parseIp("127.0.0.1", 7000);
var sendFrame = async send_message(addr);
try await sendFrame; // And await the message
}
// Note how the function definition doesn't require the `async` marking
fn send_message(addr: net.Address) !void {
var socket = try net.tcpConnectToAddress(addr);
defer socket.close(); // Defer the close handler.
_ = try await async socket.write("Hello World!\n");
}Inversion of Concurrency
Eventually, Zig decided to remove the async/await keywords and move towards a userland implementation instead. This led to the introduction of a new interface that would handle all asynchronous execution and ultimately led to the following changes to the calling convention for coroutines in Zig:
const std = @import("std");
// Some long-running task that
fn task(io: std.Io, data: []const u8) !void {
std.debug.print("Hello, {s}!\n", .{data});
}
// Handles executing multiple tasks
fn execute(io: std.Io) !void {
var a = io.async(task, .{"Future A"});
var b = io.async(task, .{"Future B"});
try a.await(io);
try b.await(io);
}For a more detailed overview of Zig’s new async I/O, Loris Cro has a great secondary post doing just that.
The change from using await as a keyword to instead being a method the Future struct contains, effectively inverts the previous calling conventions. This is possible as all await calls effectively block the current execution of a function. This removal has a few benefits, such as allowing all functions to possible be called asynchronously regardless of declaration, user-defined scheduling based on the runtime dependency std.Io, and improving method-chaining for nested properties.
// Some awaitable structure the is generated.
const promise = new Promise(...);
// With the `old` calling convention:
(await promise).property();
// With Zigs `new` calling convention:
promise.await().property();However these benefits come at one major cost to use, the dependency on std.Io for execution and awaiting. In their attempt to remove function coloring, Zig has come full-circle and reinvented the wheel as all asynchronous functions now require a runtime structure to emulate the removed await call.
In allowing await method calls to occur in any context, async calls are instead only callable with a runtime available. This could be circumvented by using a global variable for the runtime, however the evilness of such an action has been extensively discussed.
Unconventional Conventions
With all things considered, solving colorless functions appears to be a daunting problem. However, perhaps using a mix of conventions could be the solution.
Awaiting Futures
Let’s begin our design by using Talos and keeping Zig’s Future object so that future.await() is still valid. This would keep the benefits previously mentioned. For the creation of futures, we could start by using a global variable for launching asynchronous functions to circumvent the runtime dependency that Zig contains.
// Some long-running task callback
let callback = fn { ... };
// Launch a task with `...` arguments
let future = Future.async(callback, ...);
// And then await for the result from anywhere
let result = future.await();Although this does exhibit the aforementioned evil global variable, since Talos implements concurrency internally with green threads (eg: userland cannot modify scheduling), this is a perfectly acceptable solution.
But can we improve on this and allow for launching multiple runtime targets?
Deferring Futures
We could take Zig’s original syntax for starting coroutines by using the async callback(...) syntax. However this exhibits similar readability issues to the (await future).property() style. So let’s instead apply the async keyword as an infix-operator instead of as a prefix-operator. This would give us the following syntax:
// Possible inversion of the `async` operator
let future = callback async(...);
// Alternatively any operator sigil could be used
let other = callback::(...);
// This allows us to keep our clean method-chaining
callback async().await().property();
callback::().await().property();This could allow us to bind alternate runtimes if this capability is possible in the underlying language. Alongside this, we can simply remove the async operator or the sigil operator (if used), to change back to a blocking non-concurrent function call.
// Potential runtime mixin for `async` operators
let future = callback async[runtime](...);
// Alternative runtime mixing for a sigil operator
let other = callback:runtime:(...);Note: Currently Talos uses the
Future.asyncstatic method to launch asynchronous execution. Eventually I would like to expand on this with the above syntax, but like any other programming language architect, I think there is still some room for improvement with the syntax (at least with readability, but this may stem from how unconventional this syntax is).
Alternatively, we could have exposed a .async method on every function so that we could execute asynchronous tasks with callback.async(...), however I strayed away from this as it somewhat muddies function typing (at least sadly for Talos). But this alternative could work well for other language models.
Joining Threads
Quite clearly, these speculations for potential async/await syntax exceed the status quo for a typical programming language with concurrency. However I think that inverting the normal conventions as Zig has done will lead to a language with truly colorblind functions. As such, also moving asynchronous execution to function calls instead of their declarations could lead to the final thread to finish the concurrency tapestry.