💡 The Core Concept
Concurrent programming in traditional languages often presents a false dichotomy:
- Shared Mutable Memory (C, C++, Java): Threads communicate by mutating shared structures under mutex locks. This model is notoriously prone to data races, deadlocks, lock contention, and subtle memory ordering bugs.
- Raw Channels Without Ownership (Go): Channels make communication ergonomic, but sending a pointer or reference across a channel allows both sender and receiver to mutate the same memory concurrently, creating silent data races.
Vela eliminates this entire problem class by pairing
first-class bounded channels (Chan[T])
with linear ownership transfer (own T).
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:
-
Ring Buffer: Each channel maintains a circular FIFO
memory buffer with
head,tail, andcounttracking. -
POSIX Synchronization: Thread safety is ensured via
pthread_mutex_tand two condition variables:not_empty(waking blocked receivers) andnot_full(waking blocked senders). -
Handle Table: Channel references are passed as
64-bit integer handles (
vela__chan_t) indexed into a generation-checked slot table. This eliminates dangling pointer segfaults and ensures zero-cost value passing. -
Typed Code Generation: The Vela code generator
automatically synthesizes static inline helpers such as
vela__chan_dispatch__*andvela__chan_wait_and_receive__*tailored to each carried type, avoiding runtime boxing or dynamic type dispatch.