Migrating to Swift Actors
Sharing experiences and solutions for integrating actors into existing codebases.
Actors, an important part of Swift concurrency, have been available since iOS 13, introduced at WWDC21. They provide a way to safely manage state in a concurrent environment by ensuring serial access. There are several excellent articles introducing the concept and basic usage of actors (What is an actor and why does Swift have them?), we assume you are already familiar with them. This article will focus on sharing experiences and solutions for integrating actors into existing codebases.
Refactoring a Data Model Class to an Actor
One of the most common and powerful use cases for actors is to manage a (serial) data model that is accessed from multiple threads. In this scenario, we have an Uploader
class that needs to handle concurrent operations. Here's a simplified version of the uploader:
protocol Uploader {
func upload(file: String)
func retry(file: String)
func cancelUpload(file: String)
}
final class UploaderImp: Uploader {
private var uploadingFiles = Set<String>()
private let uploadService = UploadService()
func upload(file: String) {}
func retry(file: String) {}
func cancelUpload(file: String) {}
}
class UploadService {
func upload(_ file: String, completion: @escaping (Result<(), Error>) -> Void) {}
}
Note that we have a uploadingFiles
private property, which need to be updated in the other functions. Besides, the other functions may be called in multi threads.
Advantages Over Traditional Methods
Traditionally, multithreading issues have been addressed using locks or queues. While straightforward, this approach requires developers to manually ensure that every access to a shared resource like uploadingFiles
is protected. This is error-prone, as it's easy to miss a case, and the compiler offers no assistance in catching these mistakes. Overlooking a necessary lock or queue can lead to race conditions and other concurrency-related bugs.
A traditional queue-based solution looks like this:
final class UploaderImp: Uploader {
private var uploadingFiles = Set<String>()
private let uploadService = UploadService()
private let queue: DispatchQueue
func upload(file: String) {
queue.async {
uploadingFiles.insert(file)
uploadService.upload(file) { [weak self] in
// The completion may be on the other queues,
// and we may forgot wrap with `queue.async`,
// especially in more complex codes.
// Then `uploadingFiles` may be accessed in other queues,
// in which case the compiler can't offer any help.
uploadingFiles.remove(file)
}
}
}
func retry(file: String) {
queue.async {
uploadingFiles.insert(file)
}
}
func cancelUpload(file: String) {
queue.async {
uploadingFiles.insert(file)
}
}
}
However, it's easy to forget to wrap all property access with the queue, leading to potential data races.
Another traditional approach is using locks. However, when dealing with multiple properties, ensuring atomic operations can be challenging. Let's consider an example using the new Mutex
introduced in WWDC24:
import Synchronization
final class UploaderImp: Uploader {
private var uploadingFiles = Mutex<Set<String>>()
private var uploadedFiles = Mutex<Set<String>>()
private let uploadService = UploadService()
func upload(file: String) {
uploadingFiles.withLock {
$0.insert(file)
}
uploadService.upload(file) { [weak self] _ in
// Moving file from uploadingFiles to uploadedFiles
// is NOT ATOMICALLY, which may cause multi thread issues
// like race condition or dirty read.
self?.uploadingFiles.withLock {
$0.remove(file)
}
self?.uploadedFiles.withLock {
$0.insert(file)
}
}
}
func retry(file: String) {}
func cancelUpload(file: String) {}
init() {}
}
If the data model contains multiple properties, wrapping each one with a lock does not guarantee atomic operations across all properties. Using a single lock for all properties has the same drawbacks as using a queue.
This is where actor
s come in. Here's how the Uploader
can be refactored into an actor:
actor UploaderImp: Uploader {
private var uploadingFiles = Set<String>()
private var uploadedFiles = Set<String>()
private let uploadService = UploadService()
nonisolated func upload(file: String) {}
private func _upload(file: String) {
uploadingFiles.insert(file)
uploadService.upload(file) { [weak self] _ in
self?.uploadingFiles.remove(file) // ERROR: Actor-isolated property 'uploadingFiles' can not be mutated from a nonisolated context
self?.uploadedFiles.insert(file) // ERROR: Actor-isolated property 'uploadedFiles' can not be mutated from a nonisolated context
}
}
nonisolated func retry(file: String) {}
nonisolated func cancelUpload(file: String) {}
init() {}
}
You may have noticed the nonisolated
keyword on some functions; we'll discuss that shortly.
The compiler has generated some errors, but these can be resolved. We can fix the compilation errors and ensure atomicity by using a helper method like performInIsolation
(which can be optimized with sending
instead of Sendable
):
public extension Actor {
/// Adds a general `perform` method for any actor to access its isolation domain to perform
/// multiple operations in one go using the closure.
func performInIsolation<T>(_ block: sending (_ actor: isolated Self) throws -> sending T) rethrows -> sending T {
try block(self)
}
}
actor UploaderImp: Uploader {
private var uploadingFiles = Set<String>()
private var uploadedFiles = Set<String>()
private let uploadService = UploadService()
nonisolated func upload(file: String) {}
private func _upload(file: String) {
uploadingFiles.insert(file)
uploadService.upload(file) { [weak self] _ in
Task {
await self?.performInIsolation { `self` in
self.uploadingFiles.remove(file)
self.uploadedFiles.insert(file)
}
}
}
}
nonisolated func retry(file: String) {}
nonisolated func cancelUpload(file: String) {}
init() {}
}
Now, access to uploadingFiles
and uploadedFiles
is guaranteed to be on certain serial thread (guaranteed by actor
), and the modification in the upload completion is atomic.
You might think that the amount of code is similar to the traditional methods. However, the key advantage is that the compiler now guarantees that actor properties are accessed only within their isolated environment. This compile-time safety prevents data races and other concurrency issues, reducing the cognitive load on the developer and preventing bugs before the code is even run.
Bridging from Synchronous to Isolated Environment
Our uploader is now internally serial and isolated. However, the rest of the codebase may not be aware of actors or isolation. It likely exists in a traditional, synchronous world. Therefore, we need to create a bridge between these two environments.
We expose the Uploader
protocol to the rest of the codebase. If possible, we can mark the functions in the Uploader
protocol with the async
keyword, since a non-isolated environment must access an isolated one asynchronously.
protocol Uploader {
func upload(file: String) async
func retry(file: String) async
func cancelUpload(file: String) /*async*/ // If we don't mark async
}
actor UploaderImp: Uploader {
func upload(file: String) {}
func retry(file: String) {}
func cancelUpload(file: String) {} // ERROR: Actor-isolated instance method 'cancelUpload(file:)' cannot be used to satisfy nonisolated protocol requirement
}
However, to bridge to a world that doesn't use async
, we must mark the protocol's methods as nonisolated
in the UploaderImp
actor. Inside the actor, we can then use a Task
to bridge to the asynchronous, isolated environment:
protocol Uploader {
func upload(file: String)
func retry(file: String)
func cancelUpload(file: String)
}
actor UploaderImp: Uploader {
nonisolated func upload(file: String) {
Task { [weak self] in await self?._upload(file: file) }
}
private func _upload(file: String) {
// Do our serial logic here
}
// retry and cancelUpload is similar
This way, the Uploader
protocol can be used in a synchronous context, while the implementation leverages modern concurrency features.
If your methods need to return a value, they must be asynchronous. You can achieve this by using a completion handler that is called with the result of the asynchronous operation.
Note:
Task
does not guarantee the order of execution. If the order of operations is important, consider using a library like swift-async-queue.
Guaranteeing Main Thread Execution with @MainActor
Another important use of actors is the actor attribute (like @MainActor
), which can be applied to functions, classes, and other declarations to ensure they are executed on the specific actor (like main thread). You can even define your own custom actors.
For a long time, the only way to ensure that an API was called on a specific thread (usually the main thread) was to state it in the documentation. As developers, we learn through experience which APIs are main-thread only (like UIView
) and which can be used on background threads (like UIImage
). However, it's easy to make a mistake in a deep call stack, which can lead to runtime crashes.
On the other side, as an API provider, you might add guards to your code to switch to the correct thread, using locks or semaphores to prevent misuse. This often leads to boilerplate code in every public API and can introduce its own set of threading issues.
The @MainActor
attribute solves this problem by allowing us to specify that an API must be called on the main thread. Let's look at an example with a view model:
import UIKit
class ViewModel {
var isValid: Bool = false {
didSet {
updateViewHidden(isValid)
}
}
private weak var view: UIView?
private func updateViewHidden(_ isHidden: Bool) {
view?.isHidden = isHidden
}
}
This code looks familiar and seems correct at first glance, right?
Now, let's look at how the ViewModel
might be used:
class DataModel {
let vm = ViewModel()
func updateData(_ isValid: Bool) {
vm.isValid = isValid
}
}
It's easy to accidentally call updateData
from a background thread, which would then attempt to update a UIView
property from a background thread, leading to a crash.
Now, let's add the @MainActor
attribute to updateViewHidden
. This will cause the compiler to flag errors on isValid
and updateData
:
class ViewModel {
var isValid: Bool = false {
didSet {
updateViewHidden(isValid) // ERROR: Call to main actor-isolated instance method 'updateViewHidden' in a synchronous nonisolated context
}
}
private weak var view: UIView?
@MainActor
private func updateViewHidden(_ isHidden: Bool) {
view?.isHidden = isHidden
}
}
class ViewModel {
@MainActor
var isValid: Bool = false {
didSet {
updateViewHidden(isValid)
}
}
private weak var view: UIView?
@MainActor
private func updateViewHidden(_ isHidden: Bool) {
view?.isHidden = isHidden
}
}
class DataModel {
let vm = ViewModel()
func updateData(_ isValid: Bool) {
vm.isValid = isValid // ERROR: Main actor-isolated property 'isValid' can not be mutated from a nonisolated context
}
}
class DataModel {
let vm = ViewModel()
func updateData(_ isValid: Bool) {
Task { @MainActor in
self.vm.isValid = isValid
}
// or in traditional way as long as the compiler know
// the current environment is in MainActor:
DispatchQueue.main.async {
self.vm.isValid = isValid
}
}
}
If you want to know why we can use DispatchQueue.main here to provide MainActor environment, see: How the Swift compiler knows that DispatchQueue.main implies @MainActor – Ole Begemann
These errors will eventually lead you to mark every function and property in the call chain with @MainActor
, ensuring that the entire chain is executed on the correct thread from the very beginning, with compile-time checks. This is similar to the type safety that Swift provides at compile time, as opposed to the dynamic typed languages. The @MainActor
attribute gives us this power, allowing us to fix threading issues at their source, rather than patching them up later.
Actor Attribute Basics
Actor attribute like the @MainActor
can be applied not only to functions and properties, but also to classes, structs, enums, and protocols.
When applied to a class, all properties and methods within that class inherit the @MainActor
attribute by default. You can opt out of this behavior by marking a specific function with the nonisolated
keyword.
// On class declaration:
@MainActor
class V {
var a: Int = 1 // all properties and functions in the class
func b() {} // are inherited with `@MainActor` by default.
// You can explicit mark with `nonisolated` to drop the actor inheritance.
nonisolated func c() {}
}
// On struct and enum declaration:
// All properties and functions are also inherited with `@MainActor` by default.
@MainActor
struct S {}
@MainActor
enum E {}
Inheritance also applies to protocols and their implementations:
// On Protocol properties and functions:
protocol P {
@MainActor
var a: Int { get }
}
class C: P {
var a: Int = 0 // C.a inherits `@MainActor`
}
func b() {
let c = C()
c.a // ERROR: Main actor-isolated property 'a' can not be referenced from a nonisolated context
}
// On the protocol itself:
@MainActor
protocol P2 {}
class C2: P2 {} // C2 inherits `@MainActor`
func b2() {
let c = C2() // ERROR: Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context
}
You can make great use of actor attributes to guarantee not just thread safety, but also what we might call "thread correction"—ensuring that code runs on the right thread.
The Deal with UIView
You may have noticed that while UIView
is marked with @MainActor
, calling UIView
and its methods from a background thread (in non-isolated context) doesn't produce any errors or warnings. Why is that?
import UIKit
@MainActor
class MyView {}
func a() {
UIView() // No error or warnings!
MyView() // ERROR: Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context
}
The secret lies in @preconcurrency
. Even if you don't explicitly see @preconcurrency
in the .swiftinterface
file for UIView
, it's implicitly marked because UIView
is imported from Objective-C. Declarations from Objective-C are always imported as if they were marked with the preconcurrency
attribute.
The @preconcurrency
attribute suppresses warnings that would otherwise appear if the code were not in a concurrency environment:
import UIKit
@preconcurrency
@MainActor
class MyView {}
func a() {
UIView() // No error or warnings!
MyView() // No error or warnings either!
}
However, if you try to call a UIView
-related API within a concurrency environment that is not on the MainActor
, the compiler will issue a warning (even with minimal concurrency checking):
actor A {
func a() {
UIView() // WARN: Call to main actor-isolated initializer 'init()' in a synchronous actor-isolated context; this is an error in the Swift 6 language mode
MyView() // Same warning as above
}
nonisolated func b() {
UIView() // WARN: Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context; this is an error in the Swift 6 language mode
MyView() // Same warning as above
}
}
In such cases, you should use Task { @MainActor in }
or dispatch to the main thread to ensure thread correctness for the compiler.
It's important to note that there are some situations where the compiler might not provide errors or warnings, especially in non-concurrency environments. Therefore, you should be aware of these cases and not rely solely on compiler errors if you are not in a complete concurrency environment.
Concurrency Within Actors
While actors execute code serially, you can still run code concurrently within an actor by using the nonisolated
keyword, as mentioned earlier. This means that if a function is not isolated to its enclosing actor, it can be run concurrently.
For example, if you receive data that needs processing before being passed to a view, you can use nonisolated
functions in conjunction with async let
(or TaskGroup
, or any other asynchronous task) to perform the heavy lifting off the actor's serial execution context:
import UIKit
@MainActor
class ViewModel {
private weak var view: UIView?
// This func is @MainActor inherit from the enclosing class
func didReceiveData(_ data: Int) {
// Task.init inherit @MainActor from the environment
// However, if we use Task.detached, there will be errors
Task {
print(Thread.isMainThread) // true
async let processedData = processData(data)
print(Thread.isMainThread) // true
// Guarded on main thread by the compiler
view?.isHidden = await processedData > 0
}
}
nonisolated private func processData(_ data: Int) -> Int {
// Simulate your heavy processing logic here
Thread.sleep(forTimeInterval: 1)
print(Thread.isMainThread) // false, meaning the heavy logic is running off the main thread
return data
}
}
However, writing asynchronous code introduces the possibility of re-entrancy issues. If didReceiveData
is called multiple times in quick succession, you could have multiple processing tasks running simultaneously. To address this, you can store the last running task, cancel it before starting a new one, and check for cancellation before updating the view:
import UIKit
@MainActor
class ViewModel {
private weak var view: UIView?
private var calculatingTask: Task<Void, Error>?
// This func is @MainActor inherit from the enclosing class
func didReceiveData(_ data: Int) {
// Task.init inherit @MainActor from the environment
// However, if we use Task.detached, there will be errors
calculatingTask?.cancel()
calculatingTask = Task {
print(Thread.isMainThread) // true
async let processedData = processData(data)
let processedResult = await processedData
print(Thread.isMainThread) // true
try Task.checkCancellation()
view?.isHidden = processedResult > 0
}
}
nonisolated private func processData(_ data: Int) -> Int {
// Simulate your heavy processing logic here
Thread.sleep(forTimeInterval: 1)
print(Thread.isMainThread) // false
return data
}
}
Conclusion
While actors are not yet in their final form, the Swift language and its concurrency model are constantly evolving. The recent Swift 6.2 release introduced several new features that make working with actors and concurrency easier than ever, such as Control default actor isolation inference
, Global-actor isolated conformances
, and the ability to Run nonisolated async functions on the caller's actor by default
. These improvements align with the vision of approachable concurrency outlined in the official Swift documentation. As Swift continues to mature, we can expect even more enhancements that will make concurrent programming safer, more intuitive and easier to use.
But alongside that, we can already integrate actors and other Swift concurrency features into our projects to enhance our development experience, since now.
This article was written with Xcode 16.0 and Swift 6.0.