All skills

Use Swift 6.2 approachable concurrency. Keep code on the caller's actor by default. Use @concurrent for clear background work. Use isolated conformances for MainActor types.

  • 1 file
  • 9.7 KB
  • Updated 2 weeks ago
  • GitHub

Use this Skill: https://skilld.dev/gh/agenticluke/swift-approachable-concurrency-plus/skill

This session only. Nothing lands on disk.

SKILL.md

≈45 tokens always: the name and description. ≈2.4k when used: this file.

Swift 6.2 Approachable Concurrency

Use this skill to write or move code to the Swift 6.2 concurrency model.

The main rule is simple:

  • Keep code on the caller's actor by default.
  • Use @concurrent only for work that should run at the same time.
  • Use @MainActor for UI state and shared app state.
  • Use an isolated conformance when a protocol must follow an actor.

This model helps the compiler find data races before the app runs.

Use This Skill When

Use these steps when:

  • Moving a Swift 5.x, 6.0, or 6.1 project to Swift 6.2
  • Fixing compiler errors about sending values or data races
  • Building an app around MainActor
  • Moving slow CPU work off the main actor
  • Making a MainActor type follow a protocol
  • Turning on approachable concurrency in Xcode 26

Check the Build Settings First

Swift 6.2 features can depend on target settings. Before changing code, check the settings for each app, test, and package target.

Look for these Swift compiler options:

  • Default actor isolation
  • Nonisolated async functions that stay on the caller's actor
  • Strict concurrency checks
  • Swift language mode

For an app target, MainActor is often a good default actor. A library may need a different default because its code can be called from many actors.

Do not assume all targets use the same settings.

Async Does Not Mean Background Work

An async function can pause. It does not always move work to a background thread.

With the Swift 6.2 setting for caller actor isolation, a plain async function stays on the caller's actor unless its code asks to run elsewhere.

@MainActor
final class StickerModel {
    let photoProcessor = PhotoProcessor()

    func extractSticker(
        from item: PhotosPickerItem
    ) async throws -> Sticker? {
        guard let data = try await item.loadTransferable(
            type: Data.self
        ) else {
            return nil
        }

        return await photoProcessor.extractSticker(
            data: data,
            id: item.itemIdentifier
        )
    }
}

This can prevent errors caused by sending non-Sendable state to another actor.

It does not make slow work safe for the main actor. A long loop can still freeze the UI.

Use @MainActor for UI and Shared App State

Put UI state and app-wide mutable state on MainActor.

@MainActor
final class StickerLibrary {
    static let shared = StickerLibrary()

    private(set) var stickers: [Sticker] = []

    func add(_ sticker: Sticker) {
        stickers.append(sticker)
    }
}

Code outside MainActor must use await to read or change this state.

await StickerLibrary.shared.add(sticker)

A global actor protects access. It does not make each stored value Sendable.

Default MainActor Isolation

Swift 6.2 can make MainActor the default isolation for a target.

final class StickerModel {
    var selection: [PhotosPickerItem] = []
}

In a target with default MainActor isolation, this type is treated as main actor code unless it says otherwise.

This setting is useful for:

  • UI apps
  • Scripts
  • Other executable targets

Use care in library targets. A library often needs types that work from many actors.

Do not rely on this setting when code is shared across targets. Add @MainActor when the rule must be clear in the source.

Use an Isolated Protocol Conformance

A MainActor type may follow a protocol only on MainActor.

protocol Exportable {
    func export()
}

@MainActor
final class StickerModel {
    let photoProcessor = PhotoProcessor()
}

extension StickerModel: @MainActor Exportable {
    func export() {
        photoProcessor.exportAsPNG()
    }
}

Use the value from a matching actor:

@MainActor
struct ImageExporter {
    private var items: [any Exportable] = []

    mutating func add(_ item: StickerModel) {
        items.append(item)
    }
}

A nonisolated caller cannot use this conformance without moving to MainActor.

func exportOnMain(_ item: StickerModel) async {
    await MainActor.run {
        item.export()
    }
}

Check generic code and stored protocol values. They must keep the same actor rule.

Use @concurrent for Slow CPU Work

Use @concurrent when work should run away from the caller's actor.

Good uses include:

  • Image changes
  • File compression
  • Audio work
  • Large math tasks
  • Parsing a large block of data

The function should use values that are safe to send. Its result should also be safe to send.

struct Sticker: Sendable {
    let pixels: [UInt8]
}

enum StickerProcessor {
    @concurrent
    static func findSubject(
        in pixels: [UInt8]
    ) async -> Sticker {
        let output = runSlowImageWork(pixels)
        return Sticker(pixels: output)
    }
}

Call it with await:

let sticker = await StickerProcessor.findSubject(
    in: imagePixels
)

@concurrent does not make shared mutable state safe. Do not read or write a normal class property from several calls at once.

Use an actor for a cache:

actor StickerCache {
    private var values: [String: Sticker] = [:]

    func value(for id: String) -> Sticker? {
        values[id]
    }

    func store(_ sticker: Sticker, for id: String) {
        values[id] = sticker
    }
}

Full Usage Example

This example keeps UI state on MainActor, runs image work with @concurrent, and protects the cache with an actor.

struct Sticker: Sendable {
    let pixels: [UInt8]
}

actor StickerCache {
    private var values: [String: Sticker] = [:]

    func value(for id: String) -> Sticker? {
        values[id]
    }

    func store(_ sticker: Sticker, for id: String) {
        values[id] = sticker
    }
}

enum StickerProcessor {
    @concurrent
    static func process(_ pixels: [UInt8]) async -> Sticker {
        let output = runSlowImageWork(pixels)
        return Sticker(pixels: output)
    }
}

@MainActor
final class StickerViewModel {
    private let cache = StickerCache()
    private(set) var currentSticker: Sticker?

    func load(id: String, pixels: [UInt8]) async {
        if let saved = await cache.value(for: id) {
            currentSticker = saved
            return
        }

        let sticker = await StickerProcessor.process(pixels)
        await cache.store(sticker, for: id)
        currentSticker = sticker
    }
}

How to Add @concurrent

Before adding it:

  1. Find a slow CPU task with a profiler.
  2. Make the input safe to send.
  3. Make the result safe to send.
  4. Remove access to unsafe shared state.
  5. Mark the async function with @concurrent.
  6. Add await at each call.
  7. Test cancellation, errors, and repeated calls.

A type does not always need to be marked nonisolated. Use nonisolated only when the type or member must not use the target's default actor.

Migration Steps

  1. Move to Swift 6.2 one target at a time.
  2. Turn on strict concurrency checks.
  3. Pick the target's default actor isolation.
  4. Fix shared mutable state with MainActor or another actor.
  5. Use isolated conformances for actor-bound types.
  6. Keep normal async work on the caller's actor.
  7. Add @concurrent only to proven slow CPU work.
  8. Check that sent inputs and results are Sendable.
  9. Build and test all app, test, and package targets.
  10. Run Thread Sanitizer tests when useful.

Edge Cases

Long Work Can Still Block the UI

An async function on MainActor can freeze the UI if it does long work without a pause. Move that CPU work to an @concurrent function.

@concurrent Is Not a Lock

It does not protect global variables, static variables, caches, or class fields. Use an actor, a global actor, or another safe design.

Sendable Does Not Protect Mutable State

A value marked Sendable must truly be safe to move between actors. Do not add unchecked conformance only to silence the compiler.

Cancellation Must Be Checked

Long loops should stop when the task is cancelled.

@concurrent
func processItems(_ items: [Item]) async throws -> [Result] {
    var output: [Result] = []

    for item in items {
        try Task.checkCancellation()
        output.append(process(item))
    }

    return output
}

Actor Reentry Can Change State

An actor method may pause at await. Other work can then enter the actor and change its state. Check key state again after an await when needed.

Old APIs May Need a Bridge

Some older APIs use callbacks or DispatchQueue. Wrap them with async tools when needed, but keep the actor rule clear. Do not remove a queue until you know what state it protects.

Tests May Use Different Isolation

A test target may not use the app target's default actor. Add @MainActor to a test or test type when it reads main actor state.

Good Rules

  • Start with simple actor-bound code.
  • Keep UI state on MainActor.
  • Use actors for shared mutable state.
  • Use @concurrent only for slow CPU work.
  • Measure before moving work.
  • Make sent values Sendable.
  • Check cancellation in long tasks.
  • Migrate one target at a time.
  • Treat compiler race errors as real design warnings.

Avoid These Patterns

  • Adding @concurrent to every async function
  • Using nonisolated only to hide an error
  • Adding @unchecked Sendable without a full safety review
  • Sharing a mutable class across concurrent calls
  • Assuming async means background work
  • Assuming @concurrent makes state thread-safe
  • Using Task.detached when normal actor rules will work
  • Keeping old queue code without knowing what it protects
  • Reading main actor state from nonisolated code without await
  • Relying on target defaults in code shared by many targets

Source: SKILL.md on GitHub

No third-party reports yet.

Signed by skilld at ed5b77a. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated 2 weeks ago

README badge

README badge for agenticluke/swift-approachable-concurrency-plus