Engineering

@MainActor and Dispatchers.Main are two different promises

Both mean the main thread and neither knows about the other.

MobileByteSensei 11 min read
kmpiosconcurrency

TL;DR

  • A Kotlin callback that arrives on Dispatchers.Main and writes to an @Published property compiles with Swift 6 strict concurrency as "call to main actor-isolated property in a synchronous nonisolated context" — because Swift's checker has no way to know the thread.
  • Both mean "the main thread". @MainActor is a compile-time guarantee verified by the Swift compiler; Dispatchers.Main is a runtime fact the Swift compiler cannot observe. Neither satisfies the other.
  • The fix is a single explicit hop at the boundary — MainActor.assumeIsolated where the invariant is genuinely held, and Task { @MainActor in } where it is not — and knowing which took a measurement.
  • What reading on buys: why the two systems cannot see each other, when assumeIsolated is safe versus a crash waiting, and the thread-identity probe that settles it.

The case this post is about

A Kotlin controller delivering state on the main dispatcher:

// commonMain/kotlin/feed/FeedController.kt
class FeedController(private val repo: FeedRepo) {

    // Main.immediate, so a callback from a main-thread caller runs synchronously rather than
    // being posted — which is the behaviour the UI wants and is central to what follows.
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

    fun observe(onState: (FeedState) -> Unit): Cancellable {
        onState(state.value)
        val job = scope.launch { state.collect { onState(it) } }
        return Cancellable { job.cancel() }
    }
}
// iosApp/Sources/FeedModel.swift
@MainActor
final class FeedModel: ObservableObject {
    @Published var state: FeedState = .loading
    private var subscription: Cancellable?

    init(controller: FeedController) {
        subscription = controller.observe { [weak self] newState in
            self?.state = newState        // ← here
        }
    }
}

The symptom

With Swift 6 language mode enabled:

error: call to main actor-isolated property 'state' in a synchronous nonisolated context
  FeedModel.swift:12:19
error: capture of 'self' with non-sendable type 'FeedModel?' in a '@Sendable' closure
  FeedModel.swift:11:40

Two errors on a closure that provably runs on the main thread. And in Swift 5 language mode the same code produced a warning that had been ignored for months:

warning: capture of 'self' with non-sendable type 'FeedModel?' in a '@Sendable' closure;
         this is an error in the Swift 6 language mode

The code worked. It had always worked — the callback does arrive on the main thread, every time. The compiler was not wrong either: nothing in the type system says so.

Two compile errors on a closure that provably runs on the main thread, with the same code producing an ignorable warning in the previous language mode

Why the obvious fix failed

The first fix was @unchecked Sendable, which silences it:

final class FeedModel: ObservableObject, @unchecked Sendable { … }

That is an assertion that the type is safe to share across concurrency domains, which FeedModel is not — it has mutable state with no synchronisation. It compiles and removes the one check that would have caught a genuine misuse later, which is a bad trade for a build-error fix.

The second fix was to wrap every callback body in a Task:

subscription = controller.observe { [weak self] newState in
    Task { @MainActor in self?.state = newState }
}

Compiles, correct, and it changes the timing. Dispatchers.Main.immediate delivers synchronously when already on main; a Task always suspends and resumes on the next main-actor turn. Measured on a scroll-driven update that was arriving at 60 Hz:

  direct assignment            state visible in the same frame
  Task { @MainActor in … }     state visible 1–2 frames later, and coalesced

One frame of latency per update, and SwiftUI coalescing two updates into one — which on a progress indicator looked like it was skipping values.

The third attempt was to make the Kotlin side dispatch to the main actor, which is not a thing Kotlin can express:

// there is no Dispatchers.MainActor
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

Dispatchers.Main on iOS dispatches to the main queue. The main actor is also backed by the main queue, so the thread is right — and "runs on the thread the main actor uses" is not the same statement as "is main-actor isolated", and only the second one is a type-level fact.

The actual mechanism

Two concurrency systems, both correct, with no shared vocabulary.

@MainActor is a compile-time isolation domain. The Swift compiler tracks which actor each function and property belongs to and rejects a cross-domain access that is not awaited. The guarantee is static: the checker proves the access happens in the right domain, and the runtime assertion is a consequence rather than the mechanism.

Dispatchers.Main is a runtime dispatch target. The coroutine machinery posts to the main queue (or runs inline, with .immediate, if already there). The guarantee is dynamic and real — the code genuinely executes on the main thread — and there is no artefact the Swift compiler can inspect to learn it.

The exported closure is @Sendable by necessity. Kotlin's exported function type becomes an Obj-C block, and Swift must assume a block can be called from anywhere, because nothing in the Obj-C type system constrains it. So the closure is nonisolated, and a nonisolated synchronous context cannot touch main-actor state.

Which means the invariant is true and unprovable. That is the whole situation, and it has exactly two honest resolutions:

  the callback IS always on main   → assert it: MainActor.assumeIsolated { }
                                     zero cost, and CRASHES if ever false
  the callback MIGHT not be        → hop: Task { @MainActor in }
                                     one frame of latency, always safe

assumeIsolated is not a silencer — it is a runtime precondition. It compiles to an assertion that the current execution context is the main actor, and traps if not. So it converts an unprovable invariant into a checked one, which is a genuinely different thing from @unchecked Sendable, and the difference is that a violation is a loud crash rather than silent data corruption.

And whether the invariant holds depends on the Kotlin side, which is the part that needed measuring rather than asserting.

The two guarantees compared, with one verified statically by the compiler and the other established at runtime by the dispatcher, and no artefact the compiler can inspect to learn the second

The fix

One boundary adapter, with assumeIsolated where the dispatcher guarantees it and a hop where it does not — and the Kotlin side made explicit about which it provides.

// commonMain/kotlin/feed/FeedController.kt
class FeedController(private val repo: FeedRepo) {

    /**
     * Main.immediate, documented as part of the contract rather than an implementation detail:
     * the Swift side's `assumeIsolated` is a precondition on exactly this, so changing the
     * dispatcher here turns a working app into a trap.
     */
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

    /**
     * Callbacks are invoked on the MAIN THREAD, always — synchronously when the caller is
     * already there. Consumers may assume main-actor isolation.
     */
    fun observe(onState: (FeedState) -> Unit): Cancellable { … }

    /**
     * Callbacks are invoked on an ARBITRARY thread. Named differently on purpose: the two
     * guarantees cannot share a function name, because the Swift call site has to choose a
     * different adapter and the type system will not tell it which.
     */
    fun observeOffMain(onState: (FeedState) -> Unit): Cancellable {
        // The controller's own scope, not a fresh one per call. A CoroutineScope constructed
        // inside the function is a Job nobody owns — cancelling the returned handle stops the
        // collection, and a caller who drops the handle leaks the collector instead.
        val job = offMainScope.launch { state.collect { onState(it) } }
        return Cancellable { job.cancel() }
    }

    private val offMainScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

    /** Cancels both scopes. Called from the platform when the controller is discarded. */
    fun close() { scope.cancel(); offMainScope.cancel() }
}
// iosApp/Sources/KotlinBridge.swift
//
// The one place the two systems meet. Centralised so the unsafe-looking call appears once,
// with the invariant it depends on written next to it, rather than at every call site.
enum KotlinBridge {

    /// For callbacks the Kotlin contract guarantees are on main.
    ///
    /// `assumeIsolated` is a PRECONDITION, not a silencer: it compiles to an assertion that
    /// the current context is the main actor and traps if not. So a Kotlin-side dispatcher
    /// change is a loud crash in the first second of use rather than silent corruption —
    /// which is the property `@unchecked Sendable` would have thrown away.
    static func onMain<T>(_ value: T, _ body: @MainActor (T) -> Void) {
        MainActor.assumeIsolated { body(value) }
    }

    /// For callbacks with no thread guarantee. Costs one main-actor turn, which is the honest
    /// price of not knowing.
    static func hopToMain<T: Sendable>(_ value: T, _ body: @escaping @MainActor (T) -> Void) {
        Task { @MainActor in body(value) }
    }
}
// iosApp/Sources/FeedModel.swift
@MainActor
final class FeedModel: ObservableObject {
    @Published var state: FeedState = .loading
    private var subscription: Cancellable?

    init(controller: FeedController) {
        // observe() guarantees main, so assumeIsolated — no hop, no frame of latency, and a
        // trap if that guarantee ever stops holding.
        subscription = controller.observe { [weak self] newState in
            KotlinBridge.onMain(newState) { s in self?.state = s }
        }
    }
}

And the output it produced:

$ # target build settings: Swift 6 language mode, strict concurrency "complete"
$ xcodebuild -scheme iosApp -configuration Release build

BUILD SUCCEEDED
  0 errors, 0 warnings

$ ./tools/update-latency-probe.sh --updates 600
                               frames to visible   coalesced updates
  direct (pre-Swift-6)                  0                  0
  Task { @MainActor in }                1.4 avg           38 of 600
  assumeIsolated                        0                  0

How we proved it

That the callback really does arrive on the main thread — because assumeIsolated is a precondition and shipping it on an unverified assumption is how you get a trap in production:

// a probe, in a debug build, on every callback for one session
controller.observe { _ in
    assert(Thread.isMainThread, "callback off main")
    probeCounter.record(thread: Thread.current.description)
}
  callbacks observed                 37,944
  on the main thread                 37,944
  on any other thread                     0
  sessions probed                        37

Thirty-eight thousand callbacks, all on main. Which justifies the precondition and does not prove it — see the limit below.

That Main.immediate is what makes it synchronous, and that the distinction is observable:

  dispatcher                     callback arrives
  Dispatchers.Main.immediate     synchronously, in the caller's frame   (when already on main)
  Dispatchers.Main               posted, next main-queue turn
  Dispatchers.Default            on a worker thread

So the Swift side's choice of adapter depends on a Kotlin-side dispatcher the Swift side cannot see — which is why the two Kotlin functions have different names rather than a parameter.

That the Task hop costs a frame and coalesces. Measured, because "just hop, it is safer" is the easy answer:

  600 state updates during a scroll
                            frames to visible (avg)   updates SwiftUI coalesced
  assumeIsolated                     0.0                        0
  Task { @MainActor in }             1.4                       38

Thirty-eight of six hundred updates never rendered as distinct values. On a progress indicator that is visible as skipping.

That @unchecked Sendable removes a real check. The fixture is the misuse it would then permit:

// with @unchecked Sendable on FeedModel
Task.detached { model.state = .loaded([]) }      // compiles, writes from a background thread
  @unchecked Sendable        compiles, data race, no diagnostic
  assumeIsolated at the edge compiles; the detached write is still an ERROR

assumeIsolated narrows the exemption to one boundary; @unchecked Sendable exempts the whole type everywhere, including the places the checker would have been right.

That assumeIsolated traps rather than corrupting when the invariant breaks. Which is the whole argument for it over the alternatives, so it is worth demonstrating:

$ # the Kotlin side deliberately switched to Dispatchers.Default
$ # run the app, open the feed
Fatal error: Incorrect actor executor assumption; Expected 'MainActor' executor.
  KotlinBridge.swift:14

Immediate, loud, at the boundary, naming the file. Against @unchecked Sendable, where the same change produces an intermittently-corrupted @Published value and a SwiftUI crash somewhere else.

What I cannot tell you is that 37,944 callbacks is a proof. It is evidence for the paths we exercised across 37 sessions, and a code path that dispatches differently — an error callback, a background refresh added later — would violate the precondition and trap. That is the designed failure mode and it is still a crash in front of a user. The mitigation is that the contract is documented on the Kotlin function and the trap names the one file to look at.

And an honest limit on the two-function approach. observe and observeOffMain encode the guarantee in a name, which the compiler does not check — nothing stops a future edit from changing observe's dispatcher while keeping the name. A detekt rule now forbids changing the scope in that class without updating the KDoc, which is a weak check for a strong requirement, and I have not found a better one.

Thirty-eight thousand callbacks measured for thread identity, and the frame cost of hopping instead of asserting, with thirty-eight of six hundred updates coalesced away

What this generalizes to

Two systems can both be correct about the same fact and unable to exchange it, because one encodes it statically and the other establishes it at runtime — and a boundary between them is where an unprovable-but-true invariant has to be asserted or paid for. The useful distinction is between a silencer and a precondition: one removes the check and the other relocates it to a single place where a violation is loud. Prefer the loud one, centralise it, and write the invariant it depends on next to it — because the thing that will eventually break it is a change on the other side of the boundary, made by someone who cannot see your assertion.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS