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:

  1. async fn marks the function as asynchronous. It can contain await expressions and returns a future.

  2. await client.get(url).send() suspends execution until the HTTP request completes. The await keyword comes before the expression (prefix syntax).

  3. await resp.text() similarly waits for the response body to be read.

  4. The function returns String?, not Future<String?>. The async keyword 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 await in 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 Pending if 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 fn declares functions that can be suspended and resumed
  • Prefix await waits 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.