Actors ensure your code is executed on a specific thread, like the main or a background thread. They help you synchronize access to mutable states and prevent data races. However, developers commonly misunderstand how actors dispatch to threads in non-async contexts. It’s an essential understanding to avoid unexpected crashes.
Before we dive deeper into the specifics of dispatching, I encourage you to read my articles Actors in Swift: how to use and prevent data races and MainActor usage in Swift explained to dispatch to the main thread since they will introduce you to the concept of actors in general. In this article, we’ll explore the effect of calling methods marked with any actor attribute.
Thread dispatching in asynchronous contexts
Let’s start with the most common dispatching when working with actors. In general, you’ll likely going to use actors in asynchronous environments. In case your calling code accesses actor-attributed methods, you’ll have to use a task or adopt the same global actor:
The above example image demonstrates the real power of Swift’s concurrency framework and the compiler. You’ll be instructed to adjust your code when you’re trying to access asynchronous code from a non-async context.
The example DispatchCoordinator
contains a dispatch()
method that isn’t marked with the same actor attribute as the ActorDispatcher
method. Therefore, we need to either use a task in combination with an await:
struct DispatchCoordinator {
static func dispatch() {
Task {
let dispatcher = ActorDispatcher()
await dispatcher.methodAttributedWithMainActor()
}
}
}
Or we need to add the same actor attribute to either the method or the enclosing DispatchCoordinator
instance:
struct DispatchCoordinator {
@MainActor
static func dispatch() {
let dispatcher = ActorDispatcher()
dispatcher.methodAttributedWithMainActor()
}
}
The above should make sense in most cases, as the compiler will instruct you accordingly. However, there are cases in which you won’t get any instructions.
Thread dispatching in synchronous contexts
A common mistake I’ve seen many developers make (including myself) is assuming a method is always executed on the main thread when attributed to @MainActor
. For example, you might have existing code that dispatches to a background thread using DispatchQueue
:
DispatchQueue.global().async {
/// Executed on some kind of background thread.
/// In this case, simulated by calling into DispatchQueue.global().
let dispatcher = ActorDispatcher()
dispatcher.methodAttributedWithMainActor()
}
The above is an explicit example, but many APIs from Apple in frameworks like Foundation return on a background thread without you even noticing. The code compiles, and the compiler is not suggesting any changes regarding using a task or similar. Therefore, we assume our code is thread-safe and executes nicely on the main thread. The opposite is true when pausing our app and looking into the Debug Navigator:
The above DispatchQueue
closure executes synchronously in a nonisolated context. The compiler can only suggest changes when it’s confident about potential failures and doesn’t enforce actor isolation in code that’s not concurrency aware. In other words, we can’t assume our code executes on the destination actor in case there are no compilation failures.
Strict Concurrency Checking to the rescue
Luckily, we won’t be able to compile the above code when Swift 6 arrives. You can already opt-in to Strict Concurrency Checking with the latest Xcode by changing the build setting:
Once enabled, you’ll notice the following warning after compiling:
Call to main actor-isolated instance method ‘methodAttributedWithMainActor()’ in a synchronous nonisolated context; this is an error in Swift 6
The warning would have helped us prevent the unexpected thread destination. It’s essential to prepare your projects for Swift 6 and prevent yourself from having to fix many of these warnings in the future. If you want to learn more, I encourage you to read Swift 6: Preparing your Xcode projects for the future.
Continuing your journey into Swift Concurrency
The concurrency changes are more than imports and include many new features you can benefit from in your code. Now that you’ve learned about pre-concurrency maintenance, it’s time to dive into other concurrency features:
- MainActor usage in Swift explained to dispatch to the main thread
- How to Use URLSession with Async/Await for Network Requests in Swift
- Async await in Swift explained with code examples
- Swift 6: Incrementally migrate your Xcode projects and packages
- Concurrency-safe global variables to prevent data races
- Unit testing async/await Swift code
- Thread dispatching and Actors: understanding execution
- @preconcurrency: Incremental migration to concurrency checking
- Detached Tasks in Swift explained with code examples
- Task Groups in Swift explained with code examples
- Sendable and @Sendable closures explained with code examples
- AsyncSequence explained with Code Examples
- AsyncThrowingStream and AsyncStream explained with code examples
- Tasks in Swift explained with code examples
- Nonisolated and isolated keywords: Understanding Actor isolation
- Async let explained: call async functions in parallel
- Actors in Swift: how to use and prevent data races
Conclusion
While actors are an excellent solution for Swift Concurrency, they can lead to confusion and wrong expectations. Knowing what to expect from the compiler and when you can be sure about a thread destination is essential to prevent runtime exceptions.
If you like to learn more tips on Swift, check out the Swift category page. Feel free to contact me or tweet me on Twitter if you have any additional suggestions or feedback.
Thanks!