Futures and the Async Syntax
At the heart of async programming in Oxide are futures: values that
represent work that may not be complete yet. When you call an async function,
it returns a future immediately. The actual work happens later, when you
await that future.
What Is a Future?
A future is like a receipt for a meal at a restaurant. When you order, you get the receipt immediately, but your food isn't ready yet. The receipt represents your eventual meal. You can either:
- Stand at the counter waiting (blocking)
- Go do other things and come back when called (async)
In code:
// This returns immediately with a future, not the actual response
let responseFuture = fetchWebPage(url)
// The actual network request happens when we await
let response = await responseFuture
Your First Async Function
Let's write an async function that fetches a web page title:
import tokio
import reqwest.{ Client, Error }
async fn pageTitle(url: &str): String? {
let client = Client.new()
// await the HTTP request
let response = await client.get(url).send()
// Handle potential errors
guard let resp = response.ok() else {
return null
}
// await getting the response body
let body = await resp.text()
guard let text = body.ok() else {
return null
}
// Parse the HTML and extract the title
extractTitle(&text)
}
fn extractTitle(html: &str): String? {
// Simple extraction (real code would use an HTML parser)
let start = html.find("<title>")?
let end = html.find("</title>")?
let titleStart = start + 7 // length of "<title>"
Some(html[titleStart..end].toString())
}
Let's break down what's happening:
-
async fnmarks the function as asynchronous. It can containawaitexpressions and returns a future. -
await client.get(url).send()suspends execution until the HTTP request completes. Theawaitkeyword comes before the expression (prefix syntax). -
await resp.text()similarly waits for the response body to be read. -
The function returns
String?, notFuture<String?>. Theasynckeyword handles wrapping the return type in a future automatically.
Prefix Await Syntax
Oxide uses prefix await, which differs from Rust's postfix .await:
// Oxide - prefix await
let response = await client.get(url).send()
let body = await response.text()
Rust equivalent:
#![allow(unused)] fn main() { let response = client.get(url).send().await; let body = response.text().await; }
Why Prefix Await?
Prefix await reads naturally from left to right, matching how we think about
the operation: "await the result of this expression." This syntax is familiar
to developers from:
- JavaScript:
await fetch(url) - Python:
await response.json() - Swift:
await fetchData() - Kotlin: Uses suspend functions, but
awaitin coroutines is prefix
Precedence
The await operator binds tighter than ?, so error propagation works
naturally:
// await binds first, then ? propagates any error
let response = await client.get(url).send()?
let body = await response.text()?
// Equivalent to:
let response = (await client.get(url).send())?
let body = (await response.text())?
Chaining with Prefix Await
When chaining async operations, each await handles one async step:
async fn fetchAndProcess(url: &str): Result<Data, Error> {
// Each await handles one async operation
let response = await client.get(url).send()?
let json = await response.json()?
let processed = processData(json) // sync operation, no await needed
Ok(processed)
}
For long chains, you might use intermediate variables or format across lines:
async fn complexFetch(url: &str): Result<String, Error> {
let response = await client
.get(url)
.header("Authorization", token)
.timeout(Duration.fromSecs(30))
.send()?
let body = await response.text()?
Ok(body)
}
Futures Are Lazy
A crucial concept: futures don't execute until awaited. Simply calling an async function creates a future but doesn't start the work:
async fn printMessage() {
println!("Hello from async!")
}
fn main() {
let future = printMessage() // Nothing printed yet!
println!("Future created")
// The message only prints when we await the future
// (We'd need a runtime to actually run this)
}
This laziness allows you to compose futures before executing them:
async fn main() {
let futureA = fetchData(urlA) // Doesn't start fetching yet
let futureB = fetchData(urlB) // Doesn't start fetching yet
// Now we can choose how to run them:
// - Sequentially: await futureA, then await futureB
// - Concurrently: race them or join them
}
How Async Functions Compile
Under the hood, async fn is transformed into a regular function returning
a type that implements the Future trait:
// You write:
async fn fetchData(url: &str): String {
await client.get(url).send().text()
}
// Conceptually compiles to something like:
#![allow(unused)] fn main() { fn fetch_data(url: &str) -> impl Future<Output = String> { // Returns a state machine that can be polled } }
The compiler generates a state machine that tracks progress through each
await point. When polled, it either:
- Continues execution if the awaited future is ready
- Returns
Pendingif still waiting
You rarely need to think about this, but it explains why async functions can suspend and resume without losing their state.
Running Async Code
Async code needs a runtime to execute. The runtime handles:
- Polling futures to make progress
- Managing I/O operations efficiently
- Scheduling tasks across available resources
Here's a complete example using Tokio:
import tokio
#[tokio.main]
async fn main() {
let title = await pageTitle("https://www.rust-lang.org")
match title {
Some(t) -> println!("Page title: \(t)"),
null -> println!("Could not extract title"),
}
}
async fn pageTitle(url: &str): String? {
// ... implementation from earlier
}
The #[tokio.main] attribute sets up the Tokio runtime and converts our
async fn main into a regular fn main that blocks on the async code.
The Runtime's Role
The runtime is essential because main() itself cannot be async - someone has
to start the async machinery! Here's what happens:
// With the attribute:
#[tokio.main]
async fn main() {
await doAsyncStuff()
}
// Is roughly equivalent to:
fn main() {
let rt = tokio.runtime.Runtime.new().unwrap()
rt.blockOn(async {
await doAsyncStuff()
})
}
You can also manually create and use runtimes:
import tokio.runtime.Runtime
fn main() {
let runtime = Runtime.new().unwrap()
runtime.blockOn(async {
let result = await fetchData("https://example.com")
println!("Result: \(result)")
})
}
Async Blocks
Besides async fn, you can create anonymous async blocks:
fn main() {
let runtime = Runtime.new().unwrap()
// An async block creates a future inline
let future = async {
let a = await fetchA()
let b = await fetchB()
a + b
}
let result = runtime.blockOn(future)
println!("Combined result: \(result)")
}
Async blocks are useful when you need a future but don't want to define a separate function. They capture variables from their environment like closures:
async fn processItems(items: Vec<String>): Vec<Result> {
let client = Client.new()
var results = vec![]
for item in items {
// Async block capturing `client` and `item`
let result = async {
await client.process(&item)
}
results.push(await result)
}
results
}
Async with Move
Like closures, async blocks can use move to take ownership of captured
variables:
fn spawnTask(data: String) {
// Without move, this would borrow `data`
// But the task might outlive this function!
tokio.spawn(async move {
println!("Processing: \(data)")
await processData(&data)
})
}
The async move pattern is essential when spawning tasks that need to own
their data, since the task may run on a different thread and outlive the
current scope.
Summary
In this section, we covered:
- Futures represent values that will be available later
async fndeclares functions that can be suspended and resumed- Prefix
awaitwaits for a future (Oxide's key difference from Rust!) - Futures are lazy - they don't execute until awaited
- Runtimes (like Tokio) execute async code
- Async blocks create inline futures that capture their environment
The prefix await syntax is one of Oxide's distinctive features, making async
code read naturally from left to right. In the next section, we'll explore how
to run multiple async operations concurrently.