Skip to content

Async Functions

Async code lets a program start an operation now and receive the result later.

Async code lets a program start an operation now and receive the result later.

In many languages, this is written with async and await. Zig has used those words in earlier designs, but Zig 0.16 changed the practical shape of async I/O. In Zig 0.16, the important beginner concept is not “mark every function async.” The important concept is: write normal-looking code, pass an I/O interface, and let the chosen I/O backend decide how work is scheduled. Zig 0.16 release notes describe io.async as creating a Future(T), where T is the return type of the called function. The future has await and cancel methods.

Async Means “Start Now, Finish Later”

A normal function call starts and finishes before the next line runs.

const result = doWork();
use(result);

The program cannot call use(result) until doWork() returns.

An async operation separates these two moments:

start the work
do something else
wait for the result

That is the core idea.

Async code is useful when the program spends time waiting. Common examples are file I/O, network I/O, timers, subprocesses, and other operating-system operations.

Async Is Not the Same as Parallel

This distinction matters.

Parallel means two pieces of code are running at the same time on different CPU cores.

Async means one piece of work can be started, and the program can wait for its result later.

Sometimes async work is also parallel. Sometimes it is not.

For example, an async file read may let the program continue while the operating system handles the read. That does not mean your function is running on another CPU core. It means your program is not forced to block immediately.

Threads are about multiple execution paths.

Async is about separating “start” from “wait.”

A Simple Mental Model

Think of async work as a receipt.

You ask for work to begin. Instead of getting the final result immediately, you get a handle.

Later, you use that handle to wait for the result.

future = start work
result = wait for future

In Zig 0.16 terms, that handle is a Future(T). A future represents a result that may not be ready yet. The release notes describe two important methods: await, which waits for completion and returns the result, and cancel, which requests cancellation and then waits for cleanup.

The Shape in Zig 0.16

The broad shape is:

const future = io.async(functionName, .{ arg1, arg2 });
defer future.cancel(io) catch {};

const result = try future.await(io);

Read this as:

Start functionName asynchronously.
Make sure the future is cleaned up.
Later, wait for its result.

Exact APIs can change as Zig continues toward 1.0, so the key idea matters more than memorizing the syntax.

Why Zig Does It This Way

Some languages make async spread through your whole program. If one function becomes async, its caller must become async, and then its caller must become async too.

This is sometimes called function coloring.

Zig 0.16 tries to reduce that problem by making I/O an explicit dependency. Code can receive an I/O interface, and the program can choose a backend. A threaded backend may use blocking operations on worker threads. An evented backend may use the operating system’s event mechanism. The same higher-level code can often stay similar. Zig’s release notes describe io.async as portable even across limited I/O implementations, and they note that an implementation may legally execute the function directly before returning.

That last detail is important: async expresses independence, not guaranteed concurrency.

Async vs Concurrent

Zig 0.16 distinguishes between work that may be asynchronous and work that must be concurrent.

The release notes describe io.async as expressing that a function call is independent from other logic. They also describe io.concurrent as similar, but stronger: it communicates that the operation must actually be done concurrently for correctness and may fail with error.ConcurrencyUnavailable.

So the beginner rule is:

Use async when work may be started now and awaited later.

Use concurrent when the program requires simultaneous progress to be correct.

That difference prevents a subtle bug. If your program has two operations that must both make progress, plain async may not be enough on a backend that chooses to run work directly.

A Small Example in Plain Terms

Suppose you want to read two files.

A blocking style is:

read file A
read file B
combine results

An async style is:

start reading file A
start reading file B
wait for file A
wait for file B
combine results

The second style gives the I/O system more freedom. It may overlap the two reads. On some systems, this can reduce waiting time.

The logic is still simple: start work first, wait later.

await Means “I Need the Result Now”

Awaiting a future is the point where your code says:

I cannot continue until this result is ready.

Before await, the operation may still be running, queued, or already finished.

After await, you either have the result or an error.

A future returning !usize might be awaited like this:

const n = try future.await(io);

The try handles possible errors from the operation.

Cancellation

Async work needs cleanup.

If you start an async operation and then leave the scope early, you must not leak the operation.

That is why async code often uses defer:

const future = io.async(doWork, .{});
defer future.cancel(io) catch {};

const result = try future.await(io);

The defer says: if this scope exits before normal completion, request cancellation and clean up the future.

This is the same discipline you have already seen with memory and locks. Zig wants resource lifetime to be visible.

Async Code Still Needs Ownership Rules

Async code often uses pointers.

That means lifetime matters.

This is unsafe in spirit:

fn start(io: std.Io) !void {
    var buffer: [1024]u8 = undefined;

    const future = io.async(readIntoBuffer, .{&buffer});
    _ = future;

    return;
}

The buffer belongs to the function stack. If the async operation continues after the function returns, it may use memory that no longer exists.

The safe rule is:

Data passed to async work must live until that work has finished or has been cancelled.

This is the same rule as threads, but async makes it easier to forget.

Async Does Not Remove Synchronization

If async tasks share mutable data, they still need coordination.

Async code can still have races, invalid lifetimes, inconsistent state, and ordering bugs.

If two async operations update the same object, you still need a clear rule:

Who owns the object?

Can both operations access it?

Is access protected by a mutex, atomic value, queue, or single-threaded event loop?

Async changes scheduling. It does not remove the need for correctness.

When Async Is Useful

Async is useful when your program waits on external systems.

SituationWhy async helps
Reading many filesReads can overlap
Handling many socketsOne connection can wait while another progresses
TimersThe program can wait without blocking all work
Subprocess I/OOutput can be handled as it arrives
ServersMany clients can be active at once

Async is less useful for pure CPU work. If you need to compute faster using multiple cores, threads or worker pools are usually the more direct tool.

A Beginner Rule

Use threads when you want separate execution paths.

Use mutexes when threads share mutable data.

Use atomics for tiny shared facts.

Use condition variables when a thread should sleep until shared state changes.

Use async when work spends time waiting and you want to start it now, then receive the result later.

In Zig 0.16, async is tied closely to the standard library I/O design. The stable idea is not “decorate every function with async.” The stable idea is explicit scheduling through an I/O interface, visible lifetimes, and explicit waiting through futures.