skip to content

How does capturing a mutable var interact with inline functions and concurrency? Discuss inline vs non-inline capture, and the thread-safety of mutating a captured var from multiple threads/coroutines.

level: principalimportance: nice to knowfreq 20%

answer

  1. Inline -> body pasted, captured var stays local, no Ref
  2. noinline -> real object; crossinline -> inlined, no non-local return
  3. Non-inline -> shares Ref box for captured var
  4. Ref.element not volatile/atomic -> data race
  5. Use AtomicInteger/atomicfu/Mutex/StateFlow instead

basics

~20 s

Inline lambdas paste their body in place, so a captured var often needs no wrapper. But two threads mutating the same captured var race — the Ref field isn't synchronized; use atomics or proper concurrency instead.

solid answer

~40 s

An inline function inlines the lambda body at the call site, so a captured var can stay an ordinary local — usually no Ref allocation and no escape. A non-inline lambda (stored, returned, crossinline, passed to a non-inline fn, or surviving across a coroutine suspension) becomes a real object that shares a Ref.IntRef/ObjectRef with the enclosing scope. Concurrency is the danger: mutating a captured var from multiple threads or concurrently running coroutines is a data race — Ref.element is a plain non-volatile, non-atomic field, so increments lose updates and visibility isn't guaranteed. Don't accumulate into a captured var across launch{} coroutines. Use AtomicInteger/atomicfu, a Mutex, confine to one thread, or model state with StateFlow/channels. The capture mechanism gives shared mutable state with none of the safety.

code

kotlin · 11 lines
kotlin
import kotlinx.coroutines.*
import java.util.concurrent.atomic.AtomicInteger

suspend fun safeCount(): Int = coroutineScope {
    val counter = AtomicInteger(0)          // capture the atomic, not a plain var
    repeat(1000) {
        launch(Dispatchers.Default) { counter.incrementAndGet() }
    }
    // children join at end of coroutineScope
    counter.get()                            // reliably 1000
}

go deeper

for a junior

May know inline 'pastes' code but not the capture/concurrency consequences.

for a middle

Distinguishes inline vs non-inline capture and knows shared var mutation across threads is unsafe.

for a senior

Explains noinline/crossinline semantics, the Ref non-atomic field, and names AtomicInteger/Mutex/StateFlow fixes.

for a principal

Designs concurrency around immutable accumulation/structured concurrency, sets conventions banning shared captured-var mutation, and reasons about happens-before/visibility.

## Inline vs non-inline capture Kotlin's `inline` functions copy the lambda **body** into the call site instead of creating a function object: - **Inline** (`let`, `run`, `forEach`, `repeat`, custom `inline fun`): the lambda doesn't escape; a captured `var` can remain a normal JVM local. Typically **no `Ref` box** and no allocation. - **`noinline`** parameter: forces that specific lambda to be a real object (so it can be stored/passed) — captures behave like the non-inline case (Ref box for vars). - **`crossinline`** parameter: still inlined, but forbidden from non-local returns because it may be invoked from another execution context; captures are inlined but you must treat the lambda as possibly running elsewhere. - **Non-inline** lambda (stored in a `val`, returned, passed to a non-inline higher-order function): compiled to a class that shares a `kotlin.jvm.internal.Ref` holder with the enclosing scope for any captured `var`. ```kotlin inline fun timed(block: () -> Unit) { val t = System.nanoTime(); block(); /* ... */ } // `block` is inlined; a var it captures usually stays a local, no Ref. ``` ## The concurrency hazard Capturing a `var` gives you **shared mutable state** with *no* built-in synchronization. The `Ref.element` field is a plain field — not `volatile`, not atomic. So mutating a captured var from multiple threads or concurrently scheduled coroutines is a **data race**: ```kotlin // BUG: races on the captured var var counter = 0 coroutineScope { repeat(1000) { launch(Dispatchers.Default) { counter++ } } } println(counter) // often < 1000 — lost updates, no visibility guarantee ``` `counter++` is read-modify-write; concurrent increments interleave and lose updates, and there's no happens-before edge guaranteeing one thread sees another's write. ## Correct alternatives - **`java.util.concurrent.atomic.AtomicInteger`** (or kotlinx-atomicfu `atomic(0)`): capture the atomic and call `incrementAndGet()`. - **`Mutex`** (kotlinx.coroutines) around the mutation, or confine all mutations to a single thread/dispatcher. - **Immutable accumulation**: have each coroutine return a value and combine results (`map { async { ... } }.awaitAll().sum()`), avoiding shared state. - **`StateFlow`/`MutableStateFlow`** for observable, atomically-updated state (`update { it + 1 }`), or actors/channels to serialize mutations. ## Design framing Captured-var mutation is convenient for single-threaded accumulation (a running total in a sequential loop) but is the wrong tool for concurrent code. The capture mechanism shares the variable; it does **not** make it thread-safe. Choose explicit concurrency primitives when more than one thread or coroutine writes.

  • Why does an inline forEach capturing a var usually allocate no Ref?
    The lambda body is inlined into the loop, so the captured var stays an ordinary local variable; there's no separate lambda object that needs a shared holder.
  • What does crossinline change about a captured lambda compared to a plain inline parameter?
    crossinline keeps the lambda inlined but disallows non-local returns from it, because it may be invoked from a different execution context (e.g. inside another lambda or runnable).
  • Is incrementing a captured Int var from two coroutines on Dispatchers.Default safe?
    No. It's a data race on a non-atomic field; use AtomicInteger, a Mutex, single-thread confinement, or StateFlow.update instead.

Capturing a var across threads is like several people writing on one sticky note at once: convenient for one person, chaos for many — you need a lock or separate notes.

saying these in an interview costs you the question

  • Claiming the Ref box makes captured var mutation thread-safe
  • Saying inline and non-inline lambdas allocate identical capture wrappers
  • Recommending a plain captured var for concurrent accumulation
  • Confusing crossinline (no non-local return) with noinline (becomes an object)
  • Believing counter++ on a captured var is atomic

context