05. Sendable

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

1. Backgroud

Sendable is used to mark a type that can be safely passed between different tasks and threads. It is an important concept in concurrent programming and is used to ensure that data is passed without causing race conditions or data race issues. By marking a type as Sendable, the compiler can verify the delivery behavior of concurrent code at compile time to improve the safety of the code.

Because Actor can only guarantee the thread safety of instance boundaries, it cannot guarantee the safety of objects such as references that cross Actor. For example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class User {
var name: String
var age: Int

init(name: String, age: Int) {
self.name = name
self.age = age
}
}

actor BankAccount {
let accountNumber: Int
var balance: Double
var name: String
var age: Int

func user() -> User {
return User.init(name: name, age: age)
}
}

At this time, once the User is obtained externally, security cannot be guaranteed. At this time, Sendable can help to pass it freely across actors.

2. The Sendable Protocol

The protocol is defined as follows:

1
2
3
4
5
6
/// ### Sendable Metatypes
///
/// Metatypes such as `Int.Type` implicitly conform to the `Sendable` protocol.
public protocol Sendable {

}

The Sendable protocol has the following characteristics:

(1) Sendable is an empty protocol and a Marker Protocol;

(2) It cannot be used as a type name for is, as? and other operations;

(3) Cannot be used as a constraint on a generic type to make a type comform to a non-marker protocol, for example:

1
2
3
4
5
6
7
8
9
10
protocol P {
func test()
}

class A<T> {}

// Error: Conditional conformance to non-marker protocol 'P' cannot depend on conformance of 'T' to non-marker protocol 'Sendable'
extension A: P where T: Sendable {
func test() {}
}

(4) Value types (struct, basic type, enum, etc.) will perform a copy operation when being passed, which means that they are safe to pass across actors. These types implicitly and automatically comply with the Sendable protocol, as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Implicitly complies with the Sendable protocol
struct A {
var views: Int
}
// Will not implicitly comply with the Sendable protocol
class B {
var views: Int
}
// Because it contains a reference type, it does not implicitly comply with the Sendable protocol.
struct C {
var views: Int
var b: B
}

//If Value does not comply with the Sendable protocol, D will not automatically comply with the protocol implicitly.
struct D<Value> {
var child: Value
}

Note:
If struct and enum contain reference types, they will not implicitly comply with the Sendable protocol.

(5) All actors comform to the Actor protocol, which inherits from Sendable, as follows:

1
2
3
4
5
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public protocol Actor : AnyObject, Sendable {
nonisolated var unownedExecutor: UnownedSerialExecutor { get }
}

(6) Class requires us to actively declare compliance with the Sendable protocol and has the following restrictions:

A. Final class restriction: When a class is declared to conform to the Sendable protocol, it must be a final class.

This is because inheritance may introduce concurrency safety issues, and only methods and properties of final classes can be safely used in a concurrent environment. If the class is not final, the compiler will provide a warning and suggest using @unchecked Sendable as an alternative, but this does not provide the same concurrency safety.

B. Immutable attribute restrictions: The stored attributes of classes that conform to`` the Sendable protocol must be immutable, that is, they cannot be modified after initialization.

This is to ensure that no race conditions occur when passing instances.

C. Attribute type restrictions: The type of the storage attribute of the class must also comform to the Sendable protocol.

This is to ensure that the passing of properties is also safe. If the property’s type is not Sendable, a race condition may occur when passing it.

D. Ancestor class restriction: If a class has an ancestor class, then the ancestor class must comply with the Sendable protocol or NSObject.

This is to ensure that all classes in the class inheritance chain are safe in a concurrent environment.

Going back to the top code example, User has 2 modification options:

Changed from class to struct:

1
2
3
4
struct User {
var name: String
var age: Int
}

Or manually implement the Sendable protocol:

1
2
3
4
final class User: Sendable {
let name: String
let age: Int
}

(7) If you want the compiler not to check Sendable semantics, you can use @unchecked attribute;

3. @Sendable

Sendable can only be used to modify regular types. For closures and functions, you need to use @Sendable. Functions and closures modified by @Sendable can be passed across actors.

(1) To modify a function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Sendable
func incrementAndPrint(value: inout Int) {
value += 1
print("Value is now \(value)")
}

var myValue = 5

Task {
await incrementAndPrint(value: &myValue)
}

Task {
await incrementAndPrint(value: &myValue)
}

(2) To modify a closure:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
actorStorage {
var capacity: Int

init(capacity: Int) {
self.capacity = capacity
}

func addCapacity(consume: Int, completion: @Sendable (Storage) -> Void) {
capacity += consume
completion(self)
}
}

//Create actor instance
let storage = Storage(capacity: 1500)

//Execute tasks in actor context
Task {
await Storage.(consume: 500) { storage in
print("Storage new capacity: \(storage.capacity)")
}
}

The closure modified by @Sendable may be called in a concurrent environment, so data security must be paid attention to.

Therefore, when it comes to closures executed in a concurrent environment, especially method parameters and return values ​​within the actor or external methods of the actor, if they may be passed between different tasks and threads, they should be decorated with @Sendable. Ensure safety.

Compliance checks performed by the compiler when using @Sendable to decorate a closure help ensure the safety of the closure in a concurrent environment:

First, actor-isolated properties cannot be captured: since @Sendable closures may be executed in a concurrent environment, they cannot capture actor-isolated properties belonging to other actors. Because @Sendable closures can execute across different tasks and threads, capturing properties belonging to other actors can lead to race conditions. Only asynchronous @Sendable closures (@Sendable () async) can capture actor-isolated properties, because async closures can execute in the same actor context.

Second, mutable var variables cannot be captured: Since @Sendable closures may be executed between different tasks and threads, capturing mutable var variables may cause data race problems. Therefore, @Sendable closures do not allow capture of mutable variables.

Third, the captured object must implement the Sendable protocol: the object captured in the @Sendable closure must be a type that follows the Sendable protocol. This is to ensure safety when passing objects in a concurrent environment and avoid race conditions and data races.

In addition, it should be noted that in WWDC21, Apple stated that in future versions, the Swift compiler will prohibit passing instances of non-Sendable types to actors. This change may affect some existing code, especially that involving the passing of instances of non-Sendable types.

Reference

[1] https://juejin.cn/post/7153096148842971144
[2] https://juejin.cn/post/7076741945820872717