Swift Actors

👍
· Jan 26, 2024 · 5 mins read
Swift Actors

The actor model is a popular model for concurrency computing. Swift adopts this model and adds actor as an integral part of Swift concurrency since Swift 5.5. Swift actors encapsulate state variables and operations that defined upon states to ensure mutually exclusive access to these states. In this article, we are going to explore how to utilise this feature.

Define an actor

To define an actor, it’s just like to define a struct or class(Please note actor is a reference type).

actor Account {  
    let ID: String  
    var balance: Double  
  
    init(ID: String, balance: Double) {  
        self.ID = ID  
        self.balance = balance  
    }  
  
    func newTransaction(of value: Double) {  
        balance -= value  
    }  
}

If we have an Account actor above, the operations of accessing balance of the Account actor instance can only be done on the Actor-isolated context. For example, use newTransaction method defined in this actor like below:

let account = Account(ID: "12345", balance: 100)  
  
Task {  
    await account.newTransaction(of: 10)  
    print(await account.balance)  
}

If we try to access it directly like below:

await account.balance = 100

The compiler will give us error Actor-isolated property ‘balance’ can not be mutated from a non-isolated context .

In summary, Swift guarantees the safety of the states of actor instances by run these operations on the actor isolated context in sequence. This is a much easier and intuitive way to ensure data safety than using serial operation queue or some other mechanisms. It’s recommended to handle data safety in the Swift Structure concurrency world.

Global actors

Sometimes, it is not always easy to model our data into actors. For example, some global states of the app are not part of any actors, but still need a controlled access to these global states in some cases. This is where global actor comes in.

Before we dive into defining our own global actors, let’s explore the famous MainActor — a global actor that ensures the code running on Main thread in sequence. The following syntaxes can be used for MainActor:

// Specify variable to be accessed on Main Thread  
@MainActor var globalVairable = false  
  
// Specify func to run on Main Thread  
@MainActor func someFunc() {  
    print("On Main thread")  
}  
  
// Specify a closure to run on Main Thread  
var callback: @MainActor () -> Void = {  
    print("On Main thread")  
}  
  
// Specify a closure to run on Main Thread  
let callback2 = { @MainActor in  
    print("On Main thread")  
}  
  
// Specify all methods & vairabls of a type to run on Main Thread  
@MainActor  
class SomeClass {  
    func run() {  
        print("On Main thread")  
    }  
}  
  
// Run code block on main thread  
await MainActor.run {  
    print("On Main thread")  
}  

Also, we can define our own global actors and use them in the similar fashion. The global actors defined by us will ensure the code running on an isolated context but not necessarily on main thread. For example, in the code snippet blow, we define a global actor DBActor first and use it to make sure data safety.

@globalActor  
public struct DBActor {  
    public actor DBActor { }  
    // The singleton actor instance to provide isolated context  
    public static let shared = DBActor()   
}  
  
@DBActor var globalDBVairable = false  
  
@DBActor func someFunc() {  
    print("On DBActor Context")  
}  
  
var callback: @DBActor () -> Void = {  
    print("On DBActor Context")  
}  
  
let callback2 = { @DBActor in  
    print("On DBActor Context")  
}  
  
@DBActor  
class SomeClass2 {  
    func run() {  
        print("On DBActor Context")  
    }  
}

The code here marked with DBActor will run on the actor isolated context provided by the singleton shared instance of this global actor.

Please note, apart from this syntax MainActor.run , we can use all other syntaxes that available for MainActor for our own global actor. Again, compared with serial operation queue, this global actor syntax is more concise and simpler.

Refined control of States

By default, all states defined in actor are actor-isolated. However, this is too strict for some cases. For example, the ID property of the Account actor defined earlier is a read only property and it’s thread safe reading it. To allow it, we can use nonisolated keyword like below:

actor Account {  
    nonisolated let ID: String // Make it nonisolated  
    var balance: Double  
  
    init(ID: String, balance: Double) {  
        self.ID = ID  
        self.balance = balance  
    }  
  
    func newTransaction(of value: Double) {  
        balance -= value  
    }  
  
    // Similarly, we can mark a func nonisolated  
    nonisolated func isSame(with account: Account) -> Bool {  
        ID == account.ID  
    }  
}

Please note, in a nonisolated function in an actor, we cannot access the actor’s isolated states. To be accurate, the types involved in a non-isolated declaration must all be [Sendable](https://developer.apple.com/documentation/swift/sendable) .

Refined control of actor executors

So far, we know that conceptually, once the code is actor isolated, it will run synchronically and the memory safe will be guaranteed. The way Swift does it is to provide a default unownedExecutor for each actor to run the actor isolated code in sequence. If needed, we can override this property and provide our own executor like below:

actor MyMainActor {   
    nonisolated var unownedExecutor: UnownedSerialExecutor {   
        MainActor.sharedUnownedExecutor  
    }  
  
    func test() {   
        print("On main thread as the customised Executor is from MainActor.sharedUnownedExecutor")   
    }  
}

In the code above, we use MainActor.sharedUnownedExecutor as the executor of our MyMainActor , which results in all the isolated code in MyMainActor run on main thread in sequence. If need more details of how to implement an unownedExecutor from scratch, please refer to this proposal.

Conclusion

In conclusion, Swift Actor provides a simple model to ensure safely access shared states, which help write concise and readable code.

Comments

Sharing is caring!