Laurel Language Designer Guide

10.2. Example: encoding async/await🔗

In Python and JavaScript, concurrent programs are commonly written with generators, which are essentially coroutines. The following asyncio program fetches pages one round-trip at a time:

import asyncio

async def fetch_pages(num_pages):                # fetch every page: one round-trip per page
    results = []
    for page_num in range(num_pages):
        await asyncio.sleep(0.1)                  # network I/O for this page
        results.extend([page_num * 10 + i for i in range(2)])
    return results

async def download_all(num_pages):
    items = await fetch_pages(num_pages)          # suspend on each of fetch_pages's awaits
    return items

print(asyncio.run(download_all(3)))               # [0, 1, 10, 11, 20, 21]

The same program written with plain generators makes the suspend/resume mechanism explicit: await becomes yield, await coro becomes yield from, and asyncio.run becomes a driver loop that repeatedly advances the coroutine (coro.send(None), i.e. next(coro)) until it raises StopIteration:

def fetch_pages(num_pages):
    results = []
    for page_num in range(num_pages):
        yield                                     # models `await asyncio.sleep(0.1)`
        results.extend([page_num * 10 + i for i in range(2)])
    return results

def download_all(num_pages):
    items = yield from fetch_pages(num_pages)     # `await fetch_pages(...)`: delegate,
    return items                                  #   re-yielding each suspension

def run(coro):
    try:
        while True:
            coro.send(None)                       # the driver's resume: advance one suspension
    except StopIteration as done:
        return done.value

print(run(download_all(3)))                       # [0, 1, 10, 11, 20, 21]

The Laurel encoding below mirrors this generator form: yield for await asyncio.sleep, return for the final result, a driver loop in run for the trampoline, and inside download_all the loop that yield from fetch_pages(num_pages) compiles to. Two of the ingredients are not implemented yet — a coroutine return value and the await / yield from sugar itself — and both depend on the Future channel described in Planned: Future channels for await; the encoding spells them out manually. One representational simplification keeps it small: items are summed into a running int rather than collected into a list (Laurel has no growable list yet).

coroutine fetch_pages(num_pages: int): int
{
  var results:  int := 0;
  var page_num: int := 0;
  while (page_num < num_pages) {               // Python: `for page_num in range(num_pages)`
    yield;                                     // models `await asyncio.sleep(0.1)`
    // This page's items, summed: `[page_num*10, page_num*10+1]`.
    results  := results + page_num * 10 + (page_num * 10 + 1);
    page_num := page_num + 1
  };
  return results                               // models `return results`
};

coroutine download_all(num_pages: int): int
{
  // `await fetch_pages(num_pages)` / `yield from fetch_pages(num_pages)`, expanded to
  // the loop it compiles to (see below):
  var co: fetch_pages := fetch_pages(num_pages);  // spawn the delegate
  while (has_next(co)) {                          // while it has more steps to run
    resume(co);                                   // advance it one step (Pending or Done)
    if has_next(co) then {                        // it suspended (Pending), not finished →
      status := Pending(); yield                  //   re-surface as download_all's suspension
    }
  };
  return done_value(co)                           // delegate done: read its Done payload
};

procedure run(): int                    // Python's trampoline (`run`): drive to completion
{
  var co: download_all := download_all(3);
  while (has_next(co)) {                // Python: `while True: coro.send(None)`
    resume(co)                          // the driver's one-step advance
  };
  return done_value(co)                 // Python: the `StopIteration.value` (co's Done payload)
};

Two operations at opposite ends of a control transfer appear here. resume is the driver side: run advances a coroutine by one suspension, mirroring coro.send(None) (≡ next(coro)). await / yield from is the delegate side. It is not a primitive but the while (has_next(co)) { … } loop shown, which a single var items := await fetch_pages(num_pages) would compile to. It drives fetch_pages step by step and, since download_all itself returns a value (channel Future<int>), re-surfaces each delegate suspension as its own Pending (status := Pending(); yield). That is why download_all, though it delegates internally, is still advanced only by an outer resume from run.

The same has_next serves as both loop guard and inner test: before a step, "more to do?"; after it, "suspended or finished?". On finish, done_value(co) reads the delegate's Done payload — the value stored at completion — so the result is recovered even if some other resume drove the delegate to completion while download_all was suspended.