Async Programming
Overview
Section titled “Overview”Ard’s concurrency model is Go’s: lightweight goroutines that communicate over
typed channels. There are no async/await keywords and no promises. Instead,
two small primitives compose into everything else:
ard/asyncstarts goroutines.- Built-in channels (
Chan<T>,Receiver<T>, andSender<T>) pass typed values between them.
Waiting for completion, returning results, joining a set of tasks, fan-in, and timeouts are all written in ordinary Ard on top of these — not baked into the language.
Starting goroutines
Section titled “Starting goroutines”async::start runs a function on a new goroutine. It is fire-and-forget: it
returns immediately and gives you no handle.
use ard/asyncuse go:fmt
fn main() { let done = Chan::new<Bool>() async::start(fn() { fmt::Println("running concurrently") done.send(true) }) done.recv()}Because start returns nothing, you coordinate with a channel rather than a
return value. Task functions use ordinary closure capture rules. They may
capture existing references, explicitly borrow outer storage, and rebind outer
writable reference slots.
use ard/asyncuse go:fmt
struct Counter { value: Int }
fn main() { let counter = Counter{value: 0} let reference = mut counter let done = Chan::new<Bool>()
async::start(fn() { reference.value = 1 done.send(true) })
done.recv().expect("done") // synchronizes before the read below fmt::Println(counter.value)}Capturing an existing reference copies its current handle, so both goroutines share the pointee. Explicitly borrowing outer storage or rebinding an outer writable binding captures the required binding slot. Go’s escape analysis gives captured storage a sufficient lifetime.
Ard does not add synchronization or data-race protection. Concurrent access with at least one write must be ordered through a channel, mutex, atomic operation, or another Go-compatible synchronization mechanism. Unsynchronized conflicting access is a data race, just as it is in Go.
Coordinating with channels
Section titled “Coordinating with channels”A channel is a typed conduit between goroutines. send blocks until a value is
taken (or buffered); recv blocks until a value arrives or the channel is
closed.
Waiting for completion
Section titled “Waiting for completion”use ard/asyncuse go:fmt
fn main() { let done = Chan::new<Bool>()
async::start(fn() { fmt::Println("working") done.send(true) })
done.recv() // blocks until the goroutine signals fmt::Println("finished")}Returning a result
Section titled “Returning a result”A goroutine “returns” by sending its result on a channel. This is the pattern that replaces a result-returning task: start the work, then receive its value.
use ard/asyncuse go:fmt
fn compute() Int { // ... expensive work ... 42}
fn main() { let result = Chan::new<Int>()
async::start(fn() { result.send(compute()) })
fmt::Println(result.recv().or(0))}Joining many goroutines (fan-in)
Section titled “Joining many goroutines (fan-in)”To wait on several goroutines, have each send to the same channel and receive once per goroutine:
use ard/asyncuse go:fmt
fn main() { let inputs = [1, 2, 3, 4] let results = Chan::new<Int>(inputs.size())
for n in inputs { async::start(fn() { results.send(n * n) }) }
mut total = 0 for _ in inputs { total = total + results.recv().or(0) } fmt::Println(total) // 30}Closing and draining
Section titled “Closing and draining”The sending side closes a channel when it is done producing. A receiver loops
until recv() returns none, the closed-and-drained signal:
use ard/asyncuse go:fmt
fn main() { let jobs = Chan::new<Int>()
async::start(fn() { jobs.send(1) jobs.send(2) jobs.send(3) jobs.close() })
mut total = 0 mut draining = true while draining { match jobs.recv() { n => { total = total + n }, _ => { draining = false }, } } fmt::Println(total) // 6}Directional channels
Section titled “Directional channels”An API can express direction by narrowing a bidirectional Chan<$T> to a
receive-only Receiver<$T> or a send-only Sender<$T>. Narrowing is explicit
and one-way:
fn produce(out: Sender<Int>) { out.send(1) out.close()}
fn main() { let ch = Chan::new<Int>() produce(ch.sender()) // ch.recv() ...}Waiting on many channels with select
Section titled “Waiting on many channels with select”select waits on several channel operations at once and runs the arm whose
operation is ready first. Unlike a sequence of recv calls, it registers every
arm together and blocks until one can proceed. If several are ready, one is
chosen at random (Go’s fairness). It is an expression, so it can produce a value.
select { let job = jobs.recv() => match job { j => run(j), // received a value _ => drain(), // jobs closed (recv() == none) }, results.send(value) => recorded(), // a send arm timeout.recv() => give_up(), // receive and discard _ => idle(), // non-blocking default}let name = ch.recv() => bodybindsname: $T?; a closed channel surfaces asnone, handled with a normalmatch.ch.recv() => bodyreceives and discards the value.ch.send(x) => bodysends an in-scope value; with a_arm it is a non-blocking try-send._ => bodyruns when no other arm is ready, making the wholeselectnon-blocking.
Timeouts
Section titled “Timeouts”Go’s time::After returns a channel that fires after a delay, which composes
naturally as a select arm:
use go:time
fn wait_for_work() Int { let work = Chan::new<Int>() // ... a goroutine may eventually send on `work` ...
select { let v = work.recv() => v.or(0), time::After(time::Second).recv() => -1, // timed out }}Design philosophy
Section titled “Design philosophy”Ard deliberately keeps async tiny. The only thing the language must provide is a
way to start a goroutine, because there is no go statement to write in
userland. Everything a richer async library would offer — futures, joins,
wait-groups, structured concurrency — is expressible as plain Ard over channels,
so it lives in libraries and programs rather than in the runtime. The runtime
itself defines no async type; start is a compiler-backed intrinsic, and channels lower
to native chan T.