Skip to main content

Async

There's no separate "async Stof" and "sync Stof" — the runtime is async by default, single-threaded, and switches between running work at the instruction level rather than only at explicit await points. That last part matters more than it sounds like it should; the proof-of-concurrency example further down only makes sense because of it.

The Model: Processes, Not Threads

Stof calls a unit of async work a process, not a thread or a task — each one is running, waiting, sleeping, done, or errored, and the runtime makes progress on all of them by switching between them on a single thread. Every #[main] and #[test] function automatically becomes its own process when it runs.

async and #[async] Are the Same Thing

One's a keyword that adds the attribute at parse-time, one's an attribute — they mark a function identically:

async-attr.stof
async fn this_is_async() -> str { 'hello, async' }

#[async]
fn this_is_also_async() -> str { 'async is actually just an attribute' }

#[main]
fn main() {
  pln(await self.this_is_async());
  pln(await self.this_is_also_async());
}
Output

Any Function Can await

await isn't restricted to async-marked functions — and on a value that isn't a promise, it's just a passthrough:

await-anywhere.stof
fn takes_promise_param(param: Promise<str>) -> str {
  await param
}

#[main]
fn main() {
  pln(await 'hello');
  pln(await 42);
  pln(await self.takes_promise_param('regular value'));
}
Output

Async Expressions: Spawning a Process

Putting async in front of any call spawns it as its own process and hands you back a promise, without needing the callee itself to be declared async:

async-expression.stof
fn slow_add(a: int, b: int) -> int {
  a + b
}

#[main]
fn main() {
  const promise = async self.slow_add(40, 2);
  pln(await promise);
}
Output

Async Blocks

An async { } block used as an expression spawns a process and returns a promise for whatever the block's last line evaluates to:

async-block-expr.stof
#[main]
fn main() {
  const handle = async {
      let v = 7;
      v * 2
  };

  // casting promises is rarely needed
  let res = await (handle as Promise<str>);
  pln(typeof res, res);
}
Output

Fire-and-Forget Async Blocks

The same async { } block, used as a statement rather than assigned anywhere, still spawns a process — just without a handle to await it:

fire-and-forget.stof
#[main]
fn main() {
  async {
      pln('ran as a background process');
  }

  pln('main continues immediately');
}
Output

main() doesn't wait for that block — it just keeps going. Since both are running as independent processes on the same thread, which line prints first isn't guaranteed; that's not a bug in the example, it's the actual behavior.

Awaiting Multiple Processes

await works on a list of handles directly, resolving to a list of results — useful on its own, or built up from a loop when you just need everything to finish:

await-list.stof
async fn fn_a() -> str { 'hello, async' }
async fn fn_b() -> str { 'another async function' }

#[main]
fn main() {
  const results = await [self.fn_a(), self.fn_b()];
  pln(results);
}
Output
await-all.stof
#[main]
fn main() {
  let handles = [];
  for (const _ in 5) handles.push_back(async { pln('a process ran'); });
  await handles;
  pln('all processes finished');
}
Output

Proof: True Concurrency

Because scheduling happens at the instruction level, two processes with no await inside either of them still make progress at roughly the same rate, sharing the single thread rather than running one fully before the other starts:

concurrency-proof.stof
async fn counting_up(now: ms, n: int) -> ms {
  for (let i = 0; i < n; i += 1) {}
  Time.diff(now)
}

#[main]
fn main() {
  const now = Time.now();
  const time_to_count = self.counting_up(now, 10_000);

  const loop_time = async {
      let count = 0;
      loop {
          count += 1;
          if (count >= 10_000) break;
      }
      Time.diff(now)
  };

  pln(await [time_to_count, loop_time]);
}
Output

Both numbers should come back close to each other — if the runtime ran one loop to completion before starting the other, they'd look nothing alike.

Note: the vm does optimize for when process switching happens, and for additional reasons will make progress on a series of statements at once until a block boundary is hit - the above example works because of the loops.

Promise Types

Promise<T> is an optional, explicit way to annotate a return type — most code never needs to write it, since a promise matches its inner type. You can also cast a promise to change what it resolves to, and a function returning a promise works anywhere a plain fn is expected:

promise-types.stof
fn takes_fn(pointer: fn) -> int { pointer() }

#[main]
fn main() {
  let promise = (async { return '100'; }) as Promise<str>;
  promise = promise as int;
  pln(await promise);

  const res = self.takes_fn(async (): int => 42);
  pln(await res);
}
Output