Menjalankan komputasi intensif atau operasi I/O langsung di dalam event loop GUI menyebabkan freeze, frame drop, dan aplikasi yang tidak responsif. Pada Freya—framework GUI Rust berbasis Dioxus dan Skia—rendering berjalan pada thread utama. Jika thread ini terhambat oleh operasi sinkron atau lock contention, seluruh pipeline layout dan render akan macet.

Solusi standar adalah mendelegasikan tugas berat ke background task (worker) dan mengirim hasilnya kembali ke thread UI. Artikel ini membahas penerapan pola channel worker Freya untuk sinkronisasi state yang aman, mencegah deadlock, serta menerapkan strategi batching dan backpressure agar antrian event loop UI tidak kebanjiran data.

Problem Konkurensi: Locking Contention dan Starvation UI

Kesalahan umum saat membagi state antara background thread dan GUI adalah membungkus state di dalam Arc<Mutex<T>> atau Arc<RwLock<T>> lalu mengaksesnya langsung dari komponen UI. Pola ini memicu masalah performa serius:

  • Lock Contention: Worker yang menulis data secara terus-menerus akan menahan lock. Ketika thread UI mencoba membaca state untuk rendering 60 FPS, thread utama terpaksa menunggu (blocking), menyebabkan frame drop (stuttering).
  • Deadlock: Jika komponen UI memegang lock state sambil menunggu event sistem, dan worker menunggu lock lain yang dipegang oleh thread UI, deadlock terjadi secara instan.
  • Event Loop Saturation (Starvation): Jika worker memancarkan sinyal pembaruan untuk setiap mutasi kecil (misal: 100.000 kali per detik), event loop Freya/Dioxus akan tenggelam dalam antrian re-render. Akibatnya, input pengguna seperti klik dan scroll tidak diproses.

Arsitektur Pola Channel Worker

Pola yang benar memisahkan kepemilikan data (ownership) secara tegas. Background thread bertindak sebagai produsen data independen, dan UI thread bertindak sebagai konsumen murni via channel message-passing asynchronous.

Arsitektur ini membutuhkan tiga elemen:

  1. Tokio MPSC Channel: Saluran komunikasi aman dari worker ke UI task. Gunakan bounded channel untuk membatasi buffer dan memicu backpressure otomatis.
  2. Dioxus Coroutine / Async Task: Konsumen di sisi Freya yang mendengarkan event dari channel tanpa memblokir thread rendering Skia.
  3. Batching Ring Buffer: Mekanisme akumulasi pembaruan data parsial sebelum melakukan trigger mutasi state pada UI hook (use_signal).

Implementasi Kode: Worker Asinkron dan Konsumen Freya

Contoh berikut mendemonstrasikan worker komputasi yang mengirimkan log pemrosesan dan progres ke komponen Freya tanpa memblokir thread GUI:

use freya::prelude::*;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::sleep;

#[derive(Debug, Clone)]
enum WorkerMessage {
    Progress(f32),
    BatchLog(Vec<String>),
    Finished,
}

fn main() {
    launch(app);
}

fn app() -> Element {
    let mut progress = use_signal(|| 0.0f32);
    let mut logs = use_signal(Vec::<String>::new);
    let mut is_running = use_signal(|| false);

    // Channel dibatasi untuk mencegah buffer overflow (Backpressure)
    let (tx, mut rx) = mpsc::channel::<WorkerMessage>(100);

    // Consumer Task di dalam Dioxus/Freya context
    use_hook(move || {
        spawn(async move {
            while let Some(msg) = rx.recv().await {
                match msg {
                    WorkerMessage::Progress(val) => {
                        progress.set(val);
                    }
                    WorkerMessage::BatchLog(new_entries) => {
                        logs.with_mut(|current| {
                            current.extend(new_entries);
                            if current.len() > 50 { 
                                current.drain(0..current.len() - 50); // Batasi memori log
                            }
                        });
                    }
                    WorkerMessage::Finished => {
                        is_running.set(false);
                    }
                }
            }
        });
    });

    let start_task = move |_| {
        if *is_running.read() {
            return;
        }
        is_running.set(true);
        let sender = tx.clone();

        // Spawn background worker di runtime Tokio
        tokio::spawn(async move {
            run_background_computation(sender).await;
        });
    };

    rsx! {
        rect {
            width: "100%",
            height: "100%",
            padding: "20",
            spacing: "10",
            label { "Status Progres: {progress.read():.1}%" }
            rect {
                width: "100%",
                height: "20",
                background: "rgb(220, 220, 220)",
                rect {
                    width: "{*progress.read()}*%",
                    height: "100%",
                    background: "rgb(65, 105, 225)",
                }
            }
            button {
                onclick: start_task,
                label { "Mulai Background Worker" }
            }
            rect {
                width: "100%",
                height: "150",
                background: "rgb(240, 240, 240)",
                overflow: "clip",
                for (i, log) in logs.read().iter().enumerate() {
                    label { key: "{i}", "{log}" }
                }
            }
        }
    }
}

async fn run_background_computation(tx: mpsc::Sender<WorkerMessage>) {
    let mut log_batch = Vec::with_capacity(10);

    for i in 1..=100 {
        // Simulasi tugas berat non-blocking
        sleep(Duration::from_millis(50)).await;

        log_batch.push(format!("Task unit {} selesai", i));

        // Batching: Kirim log setiap 10 item atau pada akhir proses
        if log_batch.len() >= 10 || i == 100 {
            let batch = std::mem::take(&mut log_batch);
            if tx.send(WorkerMessage::BatchLog(batch)).await.is_err() {
                return; // UI sudah ditutup, hentikan worker
            }
        }

        // Kirim update progress
        if tx.send(WorkerMessage::Progress(i as f32)).await.is_err() {
            return;
        }
    }

    let _ = tx.send(WorkerMessage::Finished).await;
}

Strategi Batching dan Backpressure

Jika worker menghasilkan jutaan iterasi per detik, UI tidak butuh melihat setiap perubahan individual karena layar hanya melakukan refresh sebesar 60–144Hz. Menerapkan optimasi berikut krusial untuk kestabilan aplikasi:

1. Bounded Channel untuk Backpressure

Menggunakan tokio::sync::mpsc::unbounded_channel berbahaya jika worker lebih cepat memproduksi data dibanding kemampuan UI mengonsumsinya. Memori akan membengkak tanpa batas (OOM). Dengan mpsc::channel(N), fungsi tx.send().await akan melakukan pause secara asinkron saat antrian mencapai batas N. Ini memperlambat worker secara otomatis tanpa memblokir thread.

2. Update Batching (Chunking)

Hindari mengirim event satuan ke signal UI. Akumulasikan data di level worker ke dalam buffer (seperti Vec<T>) dan kirimkan secara berkala berdasarkan limit jumlah item (misal: per 10–100 item) atau interval waktu (misal: setiap 16ms menggunakan tokio::time::interval). Pendekatan ini memangkas overhead dispatching virtual DOM Dioxus secara drastis.

Crossbeam vs Tokio MPSC: Menentukan Tool yang Tepat

  • Pilih Tokio MPSC: Ketika worker berjalan di lingkungan async (seperti web request, pooling database async, atau I/O jaringan). Integrasi dengan Dioxus async runtime berjalan mulus via .await.
  • Pilih Crossbeam Channel: Jika background task adalah native OS thread komputasi sinkron intensif CPU murni (misal: kompresi image, enkripsi file besar) tanpa Tokio runtime. Thread Freya UI dapat membaca channel Crossbeam menggunakan receiver non-blocking (rx.try_recv()) di dalam loop event polling.

Menerapkan isolasi thread berbasis channel memastikan thread rendering Skia pada Freya tetap terisolasi dari latensi eksternal, menjaga responsivitas antarmuka tetap konsisten di bawah beban kerja tinggi.