Skip to content
Rust & Native

Threading and Async: Tokio, Kotlin Coroutines, and Calling Back Into Kotlin from Rust

By David Cruz Anaya, Senior Mobile & Applied AI Engineer (Android, Kotlin Multiplatform, product AI)

Updated 18 September 202610 min read
AndroidRustNDKTokioKotlin CoroutinesJNI
Threading and Async: Tokio, Kotlin Coroutines, and Calling Back Into Kotlin from Rust
Share:

Rust + NDK on Android, Part 9 of 15.This series teaches Android developers how to ship Rust in production apps, no prior Rust required. Hub: Rust on Android: How to Use It, How to Debug It, and Why It's Worth It. Previous: Error Handling and Panics: Keeping Rust Crashes Out of Your Android App. Next: Low-Latency Audio DSP with Rust: AAudio and Oboe from a Rust Core.

The whole pattern fits in one sentence: store the JavaVM once in JNI_OnLoad, run a single Tokio runtime for the life of the process, hand any Kotlin object you give to a Rust task as a global reference, attach the Tokio worker to the JVM before it calls back, and wrap the callback API in suspendCancellableCoroutine so Kotlin sees an ordinary suspend function. Every step exists because of one JNI fact: a JNIEnv belongs to exactly one thread, and Rust threads start life with none.

Nearly every async JNI crash I have debugged comes from ignoring that fact: a JNIEnv smuggled onto the wrong thread, or a local reference outliving the call that created it. This post builds the machinery once, end to end, so you can copy the shape instead of rediscovering the crashes. It assumes the toolchain setup from Part 1 and the JNI basics from Part 2.

Key Takeaways

  • A JNIEnv is thread-local and can never be shared between threads; the JavaVM is process-wide and thread-safe, so capture the VM once and derive a fresh env on whichever thread needs one (Android JNI tips).
  • One Tokio runtime, built lazily, never shut down. On Android, process death is the shutdown.
  • Every jobject that crosses into a Rust task needs a global reference, and long-lived Tokio workers should attach to the JVM permanently instead of paying attach and detach churn per callback.
  • suspendCancellableCoroutine turns the Rust callback API into a plain suspend function, and invokeOnCancellation gives structured concurrency a path back into Rust to abort the task.

Why can’t a Rust thread just call into Kotlin?

Because JNI calls go through a JNIEnv, and the Android JNI tips are blunt about what that is: “The JNIEnv is used for thread-local storage. For this reason, you cannot share a JNIEnv between threads.” The env your extern "system" function receives is valid on that thread, for that call, and nowhere else. A thread spawned by Rust, whether through std::thread or a Tokio worker pool, has no env at all until it asks for one.

The type that does cross threads is the JavaVM: one per process, valid everywhere. The same docs give the recipe: share the JavaVM and use it to discover the current thread’s env. The jni crate encodes this in its types. JNIEnv is neither Send nor Sync, so the compiler stops you from smuggling one across a spawn; JavaVM is both, so a static is exactly where it belongs.

So a background thread that wants to call Kotlin attaches itself with AttachCurrentThread and receives a valid env of its own. Attaching is not free: per the same docs it constructs a java.lang.Thread object, adds it to the main ThreadGroup, and obliges the thread to detach before it exits. The callback code below handles both costs.

Capture the JavaVM in JNI_OnLoad

The crate for this part lives at rust/async-bridge/Cargo.toml:

[package]
name = "async-bridge"
version = "0.1.0"
edition = "2021"

[lib]
name = "async_bridge"
crate-type = ["cdylib"]

[dependencies]
jni = "0.21"
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
log = "0.4"
android_logger = "0.15"

When System.loadLibrary pulls in your .so, the JVM looks for a JNI_OnLoad export and calls it with the one argument we actually want. This is the top of rust/async-bridge/src/lib.rs:

use std::collections::HashMap;
use std::ffi::c_void;
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;

use jni::objects::{GlobalRef, JObject, JString, JValue};
use jni::sys::{jint, jlong, JNI_VERSION_1_6};
use jni::{JNIEnv, JavaVM};
use tokio::runtime::Runtime;
use tokio::task::AbortHandle;

static JVM: OnceLock<JavaVM> = OnceLock::new();
static RUNTIME: OnceLock<Runtime> = OnceLock::new();
static TASKS: OnceLock<Mutex<HashMap<i64, AbortHandle>>> = OnceLock::new();
static NEXT_ID: AtomicI64 = AtomicI64::new(1);

#[no_mangle]
pub extern "system" fn JNI_OnLoad(vm: JavaVM, _reserved: *mut c_void) -> jint {
    android_logger::init_once(
        android_logger::Config::default()
            .with_max_level(log::LevelFilter::Info)
            .with_tag("async-bridge"),
    );
    // The JavaVM is process-wide and thread-safe. Stored once here,
    // it serves every thread for the lifetime of the process.
    let _ = JVM.set(vm);
    JNI_VERSION_1_6
}

If you would rather skip JNI_OnLoad, env.get_java_vm() returns the same value inside any JNI call. I prefer JNI_OnLoad: it runs exactly once, before anything can race it.

How do you run a Tokio runtime inside an Android app?

Build it lazily, keep it small, and never tear it down. Tokio’s multi-thread scheduler defaults its worker count to the number of CPU cores (tokio Builder docs), which is a server-shaped default. A phone running IO-bound work next to the JVM’s own pools does not need eight workers; two is plenty here, and naming them pays off because OS thread names show up in profilers. Continuing in rust/async-bridge/src/lib.rs:

fn runtime() -> &'static Runtime {
    RUNTIME.get_or_init(|| {
        tokio::runtime::Builder::new_multi_thread()
            .worker_threads(2)
            .thread_name("rust-tokio")
            .enable_time()
            .build()
            .expect("failed to build Tokio runtime")
    })
}

fn tasks() -> &'static Mutex<HashMap<i64, AbortHandle>> {
    TASKS.get_or_init(|| Mutex::new(HashMap::new()))
}

// Stand-in for real async work: a network call, a heavy parse, a sync job.
async fn fetch_greeting(name: String) -> Result<String, String> {
    tokio::time::sleep(Duration::from_millis(300)).await;
    if name.trim().is_empty() {
        Err("name must not be blank".to_owned())
    } else {
        Ok(format!("Hello from a Tokio task, {name}"))
    }
}

The lifecycle question, when do you shut the runtime down, has an unsatisfying but honest answer: you don’t. Android does not give your process a graceful exit; it kills it. A runtime living in a static until process death is not a leak, it is the platform’s memory model. Host-side tests are the exception, and Runtime::shutdown_background exists for them.

JNI callbacks into Kotlin from a Rust Tokio task

Here is the entry point Kotlin calls to start work, and the delivery function the Tokio task uses to come back. Both go in rust/async-bridge/src/lib.rs:

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_AsyncBridge_startFetch<'local>(
    mut env: JNIEnv<'local>,
    _this: JObject<'local>,
    name: JString<'local>,
    callback: JObject<'local>,
) -> jlong {
    let name: String = env.get_string(&name).map(Into::into).unwrap_or_default();

    // `callback` is a local reference. It dies when this function returns,
    // so the task must hold a global reference instead.
    let callback: GlobalRef = match env.new_global_ref(&callback) {
        Ok(global) => global,
        Err(_) => return 0,
    };

    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);

    // Holding the lock across spawn + insert means the task cannot
    // deregister itself before its abort handle is registered.
    let mut map = tasks().lock().expect("tasks mutex poisoned");
    let join = runtime().spawn(async move {
        let result = fetch_greeting(name).await;
        // Deregister first: any cancel arriving from now on is a no-op.
        tasks().lock().expect("tasks mutex poisoned").remove(&id);
        deliver(&callback, result);
    });
    map.insert(id, join.abort_handle());
    id
}

/// Runs on a Tokio worker thread. Attaches it to the JVM, then calls
/// FetchCallback.onSuccess or FetchCallback.onError on the Kotlin side.
fn deliver(callback: &GlobalRef, result: Result<String, String>) {
    let vm = JVM.get().expect("JavaVM must be stored in JNI_OnLoad");

    // Permanent attach: the worker stays attached until the thread exits,
    // so later callbacks from this thread skip the attach cost entirely.
    let mut env = match vm.attach_current_thread_permanently() {
        Ok(env) => env,
        Err(e) => {
            log::error!("failed to attach Tokio worker to the JVM: {e}");
            return;
        }
    };

    let (method, text) = match result {
        Ok(value) => ("onSuccess", value),
        Err(message) => ("onError", message),
    };

    let payload: JObject = match env.new_string(text) {
        Ok(s) => s.into(),
        Err(e) => {
            log::error!("failed to allocate Java string: {e}");
            return;
        }
    };

    if let Err(e) = env.call_method(
        callback,
        method,
        "(Ljava/lang/String;)V",
        &[JValue::Object(&payload)],
    ) {
        log::error!("callback into Kotlin failed: {e}");
        // If the Kotlin callback threw, clear the pending exception so it
        // cannot poison the next JNI call made from this thread.
        if env.exception_check().unwrap_or(false) {
            let _ = env.exception_describe();
            let _ = env.exception_clear();
        }
    }
}

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_AsyncBridge_cancelFetch(
    _env: JNIEnv,
    _this: JObject,
    id: jlong,
) {
    if let Some(abort) = tasks().lock().expect("tasks mutex poisoned").remove(&id) {
        abort.abort();
    }
}

Three decisions in that code deserve a closer look.

The global reference is not optional. Every argument passed to a native method is a local reference, valid only for the duration of that call on that thread, per the Android docs. The task outlives the call by design, so it needs the new_global_ref move covered in depth in Part 7 on JNI memory management. The jni crate closes the loop: when the task drops the GlobalRef, the crate deletes the JNI global reference, attaching the thread first if it must.

Permanent attach beats a guard here. The jni crate offers three flavors. attach_current_thread returns a guard that detaches on drop: correct, but a long-lived worker then repeats the attach dance, and its java.lang.Thread construction, on every callback. attach_current_thread_permanently attaches once and detaches automatically when the thread terminates, satisfying the detach-before-exit rule. The daemon variant, attach_current_thread_as_daemon, only changes JVM shutdown behavior, and Android kills processes instead of shutting the VM down, so it rarely matters in an app. For a fixed Tokio pool, permanent attach is the right default.

Cancellation goes through an id, not a pointer. I could hand Kotlin a raw Box::into_raw pointer to the AbortHandle, but a cancel racing a normal completion would become a double-free question. The Mutex map makes both races boring: cancel after completion finds nothing, and completion after cancel never runs because abort drops the future at its next await point, callback unfired, global reference released.

The matching Kotlin declarations live in app/src/main/java/dev/davthecoder/rustseries/AsyncBridge.kt:

package dev.davthecoder.rustseries

interface FetchCallback {
    fun onSuccess(payload: String)
    fun onError(message: String)
}

object AsyncBridge {
    init {
        System.loadLibrary("async_bridge")
    }

    external fun startFetch(name: String, callback: FetchCallback): Long
    external fun cancelFetch(handle: Long)
}

Bridging the callback into Kotlin coroutines

A callback interface is a working API and a miserable one. Kotlin callers want a suspend function, and suspendCancellableCoroutine is the standard adapter for exactly this situation: a one-shot, callback-based API that supports cancellation (kotlinx.coroutines docs). The wrapper goes in app/src/main/java/dev/davthecoder/rustseries/FetchGreeting.kt:

package dev.davthecoder.rustseries

import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException

class RustFetchException(message: String) : Exception(message)

suspend fun fetchGreeting(name: String): String =
    suspendCancellableCoroutine { continuation ->
        val handle = AsyncBridge.startFetch(name, object : FetchCallback {
            override fun onSuccess(payload: String) {
                continuation.resume(payload)
            }

            override fun onError(message: String) {
                continuation.resumeWithException(RustFetchException(message))
            }
        })
        continuation.invokeOnCancellation {
            AsyncBridge.cancelFetch(handle)
        }
    }

Notice which thread does what. onSuccess runs on the Tokio worker. Resuming there is safe because the continuation hands the coroutine back to its dispatcher; code after the suspension point runs wherever the caller’s context says, typically Dispatchers.Main in a ViewModel. You never touch a view from the callback, and you never need to.

The cancellation path is the part most bridges get wrong, so walk it once. A caller’s scope dies, structured concurrency cancels the coroutine, invokeOnCancellation fires and calls cancelFetch, Rust removes the abort handle and aborts the task, and the callback never arrives. If the callback was already in flight, the prompt cancellation guarantee covers you: once the job is cancelled, the coroutine will not resume successfully even if resume was already invoked. Both orderings end quietly. If the Job machinery underneath that sentence feels hazy, my post on Job types in Kotlin coroutines covers it.

Usage is now indistinguishable from any other suspend call. From app/src/main/java/dev/davthecoder/rustseries/GreetingViewModel.kt:

package dev.davthecoder.rustseries

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch

class GreetingViewModel : ViewModel() {
    fun load() {
        viewModelScope.launch {
            val message = try {
                fetchGreeting("David")
            } catch (e: RustFetchException) {
                "Rust said no: ${e.message}"
            }
            // message arrives on the main dispatcher, ready for UI state.
        }
    }
}

When this ViewModel clears, viewModelScope cancels the coroutine and the whole chain unwinds down into Tokio without another line of code: lifecycle awareness for free, on the Rust side of the border.

The rules that keep you out of a tombstone

Everything above compresses into five rules I apply to any Rust threading code that touches JNI.

  1. Never cache a JNIEnv. Not in a static, not in a struct, never across threads. Store the JavaVM, derive an env where you need it. The jni crate’s Send bounds enforce this.
  2. Global-ref every jobject that outlives the call or changes threads. Locals die with the call that made them, whatever the underlying object does. Part 7 on JNI reference types and leaks covers the reference zoo in full.
  3. Attached threads must detach before exiting. Use the guard for short-lived threads and permanent attach for pools, and let the jni crate own the bookkeeping in both cases.
  4. One runtime, created once, never shut down. Treat it like the process-level resource it is.
  5. Assume callbacks land on foreign threads. Let coroutine dispatchers do the thread hop; a callback that touches UI state directly is a bug waiting for a slow device to prove it.

One panic note to close the loop with Part 8 on error handling and panics: a panic inside a spawned Tokio task does not cross the JNI boundary, because the runtime catches it and reports it through the JoinHandle, which this design drops. That silence is why the logcat panic hook from Part 8 stays mandatory here, and if your release profile sets panic = "abort", doubly so: the task panic then kills the process directly.

Frequently Asked Questions

How do I call a Kotlin method from a Rust thread?

Store the JavaVM in JNI_OnLoad, hold the target object as a global reference, then attach the Rust thread with attach_current_thread_permanently to get a valid JNIEnv and invoke call_method with the method name and JNI signature. Without the attach there is no env; without the global reference the object is invalid off its original call.

Do Rust threads need to detach from the JVM?

Yes, JNI requires every attached thread to detach before it exits (Android JNI tips). With the jni crate you rarely write that code: the attach_current_thread guard detaches when dropped, and attach_current_thread_permanently registers a thread-exit cleanup that detaches for you.

Can Tokio share a thread pool with Kotlin coroutines?

No, and you should not try. Tokio workers and coroutine dispatchers are separate pools with separate schedulers, and the bridge between them is a callback plus a continuation, not a shared executor. Keep the Tokio pool small so the two runtimes do not fight over cores Android’s own threads also want.

Does UniFFI handle async Rust for me?

Mostly, yes. UniFFI can expose an async fn as a Kotlin suspend function directly, which removes the callback and continuation plumbing from this post (UniFFI futures docs). Its docs note that cancellation is not built in, so a cancel path like this post’s abort registry remains your job. Part 3 on UniFFI bindings covers where UniFFI fits overall.

Where this leaves you

This is the part that makes Rust feel native to a Kotlin codebase instead of bolted onto it. Once the bridge exists, a ViewModel calls fetchGreeting like any repository function, and nobody reading the Kotlin can tell the work changed languages halfway through.

The next post takes these threading rules somewhere much less forgiving. Part 10, Low-Latency Audio DSP with Rust: AAudio and Oboe from a Rust Core, puts Rust on the audio thread, where blocking and allocation are banned and even JNI is off the table. If you landed here mid-series, the hub post maps all fifteen parts.

Sources

Share:

Comments

Loading comments…