Help understanding how async is implemented

I’ve been playing around w/ async recently, and I thought I understood it, but I’ve run into something I don’t understand.
I thought that if I have some function that doesn’t have a suspend point can be called in either a sync or async context.

So in this example:

var sus:anyframe = undefined;
var count:u32=0;

fn one() void {
    count += 1;
    two();
    count += 1;
}
fn two() void {
    count += 1;
    three();
    count += 1;
}
fn three() void {
    count += 1;
    suspend {
        sus = @frame();
    }
    count += 1;
}
test "async" {
    _ = async one();
    std.debug.assert(count == 3);
    resume sus;
    std.debug.assert(count == 6);
}

I should be able to change the second function to fn two() callconv(.C) void { but the compiler doesn’t like that. It says two() can’t call three() from a non-async context. Which I suppose implies that the entire call chain is passing along the “frame” (just a guess)? So that would imply if two() is called from both async and non-async context, different versions need to be generated? One that passes long the frame, and one that does not? Am I thinking about this correctly?

If what I said above is true, is there anyway one() could get the frame, and pass it to two() as an explicit param, that could then call three() correctly?

I suppose the obvious question is “why would you want to do that?”. I don’ have a good answer for that. I’m just trying to figure out how things work. But you could imagine two() is a call into some C library that calls back into zig code. You might want too suspend in the callback. It’s a stretch I know.

I guess I don’t follow why two() needs to be aware if it’s called in an async context or not. It makes a call that returns at some point. If the call is blocking, or suspending, why does two() care about the difference?

Thanks in advance for your time.

First, the criteria for a function to be async is for it to suspend. It can happend by await-ing or suspend-ing.
The thing is, in your two() function, it calls three() which is an async function. As it is an async function, the call is basically translated to await async three().
So, while two() itself doesn’t have a suspend point, it calls an async function which makes it async.

The call chain isn’t really passing a frame, the reality is that all functions have a call frame, the difference is that for an async function, it’s a special one.
What happens under the hood is that doing suspend just returns. And your sus = @frame(); stores in sus the frame, which contains the line number of the current instruction. (which would be just after you suspend block)
Note that in reality, it’s implemented a bit differently, using the number of the suspend point and storing other state…

For example, let’s say we call two(), the function executes count += 1, calls three(), which does count += 1 and then, it suspends. In sus, you have the frame which contains: what function was being executed and at which line number. This way, resume sus; has all the info it needs to resume the function where it suspended.

No. No different versions are generated for async and non-async context, as an async function is always called asynchronously (remember that for async functions, three() is syntactic sugar for await async three()) and a non-async one is always called non-asynchronously.

The problem you have when trying to use callconv(.C) is that as said above, when there’s a suspend block, the function actually returns. But the C calling convention doesn’t know anything about async functions, so it’ll just see the function return, be “ok” and move on, and you’ll have an half-executed function. Hence that’s why Zig disallows doing that.

Now, if you want to suspend in a callback, you’ll need to use @asyncCall which allows you to call an async function that will ‘outlive’ the callback. That is the callback can return but the function will still be resumable, as you manually allocate the frame buffer.
However if you do that, you’ll still need to manually resume the async function somewhere in your code (I recommend using an event loop for that like std.event.Loop, if it still works)

For example:

const allocator = ... // (some allocator)

/// The buffer that the function we call will use as stack, frame, ...
/// This must only be freed when the function is done executing!
var callback_frame_buffer: ?[]align(16) u8 = null;
var callback_frame: ?anyframe = null;
var count: u32 = 0;

fn someFunctionCalledByC() callconv(.C) void {
    callback_frame_buffer = allocator.alignedAlloc(u8, @frameSize(asyncCallback), 16) catch |err| {
        // Handle the error in some way, or do catch unreachable
    };
    callback_frame = @asyncCall(
        callback_frame_buffer.?, 
        null, // it returns void so we don't care about the result
        asyncCallback, // the function we call
        .{}, // arguments
    );
}

fn asyncCallback() void {
    count += 1;
    // we don't need to set the frame as callback_frame
    // is already set.
    suspend {}
    count += 1;
    return;
}

test "async" {
    // This function is non-async and so can be called from C code
    someFunctionCalledByC();
    try std.testing.expect(count == 1);

    // However, we need to manually resume the frame (again, I recommend an event loop)
    resume callback_frame.?;
    try std.testing.expect(count == 2);

    // When the function is done, don't forget to free the frame buffer
    // Otherwise you'd have memory leaks.
    allocator.free(callback_frame_buffer.?);
}
1 Like

This is very helpful. Thank you.

I do have a question about using the @asyncCall. So in my example, the async call to one() is the start of the “task” (top of the async stack? Not sure what to call it). So when the task suspends, the code after starting the “task” (after the async call) runs.

It seems like the @asyncCall starts a new task? So in my example, having two() use @asyncCall wouldn’t work, as it would be starting a new task. The desired outcome is to have the round-trip to/from C be part of the already existing “task”. Ideally, the entire task would look like :

task Start -> Call C w/ callback -> Call back from C (callconv(.C)) -> do something that suspends

Since suspend is implemented as a return, then there is no way to make this work, as the C code would return and unwind the stack, which isn’t the desired outcome. a “half executed function” as you said. Is that correct?

BTW: the tip that when two() calls three() is it effectively await async three() really makes it much easier to understand what’s happening.

Thanks again for your time