Skip to content

provide guarantees about whether memory goes in the coroutine frame or stack frame #1194

@andrewrk

Description

@andrewrk

This test fails:

const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;

var ptr: *u8 = undefined;

test "where does the coroutine memory go" {
    var da = std.heap.DirectAllocator.init();
    defer da.deinit();

    const p = try async<&da.allocator> simpleAsyncFn();
    _ = blowUpStack(5);
    assert(ptr.* == 0xee);
}

async fn simpleAsyncFn() void {
    var x: u8 = 0xee;
    ptr = &x;
    suspend;

    // Uncomment to make the test pass
    // var a = &x;
}

fn blowUpStack(count: usize) usize {
    if (count == 0) return 0;
    return blowUpStack(count - 1) + blowUpStack(count - 1);
}

In this example, even though a pointer to a variable x on the coroutine frame escapes, LLVM does not spill x into the coroutine frame, because it is not referenced after a suspend point. You can see that if you reference the variable after the suspend point then the test passes because x is spilled into the coroutine frame.

It is important that we support local variables that do not spill, because sometimes we need coroutine code to execute even after the frame has been destroyed. In this situation the unspilled variables are accessible while the spilled variables are not.

There is one more relevant piece of information here, which is that LLVM coroutines provide one more utility that zig does not expose directly to the user. This is that you can access the coroutine "promise" value from the handle. (In zig the handle is of type promise). Point here being that we could have async functions define a type which is guaranteed to be inside the coroutine frame, and is accessible via the promise handle.

So here's one proposal:

async<u8, *std.mem.Allocator> fn simpleAsyncFn() void {
    // Now get coroutine handle with this function instead of suspend syntax
    const my_handle = @handle();
    assert(@typeOf(my_handle) == promise:u8->void);

    // now we access the coroutine frame the same way we would from outside the coroutine
    const frame_ref: *u8 = @coroFrame(my_handle);

    frame_ref.* = 0xee;
    ptr = frame_ref;
    suspend;
}

Note that this makes the global capture unnecessary as the assert could be rewritten:

test "where does the coroutine memory go" {
    var da = std.heap.DirectAllocator.init();
    defer da.deinit();

    const p = try async<&da.allocator> simpleAsyncFn();
    _ = blowUpStack(5);
    assert(@coroFrame(p).* == 0xee);
}

This proposal also makes it possible to write generators:

async<?usize> fn range(start: usize, end: usize) void {
    const my_handle = @handle();
    const result_ptr = @coroFrame(my_handle);
    var i: usize = start;
    while (i < end) : (i += 1) {
        result_ptr.* = i;
        suspend;
    }

    result_ptr.* = null;
}

test "generator" {
    const items = try async<std.debug.global_allocator> range(0, 10);
    defer cancel items;
    while (@coroFrame(items).*) |n| : (resume items) {
        std.debug.warn("n={}\n", n);
    }
}

With this proposal, variables would work the same way they do now, and the first test case would still fail in the same way. However there would be an explicit feature to use when you want to guarantee that memory is inside the coroutine frame.

Metadata

Metadata

Assignees

No one assigned

    Labels

    acceptedThis proposal is planned.bugObserved behavior contradicts documented or intended behaviorproposalThis issue suggests modifications. If it also has the "accepted" label then it is planned.

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions