Skip to content
C.W.K.
Stream
Lesson 03 of 07 · published

Actors, @MainActor, and Sendable at the Framework Boundary

~17 min · concurrency, actors, sendable, isolated-conformance, nonisolated

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Every annotation you add under Swift 6 is a fact the old build was silently assuming."

The Three Words

  • An actor owns mutable state and serializes access to it. Code outside the actor reaches in with await; inside, methods run one at a time. @MainActor is the one global actor you already know: "the main thread", as a type.
  • Sendable marks a value that is safe to hand to another isolation domain. A struct or enum whose fields are all Sendable is inferred Sendable as long as it is not public, and actors always are. A final class with only immutable Sendable state can be Sendable too, but only when it declares the conformance.
  • @unchecked Sendable is your promise instead of the compiler's — legitimate for a small type that protects its state with a lock, dishonest for anything else.

Inside your own code, these fit together neatly. The friction is at the boundary with frameworks designed before Swift concurrency existed. Three patterns come up again and again in the family's apps.

1. A Main-Actor Class Conforming to an Old Protocol

A file browser's @MainActor view controller adopted NSServicesMenuRequestor and Swift 6 refused: conformance crosses into main actor-isolated code and can cause data races. The protocol does not say where AppKit calls it, so the compiler cannot assume main. You can: AppKit always calls it on the main thread. Say so on the conformance itself — extension LibraryController: @MainActor NSServicesMenuRequestor — an isolated conformance. @preconcurrency is the fallback that turns the question into a runtime check.

2. A Nonisolated Delegate That Always Arrives on Main

A terminal library's delegate protocol was declared nonisolated, but the library delivered every callback on the main queue. The Mac terminal app declared its witnesses nonisolated and wrapped each body in MainActor.assumeIsolated { … }: a statement of the delivery contract that the runtime checks, while an isolated conformance is checked by the compiler, and at run time only when a dynamic cast looks it up (off its actor, the cast fails). One caution from the same app: calling assumeIsolated inside a high-frequency mouse-moved event monitor crashed the Swift 6.3 runtime. There, a plain Task { @MainActor in … } hop was the safe shape. Note too that NSEvent is not Sendable, so the closure cannot return the event — return a Bool and map it outside.

3. An Actor Behind a Synchronous Protocol Requirement

An iOS client's network layer was an actor conforming to a protocol whose requirement was a synchronous function returning an AsyncThrowingStream. An actor method is isolated, and an isolated method cannot satisfy a synchronous requirement: "conformance crosses into actor-isolated code". The fix is to make the witness nonisolated, and do every touch of actor state inside the stream's task through a private isolated helper. Immutable lets of Sendable type stay readable from the nonisolated method.

Code

Isolated conformance: the fact goes on the conformance·swift
import AppKit

@MainActor
final class LibraryController: NSViewController {
    var selection: [URL] = []
}

// Without "@MainActor" here, Swift 6 reports:
// conformance of 'LibraryController' to protocol 'NSServicesMenuRequestor'
// crosses into main actor-isolated code and can cause data races
extension LibraryController: @MainActor NSServicesMenuRequestor {
    func writeSelection(to pboard: NSPasteboard, types: [NSPasteboard.PasteboardType]) -> Bool {
        pboard.writeObjects(selection as [NSURL])
    }
}
An actor behind a synchronous requirement: nonisolated witness, isolated helper·swift
import Foundation

public protocol CaptureStreaming: Sendable {
    func stream(_ id: String) -> AsyncThrowingStream<String, Error>
}

public actor CaptureClient: CaptureStreaming {
    private var token = "t0"

    public nonisolated func stream(_ id: String) -> AsyncThrowingStream<String, Error> {
        AsyncThrowingStream { continuation in
            let task = Task {
                let token = await self.currentToken()     // actor state, through the actor
                continuation.yield("\(id) with \(token)")
                continuation.finish()
            }
            continuation.onTermination = { _ in task.cancel() }
        }
    }

    private func currentToken() -> String { token }
}

External links

Exercise

In Spark, write a @MainActor window controller that conforms to an AppKit delegate protocol of your choice (NSServicesMenuRequestor produces the diagnostic; NSWindowDelegate no longer does, because the SDK already marks it for the main actor) and build it in Swift 6 mode. Record the diagnostic if there is one, then fix it with an isolated conformance. Next, write an actor CaptureClient that must satisfy a synchronous protocol requirement, and fix the resulting error with a nonisolated witness plus an isolated helper.
Hint
For each fix, write one sentence naming the real-world guarantee it encodes: "AppKit calls this on main", or "this method only hands out a stream; all state access happens on the actor". If you cannot name the guarantee, the annotation is a guess.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.