Language / Function Disciplines (pure, task, async)

Function Disciplines (pure, task, async)

This document specifies Silk’s intended “function discipline” system: how functions declare whether they are pure, asynchronous, or safe to run as parallel tasks.

Const functions (const fn) are specified separately in const functions. The const modifier is orthogonal to the discipline system described here (a const fn may also be declared pure).

, but Silk currently now implements pure fn parsing and a strict purity checker. Concurrency disciplines (task / async) are parsed and Task(T) / Promise(T) handles plus yield (task values) and await (promise values) are implemented in the Supported forms (await Task(T) is rejected). On the hosted linux/x86_64 target, the compiler now ships a bring-up async runtime (single-threaded executor + stackful coroutines in libsilk_rt) so await can suspend and resume without blocking the OS thread. A compiler state-machine coroutine transform, structured concurrency scope semantics, and task-safety (Send/Sync)-like rules remain future work. See concurrency for the concurrency model and implementation status.

Overview#

The language design distinguishes:

  • fn — normal function (may perform effects; blocking).
  • pure fn — function with no observable side effects (referentially transparent).
  • task fn — function safe to execute on a worker pool as a parallel task.
  • async fn — function that may suspend at await points (returns an awaitable).
  • async task fn — async function executed as a separate task (self-contained worker).

Intended Call Rules (Design)#

The checker is expected to enforce:

  • pure code may call only pure code (and cannot perform I/O or mutation outside local, non-escaping temporaries).
  • task code may call task and pure code, and must satisfy task-safety rules for captured/argument data.
  • async code may await other async operations; it may call pure code and may offload blocking work via explicit adapters .

Crossing discipline boundaries is intended to be explicit and diagnostic-driven (for example suggesting the correct adapter/intrinsic).

Standard Intrinsics#

The standard library is expected to provide typed adapters to cross boundaries safely (names and exact signatures are design work):

  • lifting sync work onto a task pool,
  • presenting a task as an async operation,
  • running blocking work from async without stalling the event loop,
  • structured spawn/join primitives.

These APIs are not yet present in the in-tree std/ implementation.

Implementation Notes#

Today:

  • pure fn is parsed and checked (Supported forms):
  • a pure fn may call only pure functions; ext is treated as impure,
  • the checker also supports purity inference (“auto-pure”) for ordinary fn declarations and impl methods:
  • when an unannotated function/method has an eligible signature and its body satisfies the purity rules, it is treated as pure for call checking, and may be called from pure code,
  • functions/methods with &T parameters are not eligible for inference (explicit pure fn remains supported for &T parameters in the current subset),
  • pure cannot be combined with task or async in the Supported forms,
  • a pure fn may not have mut parameters,
  • a pure fn may not declare mutable locals (var or let mut) and may not perform mutation via assignment,
  • a pure fn may not allocate (new) in the Supported forms,
  • a pure fn may not have a typed-error contract (-> T | Error...) and may not contain panic statements.
  • task fn, async fn, and async task fn are parsed and preserved in the AST.
  • Calls across disciplines are now reflected in expression types:
  • calling a task fn yields Task(T),
  • calling an async fn yields Promise(T),
  • calling an async task fn yields Promise(Task(T)),
  • yield supports both statement and expression forms:
  • statement forms (only inside an enclosing task fn / async task fn):
  • yield v; sends a value to the task’s yield stream,
  • yield * t; forwards all values from t into the task’s yield stream,
  • expression forms:
  • yield t receives the next yielded value from t,
  • yield * t drains/collects remaining values from t into T[],
  • await unwraps Promise(T) and yields T (await Task(T) is rejected), and await * ps unwraps Promise(T)[] into T[].
  • await <expr> and async { ... } / task { ... } blocks are enforced as async-only constructs:
  • await is only permitted inside async functions (including async task fn),
  • async { ... } / task { ... } blocks are only permitted inside async functions.
  • yield <expr> is enforced as a task-only construct:
  • yield expression forms (yield t / yield * t) are permitted only inside task functions (task fn / async task fn) and inside task { ... } / task loop { ... } blocks.
  • yield statement forms (yield v; / yield * t;) require an enclosing task fn / async task fn.
  • Lowering/codegen implements task execution using OS threads on linux/x86_64 and implements yield/yield * for task values plus await for promises.
  • By default, each task fn call is scheduled on the global task pool.
  • attr(task=thread) forces a dedicated OS thread per call.
  • On hosted linux/x86_64, the compiler ships a bring-up async runtime (src/silk_rt_async.c) so await is a true suspension point:
  • awaiting a pending Promise(T) parks the current fiber and allows other runnable fibers to execute (it does not block the OS thread),
  • outside the executor owner thread (including when no executor is active), await blocks the OS thread until the promise resolves.
  • yield / yield * waits on task output use the same hosted fd-wait runtime path, so waiting for task values from executor-driven async code suspends the current coroutine instead of blocking the executor owner thread.
  • the long-term design remains a compiler coroutine transform plus a stable std::runtime::event_loop API; see async runtime.
  • async { ... } / task { ... } blocks remain lexical scopes, but scope exit is now runtime-backed for live handle cleanup:
  • live Promise(T) bindings are awaited/destroyed,
  • live Task(T) bindings are drained/destroyed (joining dedicated-thread tasks; pooled/default tasks skip the join),
  • and the same cleanup runs on overwrite and early scope exit.
  • Function types are parsed in type positions (notably for ext).
  • Function expressions are implemented as first-class function values:
  • fn (x: int) -> x + 1 (expression body),
  • fn (x: int) -> int { return x + 1; } (block body).
  • fn (x: int) { ... } (block body, implicit void result).
  • Function expressions may not declare &Struct parameters; only single-slot scalar &T parameters (for example &int) are supported in the Supported forms.
  • Function expressions are eligible for purity inference (“auto-pure”):
  • when the body satisfies the pure rules, the function value is treated as pure for call checking (it may be called from pure code),
  • otherwise the function value is impure and may not be called from pure code.
  • Capturing closures are supported as a subset:
  • a function expression may reference immutable locals/parameters from an enclosing scope,
  • captures are by-value copies into a heap environment (scalar-only in the Supported forms),
  • forming captures inside pure code is rejected (capture environments allocate),
  • capturing closures are also eligible for purity inference (a closure whose body satisfies the pure rules is callable from pure code).
  • Function values (both non-capturing and capturing) are supported end-to-end: they may be passed, returned, stored, and called indirectly.

Source repository · Edit this page · View Markdown