Async in C
C# has an amazing feature called async, which we’ve talked about many times before on this blog, which allows a programmer to write functions that are non-blocking, without needing to use threads. What would it look like to have async functionality in C?
I’ve been working on an experimental “mini-programming-language” which does just that. It doesn’t work the same way as in C#, because the needs in C are completely different. In C you don’t want all the hidden overhead costs that exist in C# related to setting up tasks and delegates and execution context. In C, things should be more or less as they seem.
What does it look like?
In this experimental language, you can declare functions as async
, to say that they don’t complete immediately in a blocking fashion, but instead may complete asynchronously. When functions are declared async
, some interesting things happen. One thing is that, in the context of the async function, the word return
actually refers to a function, which can be called and saved like any other function. For example, here is an async
function foo
which returns the value 1
.
async int foo() { return(1); }
Of course this function actually returns synchronously, even though it’s declared async
. The only difference is that it’s returning using continuation passing style instead of a direct return. But using this feature we could actually delay the return to another point in time:
Continuation<int> savedFooReturn; async int foo() { savedFooReturn = return; } void someEventOccurs() { savedFooReturn(1); }
Now we’ve saved the return continuation and only triggered it when some event returns. We discussed last time what a continuation might actually look like in C, so this week we’ll just elide the type details and say that a continuation is of type Continuation<T>
, where T
is the return type of the function calling the continuation. Values of this type are each physically the size of a single pointer, and can be executed using the same syntax as a function.
Now comes the interesting bit. Say we have a function, bar
, which calls foo
. In this experimental language, you can simply define bar
like this in this experimental language:
void bar() { // ... Some code ... int x = foo(); // ... Some more code ... return x + 1; }
Now clearly bar must be asynchronous as well, since it calls foo
, and depends on the result of foo
before it can continue. But the magic is that we don’t need to declare the asynchrony explicitly. The experimental language compiler not only infers the asynchrony, but does the corresponding conversion to CPS automatically.
This is more than just a minor convenience. Imagine code like this:
int bar() { int total = 0; for (int i = 0; i < 4; i++) total += foo() * 2; return total; }
This is a relatively simple function, but if we had to write the asynchrony out explicitly in C we would have code like the following1:
struct bar_state { void (*continuation)(int); void (**return_)(int); int total; int i; struct foo_state fooState; }; void bar(struct bar_state* here, void (**return_)(int)) { here->total = 0; here->return_ = return_; here->i = 0; here->continuation = barContinuation; foo(here->fooState, &here->continuation); } void barContinuation(struct bar_state* here, int fooResult) { here->total += fooResult * 2; here->i++; if (here->i < 4) { here->continuation = barContinuation; foo(here->fooState, &here->continuation); } else (*here->return_)(here->return_, total); }
This is beginning to look like unstructured code. The for-loop construct is now completely hidden, and looks more like the old days of conditional-branch-and-jump. We’re also worrying about things that the compiler really should be sorting out for you. Like passing in the return address, passing in a space for storage of local variables, etc. These are all things you generally don’t have to worry about, so why now?
The experimental language I’m working on handles all of this asynchrony automatically. If the above examples are anything to go by, then certain types of code will be reduced to a quarter of their size and be orders of magnitude easier to read, if they were written in this experimental language instead of C. I would perhaps go as far as saying that a fair amount of multithreaded code could instead be written in this async style to run on a single thread, and would as a result be much easier to reason about.
again, using the continuation style defined in my last post ↩