04. Actor

It will take about 10 minutes to finish reading this article.

1. The Birth of Actor

class allows creating types with mutable properties and sharing them throughout a program. However, classes have problems in concurrent programming, requiring developers to manually implement error-prone synchronization mechanisms to avoid data races.

Our goal is to allow global sharing of mutable state while maintaining static detection of data contention and other concurrency issues. And the actor model can be a good solution to this problem. Each actor ensures the independence of its data through data isolation, thus ensuring that only one thread can access that data at a time.

Note:
Actor model is a concurrent computing programming model, originally proposed by computer scientist Carl Hewitt and others in 1973, it is not owned by a certain language or framework, and can be used in almost any programming language. It ensures data security through data isolation. Its principle maintains a serial queue (mailbox) inside, and all external calls involving data security are executed serially.

See: https://en.wikipedia.org/wiki/Actor_model

Swift introduced Actor as part of the Swift concurrency family in version 5.5. the actor model guarantees the same race conditions and memory safety as structured concurrency, while providing a Swift abstraction that is familiar to developers, and implementing features that are available in other explicitly declared types.

2. Concept of Actor

In Proposition 0306, Actor is a reference type, and except for the lack of inheritance support, actor is very similar to class: it can adhere to specified protocols, support extensions, etc.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
actor MyActor {
var title: String
let num: Int

init(title: String, num: Int) {
self.title = title
self.num = num
}

func updateTitle(newTitle: String) {
title = newTitle
}
}

let objectA = MyActor(title: "Starting title!", num: 1001)
await print("ObjectA: ", objectA.title)
await objectA.updateTitle(newTitle: "Second title!")
await print("ObjectA: ", objectA.title)

In order to ensure data security under concurrency, the Actor internally executes external accesses serially. Therefore, such accesses are asynchronous, which means that they do not return a result immediately, but are queued up and executed sequentially. Therefore, such accesses need to be executed via await.

3. Actor isolation

Actor isolation is a way to protect its mutable state by isolating the actor instance as a unit (boundary) from the outside world and severely restricting cross-boundary access, so that only direct access to its stored instance properties is allowed on self.

1
objectA.title = "New title!"

The above code will report an error:

1
Actor-isolated property title can not be mutated from the main actor

4. Nonisolated

Nonisolated, as the name implies, means non-isolated. the mailbox serial access mechanism inside the actor is bound to have some performance loss, and methods and computation attributes inside the actor do not always cause Data races. therefore, the “Improved control over actor isolation” in SE0313 provides an explicit way for clients to freely synchronize access to immutable actor states with the keyword nonisolated.

1
2
3
4
5
6
7
extension MyActor {
// Inside the method, only the let num is referenced, so there are no Data races.
// Can be modified by nonisolated
nonisolated func changeIntToSting(num: Int) -> String {
return "Num:" + String(num)
}
}

This allows you to call the following code directly, and since you don’t need to go through the asynchronous serial mechanism, you don’t need to add await:

1
2
3
let num = objectA.changeIntToSting(num: 1002)
print("ObjectA: ", num)
print("ObjectA: ", objectA.num)

Note:
It is not possible to access the isolated state in the nonisolated method, for example:

1
2
3
4
5
nonisolated func changeIntToSting(num: Int) -> String {
//Actor-isolated property 'title' can not be mutated from a non-isolated context
title = "Title"
return "Num:" + String(num)
}

Remove nonisolated and turn it into an internal method of the actor, where methods and properties can be accessed directly.

1
2
3
4
5
6
7
extension MyActor {
func changeIntToSting(num: Int) -> String {
title = "New Title1"
updateTitle(newTitle: "New Title2")
return "Num:" + String(num)
}
}

5. Actor Reentrancy

Actor Reentrancy is the context of entering the same actor multiple times in the same thread.Actor-isolated methods are reentrant, which means that the same actor’s method can be called multiple times in the same thread without blocking or waiting.

This reentrancy can improve performance in some cases because it allows a task executing an actor-isolated method to not wait for the previous call to complete if it needs to call the method again. This flexibility can reduce the overhead of thread switching and thus improve performance.

Specific attention should be paid to:
(1) Actor-isolated methods may have pause points within them when explicitly declared as asynchronous methods;

(2) When an Actor-isolated method is hung due to a pause point, the method is reentrant, i.e., it can be accessed again before the previous hang is resumed;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
actor MyActor {
var title: String
let num: Int
var capacity: Int

init(title: String, num: Int, capacity: Int) {
self.title = title
self.num = num
self.capacity = capacity
}

func updateTitle(newTitle: String) {
title = newTitle
}
}

extension MyActor {

func waitHere() async -> Bool {
try? await Task.sleep(nanoseconds: 1_000_000_000)
return true
}

func consumeCapacity(consume: Int) async throws -> Int {
guard capacity >= consume else {
throw CustomError.valueTooLarge
}

guard await waitHere() else {
throw CustomError.valueTooLarge
}

capacity -= consume
return capacity
}
}

The call is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let myActor = MyActor(title: "Starting title!", num: 1001, capacity: 9000)
Task {
do {
let capacity1 = try await myActor.consumeCapacity(consume: 5000)
print("Consume succeeded, capacity = \(capacity1)")
} catch {
print("Consume failed: \(error)")
}
}

Task {
do {
let capacity2 = try await myActor.consumeCapacity(consume: 5000)
print("Consume succeeded, capacity = \(capacity2)")
} catch {
print("Consume failed: \(error)")
}
}

The results of the implementation are as follows:

1
2
Comsume succeeded, capacity = 4000
Comsume succeeded, capacity = -1000

Where did it go wrong?
​Generally, the two-step operation of “check-modify” should not cross the await suspension point. They belong to an “atomic operation”, and the await suspension point may “cut” them. How to solve this problem? , you can check it again before modifying:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func consumeCapacity(consume: Int) async throws -> Int {

guard capacity >= consume else {
throw CustomError.valueTooLarge
}

// suspension point
guard await waitHere() else {
throw CustomError.valueTooLarge
}

// re-check
guard capacity >= consume else {
throw CustomError.valueTooLarge
}

capacity -= consume
return capacity
}

The results are as follows:

1
2
Consume succeeded, capacity = 4000
Consume failed: valueTooLarge

So, this issue needs to be taken care of.

6. globalActor and MainActor

6.1 globalActor
An actor is data-protected at the boundaries of its instances. globalActor is designed to be thread-safe for global variables, static attributes, and cross-type/cross-instance. A global actor is a type that has the @globalActor attribute and contains a static attribute called shared that provides a shared instance of an actor. Example:

1
2
3
4
5
6
@globalActor
public struct SomeGlobalActor {
public actor MyActor { }

public static let shared = MyActor()
}
1
2
3
4
5
6
7
8
9
10
11
12
@MyGlobalActor
var globalValue = 0

@MyGlobalActor
func incrementGlobalValue() async {
globalValue += 1
}

@MyGlobalActor
func printGlobalValue() {
print("Global value: \(globalValue)")
}

This makes the global variable globalValue thread-safe.

1
2
3
4
5
6
7
8
Task {
await incrementGlobalValue()
await printGlobalValue()
}
Task {
await incrementGlobalValue()
await printGlobalValue()
}

6.2 MainActor
MainActor is a special case of globalActor. MainActor is a global actor that describes the main thread, and the methods, properties, etc. modified by MainActor will be executed on the main thread.

1
2
3
4
@globalActor
public actor MainActor {
public static let shared = MainActor(...)
}

We often use ‘MainActor.run’ to execute a piece of code on the main thread:

1
2
3
await MainActor.run {
self.image = image
}

Reference

[1] https://github.com/apple/swift-evolution/blob/main/proposals/0306-actors.md
[2] https://juejin.cn/post/7076738494869012494
[3] https://zhuanlan.zhihu.com/p/86460724