Channels & Ownership

Go-style friendly bounded channels combined with Rust-grade linear ownership transfer. Safe, race-free concurrency compiled directly to strict ISO C11.

Concurrency Thread-Safe FIFO Linear Move Safety

💡 The Core Concept

Concurrent programming in traditional languages often presents a false dichotomy:

Vela eliminates this entire problem class by pairing first-class bounded channels (Chan[T]) with linear ownership transfer (own T).

The Vela Invariant: When an own T value is sent over a channel, the sender's binding is permanently invalidated at compile time. Only the receiving task can ever read or mutate the message payload. No locks or defensive copies are needed on the carried data!

📦 Channel Types & Creation

A channel is declared with Chan[T] (or the generic form Chan<T>). The type argument T specifies the message payload.

Primitive Channels: Chan[int]

Carries plain value types (integers, floats, booleans). Items are copied into the channel's bounded ring buffer upon send.

Owned Channels: Chan[own Shape]

Carries structured shapes or resources with ownership handoff. Dispatching requires the own keyword (e.g. dispatch(c, own item)).

Region Transfer: Chan[own region]

Planned, not built yet: transferring an entire allocated region across tasks in \(O(1)\) zero-copy time. Today the compiler refuses own work with a clear error, naming new T(…) at work as the way to allocate inside it.

Creating a Channel

Channels are allocated with the builtin primitive chan_new<T>(capacity) or via the standard library helper channel<T>(capacity) from std/sync:

include std/sync

task main()
    ; Creates a bounded FIFO ring buffer holding up to 16 jobs
    fixed queue = chan_new<own Job>(16)
    show("queue capacity: ", queue.cap)
fi

⚙️ Builtin Operations

All channel operations are available both as top-level builtin tasks and as natural method syntax on channel values:

Operation Method Syntax Return Type Description
dispatch(c, item) c.dispatch(item) bool Blocks if the buffer is full until space is freed. Returns true on success, or false if the channel is closed.
wait_and_receive(c) c.wait_and_receive() T Blocks if the buffer is empty until a message arrives. Returns the received payload.
receive(c) c.receive() maybe T Non-blocking receive. Returns { value } if a message is immediately available, or none if empty.
close(c) c.close() nothing Closes the channel. Subsequent receives drain existing items; subsequent sends fail gracefully.
— c.len int Returns the current count of items queued in the channel buffer.
— c.cap int Returns the maximum capacity of the channel's bounded ring buffer.

🛡️ Compile-Time Ownership Safety

The Vela compiler enforces strict linear move checking at compile time to guarantee zero use-after-move and zero double-move bugs:

1. Use-After-Move Detection

Once a variable is passed with own x, any subsequent read of x is rejected at compile time:

fixed q = chan_new<own Packet>(4)
fixed p = Packet { id: 42, payload: "data" }

dispatch(q, own p)
; Error: use of moved value 'p'; 'p' was moved with 'own p'
show(p.id)

2. Double-Move Prevention

Attempting to move the same variable twice without reinitializing it is caught immediately:

dispatch(q, own p)
; Error: use of moved value 'p'; 'p' was moved with 'own p'
dispatch(q, own p)

3. Loop Move Protection

Moving a variable declared outside a loop inside the loop body is prohibited, preventing a use-after-move on iteration 2:

fixed p = Packet { id: 1 }
loop count < 3
    ; Error: cannot move 'p' inside a loop; 'p' was declared outside the loop
    dispatch(q, own p)
fi

4. Reassignment Restores Usability

Assigning a new value to a mutable binding clears the moved state, making the variable available for use again:

flex p = Packet { id: 1 }
dispatch(q, own p)   ; 'p' is moved

p = Packet { id: 2 }  ; 'p' is restored with a new value!
show(p.id)             ; Completely valid! Prints 2
dispatch(q, own p)   ; 'p' is moved again

5. Non-Variable Move Rejection

Ownership transfer applies exclusively to named bindings, preventing ambiguous temporaries:

; Error: cannot move non-variable expression with 'own'
dispatch(q, own Packet { id: 1 })

🚀 Complete Working Example

The following complete program demonstrates a multi-stage producer-worker pipeline with typed channels, ownership transfers, and non-blocking polling:

shape Job
    id: int
    payload: text
    priority: int
fi

shape Result
    job_id: int
    status: text
    processed: bool
fi

task process_job(j: own Job) -> Result
    show("processing job #", j.id, " (", j.payload, ") with priority ", j.priority)
    <- Result { job_id: j.id, status: "completed", processed: true }
fi

task main()
    region r default

    ; Bounded integer channel
    fixed numbers = chan_new<int>(3)
    numbers.dispatch(100)
    numbers.dispatch(200)
    show("queue len: ", numbers.len)
    show("received: ", numbers.wait_and_receive())
    numbers.close()

    ; Structured job queue with ownership transfer
    fixed jobs = chan_new<own Job>(4)
    fixed results = chan_new<own Result>(4)

    flex j1 = Job { id: 1, payload: "encode video", priority: 10 }
    dispatch(jobs, own j1)

    flex j2 = Job { id: 2, payload: "generate thumbnail", priority: 5 }
    dispatch(jobs, own j2)

    ; Drain and process
    loop jobs.len > 0
        fixed job = jobs.wait_and_receive()
        fixed res = process_job(own job)
        results.dispatch(own res)
    fi

    ; Read results
    loop results.len > 0
        fixed res = results.wait_and_receive()
        show("job #", res.job_id, " ", res.status)
    fi

    jobs.close()
    results.close()
fi

🔬 Under the Hood: C11 Runtime Architecture

When Vela compiles your program with --emit-c or builds native binaries, channels are mapped to high-performance C11 primitives in runtime/vela_rt.h: