Async Programming with ~T
Many operations we ask a computer to do could take a while to finish. In Auto, as in most languages, these kinds of operations are called asynchronous or async. The ~T type modifier is Auto's way of expressing a value that represents a computation which will complete at some point in the future — a time blueprint.
In this chapter, we'll cover:
- What
~Tmeans and how it relates to Rust'sFuturetrait - How to create and compose async operations
- Concurrency patterns with async tasks
- Streams: async iterators over time
- How
~Tinteracts with Auto's actor model - When to use actors vs async vs threads
What Is a Time Blueprint?
Consider two different operations:
Rendering a video — this is CPU-bound. Every frame must be computed, and more CPU power directly means faster rendering. This is parallelism: doing multiple things at the same time.
Downloading a video — this is I/O-bound. The network determines the speed, not your CPU. While waiting for data, your CPU sits idle. This is where concurrency shines: you can start a download, switch to other work while waiting, and come back when data arrives.
Parallelism vs Concurrency: Parallelism is about doing multiple things simultaneously. Concurrency is about managing multiple tasks by switching between them. You can have concurrency on a single CPU core. Parallelism requires multiple cores.
Rust's Future trait represents a value that may not be ready yet. Auto's ~T takes this concept further: ~T is a time blueprint — a description of a value that will materialize in the future. The ~ symbol represents the temporal dimension: the value exists, just not yet.
int— a value you have right now~int— a blueprint for anintthat will arrive later~String— a blueprint for aStringthat will arrive later
Note: In Rust, async functions return
impl Future<Output = T>. In Auto, async functions return~T. The blueprint metaphor is intentional:~Tdescribes how to produce the value, not the value itself. Like an architectural blueprint, it must be "built" (awaited) to produce the result.
Creating Async Functions
In Auto, an async function is one that returns a ~T type. You use the async keyword to mark a function as asynchronous, and .await to wait for a blueprint's result.
Your First Async Function
There are several important things to notice here:
page_titlereturns~?String— a blueprint for an optional string- The
.awaitkeyword suspends execution until the blueprint resolves async.blocksets up the async runtime and waits for the result- The
mainfunction itself is not async — it enters the async world viaasync.block
The async.block Entry Point
Unlike Rust, where you need an external runtime like tokio, Auto has a built-in async runtime. async.block is the bridge from synchronous to asynchronous code:
fn main() {
async.block(async {
// You can use .await here
})
}fn main() {
trpl::block_on(async {
// You can use .await here
})
}async.block takes a blueprint and materializes it — it runs the async runtime, executes the blueprint, and returns the final value. This is the entry point for all async code in an Auto program.
What Happens at .await
When you call .await, the following happens:
- If the value is ready, return it immediately
- If not, suspend the current computation and yield control to the runtime
- The runtime can switch to other pending blueprints
- When the value becomes available, resume the computation
This is cooperative multitasking: blueprints yield control at await points, and the runtime schedules them fairly. Unlike OS threads, which are preemptively scheduled, async tasks choose when to yield.
Key Insight: In Rust, this suspension and resumption is implemented via a state machine that the compiler generates for each
asyncblock. Auto uses the same technique internally, but exposes it through the cleaner~Ttype notation.
Concurrency with Async
The real power of async becomes apparent when you have multiple operations that can proceed concurrently.
Racing Two Operations
async.race starts both blueprints and returns as soon as either one completes. The other blueprint is cancelled. This is useful when you want the fastest response and don't need both results.
Spawning Async Tasks
Sometimes you want to run a blueprint in the background without waiting for it immediately. Auto provides async.spawn for this:
async.spawn starts a blueprint as a separate task and returns a handle. The handle is itself a ~T — a blueprint for the task's result. You can continue doing other work and .await the handle when you need the result.
Joining Multiple Blueprints
While race returns the first result, join waits for all blueprints:
async.join runs both blueprints concurrently and returns both results as a tuple. If either blueprint panics, the other is cancelled.
Async Channels
Just as actors communicate via message passing, async tasks can communicate through async channels. An async channel is like a pipe: one end sends values, the other end receives them asynchronously.
The channel has two ends:
tx(transmitter): sends values withtx.send(value).awaitrx(receiver): receives values withrx.recv().await
The rx.recv() returns ~?T — a blueprint for an optional value. It returns Some(value) when a message arrives, and None when the channel is closed (i.e., all transmitters have been dropped).
Multiple Producers
You can have multiple tasks sending to the same channel by cloning the transmitter:
async.join_all takes a list of blueprints and waits for all of them to complete. This is the multi-blueprint counterpart to async.join.
Yielding Control
Async tasks cooperate by yielding control at .await points. But what if a task does heavy computation without any await points? It would monopolize the runtime, starving other tasks.
The Problem: Blocking Without Awaiting
Because slow has no .await points, task A runs to completion before task B even starts. There is no interleaving — no concurrency.
The Solution: async.yield_now
async.yield_now() explicitly yields control back to the runtime, allowing other tasks to make progress. Use it in CPU-heavy loops that don't have natural await points.
Rule of Thumb: If you have a long-running computation inside an async context, periodically call
async.yield_now()to be a good citizen of the cooperative runtime.
Composing Blueprints: Timeout
One of the most powerful patterns in async programming is composing blueprints to build higher-level abstractions. Let's build a timeout function that wraps any blueprint with a time limit.
The timeout function races the given blueprint against async.sleep. If the blueprint finishes first, we return its value. If the sleep finishes first, we return an error. This is a clean example of blueprint composition: complex behavior built from simple primitives.
Streams: Async Iterators
A ~T blueprint produces a single value. But many real-world data sources produce multiple values over time: WebSocket messages, file lines, sensor readings, UI events. For these, Auto provides streams.
A stream is to ~T what an iterator is to a regular value: it produces a sequence of values asynchronously.
Creating and Consuming Streams
The while let Some(value) = stream.next().await pattern is the async equivalent of for value in iterator. Each call to stream.next().await produces the next value, suspending if none is available yet.
Stream from a Channel
Channels are naturally stream-like — values arrive over time:
Composing Streams
Like iterators, streams support combinators for transforming and filtering:
Actors and Async: When to Use Which
Auto provides two primary concurrency models: actors (Chapter 16) and async (this chapter). Both are built into the language and runtime. Here's how to decide which to use:
| Scenario | Use Actors | Use Async (~T) |
|---|---|---|
| Independent services with state | Yes | No |
| Request/response workflows | Either | Yes |
| Streaming data | No | Yes |
| State machines | Yes | Either |
| CPU-bound parallelism | Limited | Use threads |
| Many I/O-bound operations | Either | Yes |
| Message-based communication | Yes | Channels |
Combining Actors and Async
Actors and async can be combined. An actor can .await blueprints inside its message handlers:
In this pattern, the actor's on handler is itself async — it returns ~T. The ask call returns a blueprint that resolves when the actor finishes processing the message.
Threads, Actors, and Async
Auto supports three levels of concurrency, each suited to different problems:
- OS Threads — for CPU-bound parallelism (via
use.rust) - Actors — for stateful, message-passing concurrent services
- Async Tasks — for I/O-bound operations and streaming data
The async runtime multiplexes many lightweight tasks onto a pool of OS threads, using work stealing to balance load. This means you can have thousands of concurrent async tasks without thousands of OS threads.
Under the Hood: How ~T Works
Understanding what happens inside ~T helps you write better async code.
Blueprints as State Machines
When the Auto compiler encounters an async function, it transforms it into a state machine. Each .await point is a state transition:
async fn page_title(url String) ~?String {
// State 1: Initial
let response = http.get(url).await // -> State 2
// State 2: Got response
let text = response.text().await // -> State 3
// State 3: Got text
Html.parse(text).select_first("title")
.map((t) => t.inner_html())
}Conceptually, the compiler creates something like:
enum PageTitleBlueprint {
State1 { url String },
State2 { response HttpResponse },
State3 { text String },
Done,
}Each time poll is called on the blueprint, it advances to the next state or returns Pending if it needs to wait.
Pinning
One subtlety of async state machines is that they can be self-referential: one state might hold a reference to data from a previous state. If the blueprint were moved in memory, those references would become invalid.
Auto handles this automatically: blueprints are pinned in memory once they begin executing. You don't need to think about pinning in normal code, but it's worth knowing it exists under the hood.
In Rust, this is expressed with the Pin<P> type and the Unpin marker trait. Auto's runtime manages pinning implicitly, so you never need to annotate lifetimes or use Pin<Box<...>>.
The ~T Type Hierarchy
| Type | Meaning | Example |
|---|---|---|
T | A value available now | 42, "hello" |
~T | A blueprint for a future T | http.get(url) |
~?T | A blueprint for a future optional T | page_title(url) |
~!T | A blueprint for a future result T | timeout(task, 1000) |
Stream<T> | A sequence of ~?T values over time | rx.stream() |
The modifiers compose naturally: ~?T is a blueprint for an optional value, ~!T is a blueprint that may fail.
Async Quick Reference
| Feature | Auto (~T) | Rust (Future) |
|---|---|---|
| Return type | ~T | impl Future<Output = T> |
| Wait for result | .await | .await |
| Enter async world | async.block | block_on / #[tokio::main] |
| Spawn task | async.spawn | tokio::spawn / trpl::spawn_task |
| Race first result | async.race(a, b) | tokio::select! / trpl::select |
| Wait for all | async.join(a, b) | tokio::join! / trpl::join |
| Wait for many | async.join_all(list) | tokio::join_all / futures::join_all |
| Sleep | async.sleep(ms) | tokio::time::sleep / trpl::sleep |
| Yield control | async.yield_now() | tokio::task::yield_now() |
| Channel | async.channel(T) | tokio::sync::mpsc / trpl::channel |
| Timeout | async.race + sleep | tokio::time::timeout |
| Async sequence | Stream<T> | impl Stream<Item = T> |
| Runtime | Built-in | External (tokio, async-std) |
Summary
Auto's ~T async model provides a clean, composable way to handle asynchronous operations:
~T— a time blueprint representing a future value.await— materialize a blueprint, suspending until it resolvesasync.block— enter the async world from synchronous codeasync.spawn— run a blueprint as a separate taskasync.race— compete blueprints, first winsasync.join— run blueprints concurrently, wait for allasync.channel— communicate between async tasksasync.yield_now— cooperatively yield control- Streams — async iterators for sequences of values over time
- Composition — build complex behavior from simple blueprints
The ~T system integrates naturally with Auto's actor model: actors can await blueprints, and async tasks can send messages to actors. Together, they provide a complete concurrency story that covers CPU-bound work (threads), stateful services (actors), and I/O-bound operations (async).
In the next chapter, we'll explore Auto's approach to object-oriented programming with the is, has, and spec keywords.