Background Tasks

Introduction

Thread is azul's background-work primitive: an OS thread plus two typed channels back to the main thread, owned and ticked by the event loop. Use it for blocking I/O, long computations, and anything you can't finish inside a single frame budget. The framework polls each registered Thread every frame, drains messages, and runs your write-back callbacks on the main thread.

There's no embedded async runtime. The framework gives you threads and timers. If you want futures, you bring the runtime.

When to use a thread vs. a timer

  • Blocking I/O (file, network, DB) or long compute. Use Thread.
  • Periodic main-thread work (animation, polling app state). Use Timer.
  • Wait for a future without a runtime. Use Thread running a current-thread executor.

A Timer runs on the main thread and is bounded by your frame budget; see timers. A Thread runs in its own OS thread and reports back through a channel.

Spawning a thread

The worker function signature is fixed:

pub type ThreadCallbackType =
    extern "C" fn(RefAny, ThreadSender, ThreadReceiver);

Build a Thread with Thread::create and hand it to the event loop via CallbackInfo::add_thread:

use azul::prelude::*;

extern "C" fn worker(
    initial: RefAny,
    mut sender: ThreadSender,
    mut recv: ThreadReceiver,
) {
    // blocking work goes here
}

extern "C" fn on_click(mut data: RefAny, mut event: CallbackInfo) -> Update {
    let init_data      = RefAny::new(/* per-thread input */ ());
    let writeback_data = data.clone();
    let thread = Thread::create(init_data, writeback_data, worker);
    event.add_thread(ThreadId::unique(), thread);
    Update::DoNothing
}

Thread::create(thread_initialize_data, writeback_data, callback) takes:

  • thread_initialize_data. Moved into the worker. Available as the first RefAny argument of the worker function. Use this for inputs the thread needs but the main side doesn't.
  • writeback_data. Kept on the main side; passed back to every WriteBackCallback invocation. This is the handle the callback uses to mutate application state in response to the thread's output.
  • callback. The extern "C" fn(RefAny, ThreadSender, ThreadReceiver) that runs on the new thread.

event.add_thread(ThreadId::unique(), thread) hands the thread to the event loop. From this point the framework polls it every frame.

Sending data back: WriteBackCallback

The thread can't touch main-thread state directly. To update application data, send a ThreadReceiveMsg::WriteBack message. The payload is a RefAny plus a callback that runs on the main thread:

pub type WriteBackCallbackType =
    extern "C" fn(RefAny, RefAny, CallbackInfo) -> Update;

Full example:

use azul::prelude::*;

struct Loaded { rows: Vec<u32> }
struct MyModel { rows: Vec<u32> }

extern "C" fn apply_loaded(
    mut app: RefAny,
    mut payload: RefAny,
    _info: CallbackInfo,
) -> Update {
    let mut model = match app.downcast_mut::<MyModel>() {
        Some(m) => m, None => return Update::DoNothing,
    };
    let mut loaded = match payload.downcast_mut::<Loaded>() {
        Some(p) => p, None => return Update::DoNothing,
    };
    model.rows.append(&mut loaded.rows);
    Update::RefreshDom
}

extern "C" fn worker(
    _initial: RefAny,
    mut sender: ThreadSender,
    mut _recv: ThreadReceiver,
) {
    let rows = blocking_query();
    let msg  = ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg {
        refany:   RefAny::new(Loaded { rows }),
        callback: WriteBackCallback {
            cb: apply_loaded,
            ctx: OptionRefAny::None,
        },
    });
    sender.send(msg);
}
# fn blocking_query() -> Vec<u32> { Vec::new() }

The first RefAny argument of apply_loaded is the writeback_data passed to Thread::create; the second is the payload the worker sent. Return Update::RefreshDom to trigger a re-layout. Update::DoNothing keeps the existing DOM.

sender.send returns bool. true if the message was queued, false if the channel is closed (the framework has already torn the thread down).

Returning Update directly

For the no-payload case (a thread that just wants to tell the UI to refresh) send ThreadReceiveMsg::Update:

use azul::prelude::*;
fn _stub(mut sender: ThreadSender) {
  sender.send(ThreadReceiveMsg::Update(Update::RefreshDom));
}

The framework applies the Update value verbatim. No callback runs.

Receiving messages from the main thread

ThreadReceiver::recv() is non-blocking and returns OptionThreadSendMsg. The main thread sends three kinds of message:

  • ThreadSendMsg::Tick. One frame elapsed. An opportunity to check progress, send a chunk back.
  • ThreadSendMsg::TerminateThread. The framework is dropping the thread; finish quickly.
  • ThreadSendMsg::Custom(RefAny). App-defined message.

Tick arrives automatically on each frame. TerminateThread is sent when the thread is removed via CallbackInfo::remove_thread or the owning Thread handle is dropped. Custom is only delivered if your code arranges for it.

Cooperative termination

A long-running thread should poll for TerminateThread between work units:

use azul::prelude::*;

fn _stub(mut recv: ThreadReceiver, items: Vec<u32>) {
  for item in items {
    if let OptionThreadSendMsg::Some(ThreadSendMsg::TerminateThread) = recv.recv() {
        return;
    }
    process(item);
  }
}

fn process(_: u32) { }

If the worker doesn't check, it runs to completion regardless. The framework's destructor sends TerminateThread and then joins. A non-cooperative thread blocks teardown until its callback returns.

Cancelling from the main thread

use azul::prelude::*;
struct MyModel { 
    thread_id: Option<ThreadId> 
}

extern "C" 
fn on_cancel(mut data: RefAny, mut event: CallbackInfo) -> Update {
    let mut m = match data.downcast_mut::<MyModel>() {
        Some(m) => m, None => return Update::DoNothing,
    };
    if let Some(id) = m.thread_id.take() {
        event.remove_thread(id);
    }
    Update::RefreshDom
}

remove_thread schedules the same TerminateThread + drop sequence the destructor runs.

Sleeping inside a thread

Thread::sleep_ms(milliseconds);
Thread::sleep_us(microseconds);
Thread::sleep_ns(nanoseconds);

These are FFI-safe wrappers around std::thread::sleep. They exist so non-Rust bindings can sleep. Inside a Rust callback std::thread::sleep works equally well.

Instant and Duration

The thread API uses Instant and Duration from the framework rather than the std types directly so timing logic compiles on no_std targets. Both are two-variant enums:

pub enum Instant  { System(InstantPtr),    Tick(SystemTick) }
pub enum Duration { System(SystemTimeDiff), Tick(SystemTickDiff) }

On std targets, Instant::System wraps std::time::Instant. On embedded or WASM targets that lack a real-time clock the variant is Instant::Tick(SystemTick), a frame counter you advance from your event loop. Mixing variants panics, so pick one per platform and stay consistent.

There's no Duration::from_millis shorthand. Build the SystemTimeDiff explicitly:

Duration::System(SystemTimeDiff::from_millis(250))
Duration::System(SystemTimeDiff::from_secs(2))
Duration::System(SystemTimeDiff::from_nanos(1_000))

Instant exposes:

  • Instant::now(). Current time on the active variant.
  • duration_since(&earlier) -> Duration. Panics if earlier > self or if the variants don't match.
  • linear_interpolate(start, end) -> f32. Clamped 0.0–1.0, useful for animation progress.
  • add_duration(...) -> Self. Additive offsets.

Reserved thread IDs

ThreadId reserves the first few IDs for framework-internal use. ThreadId::unique() skips past them, so user code never collides. Don't construct a literal ThreadId { id: 0..=4 } in user code.

What you can't do today

  • Run async fn directly. The framework doesn't provide an executor. To use Tokio, futures-rs, or smol, spawn a Thread, build a Runtime inside it, and use WriteBackCallback to surface results.
  • Stream raw socket data through the framework. Networking has its own page; see networking. Until the runtime side lands, use a Thread plus std::net::TcpStream for the same shape as the example above.
  • Share &mut references between thread and main. Communication is RefAny payloads only. Lock-free shared state isn't part of the framework. Wrap a Mutex inside RefAny if you need it.

Coming Up Next

  • Networking — HTTP from a callback
  • Timers — Timers, threads, and scheduled work
  • Events — Callbacks, event filters, and how state triggers relayout

Back to guide index