How do you migrate to Swift 6.2's approachable concurrency?
1. Scenario and constraints
You maintain an image-editing app. The UI accesses a cache while network fetches, decoding, and thumbnail generation are expensive. The old code relies on implicit thread hops, some APIs are nonisolated async, and tests only check the final image. You must adopt Swift 6.2, preserve smooth scrolling and deterministic results, and progressively enable Swift 6 data-race checking.
Start by recording baselines: main-thread frame time, decode throughput, active task count, cache hit rate, cancellation latency, and compiler diagnostics before and after migration. Without these, a performance regression and a correctness fix are easy to confuse.
2. Explain the Swift 6.2 changes first
Swift 6.2 offers the optional -default-isolation MainActor setting, which isolates code in a target to the main actor by default. It also adds @concurrent to mark functions intended to run concurrently. With NonisolatedNonsendingByDefault, a nonisolated async function keeps the caller's actor by default instead of automatically moving to the global concurrent executor; use @concurrent when parallel semantics are intended.
These options reduce annotation noise but do not make every operation parallel. Default isolation describes who may safely access state. @concurrent explicitly says that work may leave the current actor and run in parallel.
3. Set isolation boundaries
Keep mutable cache and UI state in a @MainActor type. Make pure value transformations and decoders with immutable inputs and outputs Sendable. The download layer should return values instead of objects with hidden thread affinity. For every API, document the caller actor, protected state, and whether parallel execution is allowed.
For example, keep cache protection on the main actor:
@MainActor
final class ImageCache {
private var values: [URL: Image] = [:]
func value(for url: URL) -> Image? { values[url] }
func insert(_ image: Image, for url: URL) { values[url] = image }
}Do not scatter MainActor.run around old code just to silence warnings. The migration guidance treats it as a transition tool; long-term APIs should express isolation in their types and signatures.
4. Decide when to use @concurrent
Add @concurrent only when work is safe to run in parallel, its inputs and outputs satisfy sending requirements, and a baseline shows a benefit. Image decoding often qualifies: it does not directly touch the main-actor cache and returns a newly created value.
@MainActor
struct ImagePipeline {
static func image(from url: URL) async throws -> Image {
let cached = cache.value(for: url)
if let cached { return cached }
let image = try await decode(url: url)
cache.insert(image, for: url)
return image
}
@concurrent
private static func decode(url: URL) async throws -> Image {
let (data, _) = try await URLSession.shared.data(from: url)
return try ImageDecoder.decode(data)
}
}Point out that @concurrent is not a speed switch. Excessive use adds scheduling, memory, and synchronization costs; if the decoder is internally serial or images are tiny, throughput can fall.
5. Handle caller context and Sendable
With caller-context semantics enabled, an async function may continue on the caller's actor. That helps when it needs caller-isolated state, but long CPU work can block that actor, so split out a pure @concurrent stage. Values crossing actors must be Sendable or protected by an actor, lock, or equivalent synchronization boundary.
Do not silence every diagnostic with @unchecked Sendable. Use it only as a narrow last resort after proving invariants, synchronization primitives, and lifetime, and pair it with concurrency stress tests.
6. Migrate in stages with tooling
Enable upcoming features and strict diagnostics in one module first, using migration tooling for warnings and fix-its. Stabilize isolation and Sendable constraints in public APIs, then switch that module to Swift 6 language mode. Keep each phase revertible and archive diagnostics by module.
A practical order is value types and protocol constraints, low-level services, the cache actor, and finally the UI call sites. Errors then appear at boundaries instead of hundreds of changed await expressions. During compatibility work, keep Swift 5-mode interfaces for existing clients while raising checking levels inside implementation modules.
7. Prove the absence of races and accidental serialization
Beyond functional tests, add concurrency stress: simultaneous requests for one URL, random cancellation, repeated writes, and cross-actor reads. Use Thread Sanitizer and strict concurrency diagnostics to catch races. Benchmark cold and warm caches, image sizes, and concurrency limits, comparing frame time, throughput, peak memory, and cancellation latency.
Inspect task context and asynchronous stacks to confirm decoding actually runs concurrently while cache access remains on the main actor. If @concurrent does not improve frame time, check for a blocking decoder, a single lock that serializes work, or too many tiny tasks.
8. Rubric and follow-ups
Must explain
- Distinguish default isolation, caller context, and explicit parallelism;
asyncdoes not mean a background thread. - Prove safety with
Sendable, actor boundaries, and cancellation rather than hiding errors with@unchecked Sendable. - Provide staged migration, stress tests, and performance baselines with an explanation of regression signals.
Follow-up questions
- If the decoder is not thread-safe, would you place it behind an actor, a serial executor, or an out-of-process service, and why?
- How would you coalesce duplicate downloads for one URL without serializing the entire cache?
- How can Swift 5 and Swift 6 modules share APIs during migration, and which constraints must ship first?
Scoring guide
An excellent answer connects isolation modeling, migration tooling, and runtime evidence: define state and execution context, use the smallest necessary @concurrent surface for parallelism, then validate the result with compiler diagnostics, Sanitizer, and benchmark data.