Skip to content

12 - Concurrency (Task/Actor Model) ​

Status ​

Implemented:

  • Task keyword parsing and AST representation (ast/task.rs)
  • Basic task compilation to AutoVM bytecode with handler registration
  • task block syntax with on message handlers
  • The 4-phase async architecture (plan-317, current-state audit 2026-09-07 per the archived plan's verification notes): Phase 1 actor handler execution engine + Phase 3 lazy yield/SSE (2026-06-18, actor_tests/actor_state_tests 13 green); Phase 2 ~{}.await out-of-line bytecode (plan-348 Task 22, plan348_concurrency_tests::test_task22_*); Phase 4 async HTTP server serve_async (wired into lib.rs live path). Tokio M:N scheduling + actor mailboxes are the VM's production concurrency model (vm/scheduler.rs, vm/task_system.rs).

Planned:

  • a2c state machine generation for embedded targets
  • Tokio-based async runtime for the a2r backend (a2r async/await transpilation, plan-355 archived design, not yet implemented)

Design ​

Core Philosophy: Zero Shared Mutable State ​

Auto's concurrency model follows the Actor paradigm: each task is an isolated entity with private state, communicating only through typed messages. There are no shared mutable variables, no locks, no mutexes at the language level.

┌─────────────┐     typed msg      ┌─────────────┐
│  Task A      │ ─────────────────→ │  Task B      │
│  (private    │                    │  (private    │
│   state)     │ ←───────────────── │   state)     │
│              │     reply          │              │
│  mailbox: [] │                    │  mailbox: [] │
└─────────────┘                    └─────────────┘

Phase 1: Actor Base & TaskSystem Bootstrap ​

The foundation layer establishes physical isolation boundaries and basic communication primitives.

Syntax:

auto
// 1. Message protocol (strongly typed enum)
enum CounterMsg {
    Add(int)
    Reset
    Print
}

// 2. Static task entity
task CounterTask {
    count mut = 0

    fn start() ! {
        self.count = 0
        print("CounterTask Booted!")
    }

    // Implicit message pump (event routing core)
    on {
        Add(val) => {
            self.count += val
        }
        Reset => {
            self.count = 0
        }
        Print => {
            print("Current Count: ${self.count}")
        }
    }
}

// 3. System bootstrap entry (pure synchronous main)
fn main() ! {
    print("System pre-booting...")

    // Phase A: Queue messages before ignition
    CounterTask.send(Add(10))
    CounterTask.send(Print)

    print("Ignition!")

    // Phase B: Transfer main thread control to scheduler
    TaskSystem.start()

    // Compiler enforces: unreachable code after this point
}

Key Design Decisions:

  • main() is a synchronous bootstrapper; TaskSystem.start() is the final statement
  • Messages are queued before ignition — no task runs until TaskSystem.start()
  • TaskSystem.start() maps to tokio::runtime::Runtime::block_on() in a2r, or cooperative scheduler in AutoVM
  • Compiler statically verifies TaskSystem.start() is the last reachable statement in main

Phase 2: State Machine Suspension & Bidirectional RPC ​

Introduces non-blocking time primitives and implicit bidirectional channels.

~T Async Type:

  • Any block or function prefixed with ~ returns a ~T (future/state machine)
  • .await on ~T suspends the current task and yields CPU to other tasks
  • Type dimension reduction: ~T + .await → T

ask / reply Mechanism:

auto
enum DBMsg {
    QueryUser(int)
}

#[single]
task DBManager {
    on {
        QueryUser(id) => {
            let user_info = db_driver.find(id)
            reply user_info  // Compiler injects oneshot channel
        }
    }
}

task WebWorker {
    on {
        ProcessRequest => {
            // ask() creates implicit oneshot channel, returns ~User
            let user = DBManager.ask(QueryUser(1001)).await.?
            print("Got user: ${user.name}")
        }
    }
}

a2r Degradation (Rust):

rust
// Compiler rewrites enum to include oneshot Sender:
pub enum DBMsg {
    QueryUser(i32, tokio::sync::oneshot::Sender<User>),
}

// ask().await degrades to:
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
DBMANAGER_TX.try_send(DBMsg::QueryUser(1001, reply_tx))?;
let user = reply_rx.await?;

// reply degrades to:
let _ = reply_tx.send(user_info);

Backpressure via .await:

auto
// If mailbox is full, sender suspends instead of failing
Logger.send(LogData("message")).await.?

Sync Bridge (TaskSystem.run):

auto
// Execute async block in synchronous context (e.g., during boot)
let config_url = TaskSystem.run(~{
    let json = http_get("config.json").await.?
    return parse_url(json)
}).?

Phase 3: Polymorphic Routing & Explicit Context ​

Eliminates boilerplate enum definitions for simple cases. Introduces MessageContext for explicit reply control.

Implicit Union Synthesis: The compiler's AST double-pass automatically extracts all data types from on blocks and synthesizes a message envelope — no manual enum needed.

Explicit Message Context:

auto
// ctx provides routing metadata and reply method
task NodeWorker {
    on(ctx) {
        "ping" => {
            ctx.reply("pong")
        }
        "get_data" => {
            is ctx.sender_id {
                (id) => { print("Request from ${id}") }
                !    => { print("Anonymous request") }
            }
            ctx.reply(db.query())
        }
    }
}

// Omit parameter for fire-and-forget tasks
task Logger {
    on {
        msg string => { write_to_disk(msg) }
    }
}

MessageContext Internal Type:

auto
type MessageContext {
    sender_id ?u64
    trace_id string
    is_ask bool
    fn reply(payload Any) void
}

Omnipotent Pattern Matching:

  • Literal match: "start" =&gt; { ... }, 404 =&gt; { ... }
  • Type capture: url string =&gt; { ... }, u User =&gt; { ... }
  • Guard clauses: x int if x &gt; 100 =&gt; { ... }
  • Wildcard: _ =&gt; { ... }

Phase 4: Micro-Concurrency (.go Operator) ​

Lightweight fork for fine-grained parallelism without full task ceremony.

auto
// .go spawns a lightweight concurrent unit
let handle = compute_heavy(data).go
let result = handle.await

// Structured concurrency: go blocks auto-join on scope exit
fn process(items List<int>) {
    let handles = items.map(x => transform(x).go)
    // All handles complete before function returns
}

Scheduler Architecture ​

AutoVM (Dynamic):

┌──────────────────────────────────────────┐
│              TaskSystem                   │
│  ┌─────────────────────────────────────┐ │
│  │  Scheduler (Tokio Runtime)          │ │
│  │  ├── Task A: eval_loop (Future)     │ │
│  │  ├── Task B: eval_loop (Future)     │ │
│  │  └── Task C: eval_loop (Future)     │ │
│  └─────────────────────────────────────┘ │
│  ┌─────────────────────────────────────┐ │
│  │  Message Router                     │ │
│  │  ├── mailbox_a: mpsc::Receiver      │ │
│  │  ├── mailbox_b: mpsc::Receiver      │ │
│  │  └── mailbox_c: mpsc::Receiver      │ │
│  └─────────────────────────────────────┘ │
└──────────────────────────────────────────┘

a2c (Embedded/C):

  • Compiler generates stackless coroutine state machines
  • Each .await becomes a switch-case state transition
  • Local variables surviving across .await are promoted to task struct fields
  • Oneshot channels degrade to FreeRTOS Queues or Task Notifications

Lifetime Scoping ​

ScopeKeywordLifetime
@Taskdeclared in task bodyLives until task ends
@Requestdeclared in on handlerLives until handler completes
@Globalshared keywordProcess lifetime

Open Questions ​

  • Should reply remain a keyword in Phase 2, or be replaced by ctx.reply() method in Phase 3 from the start?
  • Maximum mailbox capacity: fixed per task or configurable at runtime?
  • Should tasks support priority levels for message processing?
  • How to handle task panics: restart strategy vs. supervision trees?

Source Documents ​

Released under the MIT License.