Skip to main content
Concurrency and Performance

Pet Performance Multitasking: A Swift Concurrency Checklist for Apps

Introduction: Why Your App Needs a Multitasking ChecklistModern iOS apps juggle network calls, database writes, UI updates, and background tasks—all while the user expects instant responsiveness. Swift concurrency (async/await, actors, and structured concurrency) promises a safer, more readable path to multitasking, but it also introduces new failure modes. A method that worked with completion handlers can deadlock or starve the main actor if migrated carelessly. This guide distills field-tested

图片

Introduction: Why Your App Needs a Multitasking Checklist

Modern iOS apps juggle network calls, database writes, UI updates, and background tasks—all while the user expects instant responsiveness. Swift concurrency (async/await, actors, and structured concurrency) promises a safer, more readable path to multitasking, but it also introduces new failure modes. A method that worked with completion handlers can deadlock or starve the main actor if migrated carelessly. This guide distills field-tested practices into a checklist you can apply during code review, refactoring, or initial design. We'll walk through ten critical areas, each with a concrete scenario and actionable steps. By the end, you'll have a repeatable framework to evaluate and improve concurrency health in your apps.

1. Understanding the Core Concepts

Swift concurrency is built on a few foundational ideas that every developer must internalize. Without this foundation, you risk writing code that appears correct but behaves unpredictably under load. Let's break down the core concepts and why they matter for performance.

Async/Await: The Surface Layer

The most visible change is the async/await syntax, which replaces nested closures with linear code. However, async functions are not just syntactic sugar—they represent potential suspension points. When a function calls await, it may suspend the current task, allowing other tasks to run on the same thread. This cooperative threading model is efficient but requires careful design to avoid blocking the main thread or creating priority inversions.

One common mistake is assuming that all async functions are cheap. For example, fetching a small user profile might be fast, but if the server is slow, the suspension could delay other critical tasks. Teams often find that wrapping synchronous I/O inside an async function (using Task.detached) does not make it non-blocking—it still occupies a thread from the cooperative pool. The key insight: async/await changes the structure of code but not the underlying I/O behavior.

Actors: Protecting Shared State

Actors are reference types that isolate their mutable state, ensuring only one task accesses that state at a time. This eliminates data races without manual locking. However, actors come with trade-offs. If an actor's method is async, callers from outside the actor must await, which can introduce serialization bottlenecks. For high-contention resources (e.g., a cache), consider using a global actor like @MainActor or splitting responsibilities across multiple actors.

A practical scenario: an app that updates a shared in-memory cache from multiple network responses. If the cache actor's write method is synchronous and called from many tasks, it will block those tasks, reducing throughput. The solution is to make the write method async and batch updates where possible. Teams often report that profiling actor hotspots with Instruments reveals unexpected contention, leading to redesigns that favor value types or copy-on-write for read-heavy workloads.

Structured Concurrency and Task Groups

Structured concurrency ties the lifetimes of child tasks to their parent, preventing orphaned tasks and ensuring errors are propagated. Task groups (withTaskGroup) allow you to spawn multiple child tasks and collect their results as they complete. This pattern is ideal for parallel independent work, like downloading multiple images. However, it's not suitable for long-lived background tasks that should outlive the current scope—use unstructured tasks (Task { ... }) with careful cancellation handling.

When using task groups, be mindful of the number of child tasks. Spawning hundreds of tasks for tiny operations can overwhelm the cooperative thread pool, leading to thread starvation. A better approach is to batch work or use a custom executor. Practitioners often set a maximum concurrency limit using a semaphore or a custom async sequence. For example, downloading 100 images concurrently can be throttled to 10 at a time using for await with an AsyncStream.

Understanding these core concepts is the first step toward a performant concurrency strategy. In the next sections, we'll apply this knowledge to specific checklists.

2. The Main Actor Priority Inversion Checklist

One of the most insidious performance issues in Swift concurrency is priority inversion on the main actor. The main actor serializes all work on the main thread, but if a high-priority task (like a user tap) awaits a lower-priority task (like a background data fetch), the system may temporarily block the high-priority task. This leads to UI stutter and perceived slowness. Let's build a checklist to prevent this.

Check for Explicit Priority Overrides

When you create a task using Task(priority: .userInitiated) { ... }, you set its base priority. However, if that task calls an async function that runs on a lower-priority executor (like a background actor), the child task inherits the parent's priority only if it's also on the same executor. If the child runs on a different actor, the system may apply a priority floor, but the actual scheduling can still cause inversion. The rule of thumb: avoid mixing priorities across actor boundaries unless you've profiled and confirmed no inversion.

One team I read about experienced UI freezes when a .userInitiated task on the main actor awaited a .background task that performed heavy disk I/O. The fix was to detach the background work into a separate unstructured task with explicit priority, then use continuations to bridge the result back. This pattern, while more verbose, avoids tying the main actor's priority to the background task's execution.

Minimize Main Actor Blocking

The main actor should never perform synchronous blocking work (like Task.sleep or heavy computation). If you need to delay an action, use Task.yield() or schedule a timer on a background queue. Additionally, avoid using Task.current inside a main-actor context to check priority—it can lead to unexpected suspension.

Use Instruments to Detect Inversions

Xcode's Instruments includes a Swift Concurrency trace that shows task priorities and suspension points. Run your app under realistic load (e.g., tapping buttons while a background sync runs) and look for tasks with high priority waiting for low-priority tasks. The trace will highlight blocks where the main thread is idle while waiting for background work. Address these by restructuring the work: either elevate the background task's priority or move the awaited work to the main actor with proper priority inheritance.

Practical Scenario: Image Loading on Scroll

Consider a scrolling feed that loads images asynchronously. If each image load is a .userInitiated task that calls a .background

3. Data Race Prevention Checklist

Swift concurrency's type system helps eliminate data races, but it's not automatic. You must be deliberate about how you share state across tasks. A data race occurs when two tasks access the same memory without synchronization, and at least one access is a write. Actors solve this for reference types, but value types (like structs) are still vulnerable if they are captured by multiple tasks. Let's examine a checklist to prevent data races.

Use Actors for Mutable Shared State

Any mutable state that is accessed from more than one task should be isolated to an actor. This includes global variables, singletons, and properties of observable objects. For example, a shared counter that increments from multiple tasks should be an actor's property. If you use a class without actor isolation, you must manually add locks, but this is error-prone and can reintroduce deadlocks. The compiler warning "capture of non-sendable type" is your friend—heed it.

A common pitfall is using @Published properties in SwiftUI views. These properties are accessed from the main actor by default, but if you update them from a background task (even via an actor), you risk a data race. The solution is to mark the view model as @MainActor and ensure all updates happen on the main actor. Use await MainActor.run to bridge from background tasks.

Avoid Global Mutable State

Global variables, even if declared with let, can be captured by multiple tasks. If the global is a reference type (like a shared logger), its internal state may be mutated concurrently. Instead, use a global actor (e.g., @globalActor) to isolate access. For example, a logging actor ensures that log writes are serialized, preventing interleaved messages and data races.

Sendable Conformance

Types passed across concurrency boundaries must conform to Sendable. The compiler checks this automatically for structs with only sendable properties and for final classes with no mutable stored properties. For complex types, you may need to add @unchecked Sendable with careful manual verification. Avoid using @unchecked Sendable as a shortcut—it disables compiler checking and can hide races.

Practical Scenario: Shared Cache

An app has a global cache of user profiles implemented as a dictionary. Two tasks—one fetching a new profile and one reading an existing profile—can race if the dictionary is mutated without synchronization. The fix is to wrap the cache in an actor. Then, reads and writes become async methods that are mutually exclusive. However, if reads are frequent and writes are rare, consider using a actor with a concurrent read queue (using withLock or a custom solution) to improve throughput.

Testing for Races

Xcode's Thread Sanitizer (TSan) can detect data races at runtime. Enable it in the scheme's Diagnostics tab and run your tests with concurrent workloads. Note that TSan slows down execution, so use it during development, not in production. Additionally, write unit tests that spawn multiple tasks and assert invariants after concurrent access. For example, test that a counter actor's increment method always returns consistent values under heavy load.

4. Deadlock and Livelock Prevention Checklist

Swift concurrency reduces the risk of deadlocks compared to manual locks, but it doesn't eliminate them. Deadlocks can occur when two tasks wait on each other's resources, or when a task awaits itself (e.g., calling an async method on the same actor from within that actor). Let's build a checklist to prevent these scenarios.

Avoid Actor Reentrancy Issues

By default, actors are reentrant: when an actor's async method is awaiting, other tasks can enter the actor and mutate its state. This can lead to subtle bugs if the code assumes that state doesn't change between suspension points. For example, a bank account actor with a withdraw(amount:) method that checks the balance, then awaits a network call, then deducts the balance. If another task deposits between the check and the deduction, the balance could go negative. The fix is to use a synchronous method for the critical section or use a non-reentrant actor (by marking the method as nonisolated and using a lock internally).

Practitioners often recommend keeping actor methods synchronous where possible, or using a pattern where you capture the needed state before any await. For example, read the balance into a local variable, then await, then perform the deduction based on that snapshot. This avoids reentrancy surprises.

Beware of Async Closures and Escaping Closures

If an async function takes a closure that may be called later (escaping), and that closure captures self or other actors, you can create a retain cycle or a deadlock. For example, a network manager that calls a completion handler on a background queue can deadlock if the handler awaits on the main actor while the main actor is waiting for the network response. The rule: always bridge to the correct actor explicitly using await MainActor.run or by marking the closure as @MainActor.

Use Structured Concurrency to Limit Lifetime

Structured concurrency ensures that child tasks complete before the parent, preventing orphaned tasks that hold resources. However, if a parent task awaits a child task that itself awaits another child, you can create a chain of dependencies that may never resolve if an intermediate task is cancelled. Use withThrowingTaskGroup with cancellation handling to propagate cancellation correctly. Also, avoid using Task.detached inside a structured context—it creates an unstructured task that can outlive its parent, potentially holding references and causing leaks.

Practical Scenario: Two Actors Waiting on Each Other

Consider two actors: UserManager and PaymentManager. UserManager has a method that calls await paymentManager.processPayment(), and PaymentManager has a method that calls await userManager.updateUser(). If both methods are called simultaneously from different tasks, they can deadlock because each actor is waiting for the other to release its lock. The solution is to avoid cross-actor callbacks in a cycle. Restructure the design so that one actor is the coordinator that calls the other sequentially, or use a separate coordinator actor that holds references to both and orchestrates the flow.

5. Performance Tuning Checklist

Even without data races or deadlocks, your app can suffer from poor performance due to thread contention, excessive suspension, or inefficient task creation. This checklist helps you identify and fix common bottlenecks.

Minimize Task Creation Overhead

Creating a task has overhead—it allocates a closure, sets up the execution context, and schedules it on a thread. If you create many short-lived tasks (e.g., one per array element in a loop), the overhead can dominate execution time. Instead, use task groups with a limited number of child tasks, or batch work into larger chunks. For example, processing 1000 items: instead of 1000 tasks, create 10 tasks each handling 100 items.

Instruments' Swift Concurrency trace shows task creation events. If you see hundreds of tasks created per second, consider batching. Also, avoid creating tasks inside loops that are called frequently, like in a scroll view's cellForItemAt. Cache the tasks or use a lazy loading pattern.

Optimize Suspension Points

Each await is a suspension point where the task may be paused. Too many suspension points can reduce throughput because the scheduler spends time context-switching. Review your async functions: can you combine multiple awaits into one? For example, if you await three separate network calls that are independent, use a task group to run them concurrently, reducing the total number of suspension points from three to one (the group's await).

Also, consider using synchronous wrappers for fast operations. If a function is fast and doesn't need to suspend, make it synchronous. For instance, a cache lookup that returns immediately should not be async. This avoids the overhead of creating an async context.

Manage Thread Pool Size

The cooperative thread pool has a limited number of threads (typically equal to the number of CPU cores). If you have many tasks that block (e.g., waiting for I/O), they can occupy threads and starve other tasks. Use Task.sleep sparingly; prefer Task.yield() to give up the thread while waiting for a condition. For blocking I/O, use the URLSession async methods, which are non-blocking. If you must use a blocking API (like some file system calls), wrap it in a Task.detached with a custom executor that uses a separate thread pool.

Practical Scenario: Image Filter Pipeline

An app applies multiple filters to an image in sequence: resize, sharpen, color adjust. If each filter is an async function that awaits the previous one, the pipeline is serial. Instead, use a task group to apply filters in parallel where possible, but be aware of dependencies. For example, sharpen depends on resize, so they can't be parallel. However, you can pipeline: start the next filter on a chunk of data while the previous filter is still processing another chunk. This requires a stream-based approach, which is more complex but can significantly improve throughput.

6. Tooling and Debugging Checklist

Xcode provides several tools to analyze concurrency behavior. This checklist ensures you're using them effectively to catch issues early.

Enable Swift Concurrency Checking

In your build settings, set "Strict Concurrency Checking" to "Complete". This enables compiler warnings for sendability violations and missing actor annotations. It may flag existing code, but it's the best way to prevent races at compile time. Address each warning by adding Sendable conformance or actor isolation.

Use Instruments' Swift Concurrency Trace

This trace shows a timeline of tasks, their priorities, and suspension points. Run your app and perform typical user actions. Look for long suspension gaps (where the main thread is idle) and tasks with mismatched priorities. Also, check for task creation spikes. The trace can also show which actor is blocking others.

Thread Sanitizer (TSan)

Enable TSan in the scheme's Diagnostics. It detects data races at runtime, including those that occur inside actors if you use nonisolated functions unsafely. TSan is slow, so use it in development only. Run your test suite with TSan enabled to catch races.

Practical Scenario: Debugging a UI Hang

A user reports that the app freezes when loading a list of items. Using Instruments, you see that the main actor is blocked for 2 seconds waiting for a background task that is performing a heavy database query. The fix: move the database query to a separate actor with a lower priority, and use an async stream to update the UI incrementally. After the fix, the Instruments trace shows the main actor is never blocked for more than 10ms.

7. Migration Checklist from Completion Handlers

Migrating existing code from completion handlers to async/await is a common task. This checklist helps you do it safely without introducing regressions.

Start with Leaf Functions

Begin migrating the innermost functions (e.g., network calls, database reads) that have no callers that are not yet async. Convert them to async, then propagate the changes outward. This avoids having to convert everything at once. Use a temporary wrapper function that calls the async version from a completion handler if needed.

Handle Continuations Carefully

When bridging from callback-based APIs (like delegate methods), use CheckedContinuation or UnsafeContinuation. CheckedContinuation provides runtime checks for misuse (e.g., resuming twice). Ensure the continuation is resumed exactly once, and on the correct actor. A common mistake is to resume it on a background queue, causing a data race. Always resume on the same actor that initiated the continuation.

Update Delegate Patterns

Delegate methods are often called on arbitrary threads. Convert them to async functions by using a continuation that waits for the delegate callback. For example, a location manager delegate that calls didUpdateLocations can be wrapped in an async function that suspends until the callback arrives. This makes the code linear and easier to reason about.

Practical Scenario: Migrating a Network Layer

A team migrates a network layer that uses URLSession with completion handlers. They convert each endpoint to an async throwing function. During testing, they discover that some completion handlers are called on a background queue, but the async functions assume they are on the main actor. They add @MainActor to the network manager and use await MainActor.run inside the continuation to ensure the result is delivered on the main actor.

8. Testing Async Code Checklist

Testing async code requires special attention because of suspension points and timing. This checklist ensures your tests are reliable and cover concurrency scenarios.

Use XCTest with Async Expectations

XCTest supports async test methods (marked with async throws). Use XCTestExpectation to wait for async operations that don't return immediately. Be careful with timeouts: if an async operation is slow, increase the timeout, but also ensure the test doesn't hang forever. Use await fulfillment(of:timeout:).

Test Race Conditions

Write tests that spawn multiple tasks concurrently and check invariants. For example, test that an actor's counter is consistent after 100 concurrent increments. Use DispatchQueue.concurrentPerform or a task group to simulate load. However, be aware that such tests are non-deterministic; run them multiple times or use stress testing.

Share this article:

Comments (0)

No comments yet. Be the first to comment!