[Swift] Sendable対応の所感(意見求む)
雑多ですが意見ください!!!「うちではこうやった」「AAAのケースをBBBのアプローチで解決したことがあります」「XXXのアプローチはYYYの問題がありました」などなど、お気軽にコメントください!!
Sendable
SE-0302 Sendable and @Sendable closures
平たく言えば「スレッドセーフかどうか」
手順1 「Sendableか」を判断する
ある型がSendable
であるのは以下のA, Bを両方満たす場合。
- A: すべてのstoredなプロパティの型が
Sendable
である。-
@unchecked Sendable
でも可。
-
- B: ある型がclassなら
final
かつlet
のプロパティのみ持っている。
例
// Mutableなclassはnon-Sendable、それぞれのスレッドでヒープ上の同じ実態を触ることになり
// 同時メモリアクセスの危険があるから
final class C1 {
var count = 10
}
// ImmutableなclassはSendable
// Readだけの場合は同時アクセスでも問題ないから
final class C2: Sendable {
let count = 10
}
// 純粋なstructはSendable
// 値がそのままコピーされ、それぞれのスレッドで別のメモリを扱うことになり
// 同時メモリアクセスの危険性がないから
struct S1: Sendable {
var count = 10
}
struct S2: Sendable {
var s1 = S1()
}
// 内部にnon-Sendableなclassを持つstructはnon-Sendable (value sematicsを持たない)
// S3をコピーしても、c1が指す実態は同じであり同時アクセスの問題があるから
struct S3 {
var c1 = C1()
}
struct S4 {
let c1 = C1()
}
// 内部にSendableなclassを持つstructはSendable
// SendableなclassはImmutableや後述の「実質的にSendable」であり、同時アクセスの問題が存在しないから
struct S5: Sendable {
var c2 = C1()
}
struct S6: Sendable {
let c2 = C1()
}
条件を満たす場合、以下の対応を取る。
- 自分が定義した型:
struct/class XXX: Sendable
- フレームワークの型:
extension XXX: @unchecked Sendable {}
- Appleのフレームワークならfeedbackを送ったり、OSSならIssue/PRを立てたりして公式にSendable対応を待つと良い
自分が定義した型なら、一旦XXX: Sendable
をつけてみてwarningが出るか試すのが良い。
手順2 「"実質的に"Sendableか」を判断する
手順1の B
が原因で素直にSendable
にできない場合がある。
その場合はその型が「"実質的に"Sendableか」を判断する。
「"実質的に"Sendable」な場合は@unchecked Sendable
を付ける。
「"実質的に"Sendableか」は「スレッドセーフか」と読み換えて良い。
例えば
NSLock
/GCD
などその他機構で排他制御されスレッドセーフになっている場合。
final class LockedCounter: @unchecked Sendable {
private var count = 0
private let lock = NSLock()
func increment() {
lock.withLock {
count += 1
}
}
}
final class GCDCounter: @unchecked Sendable {
private var count = 0
private let queue = DispatchQueue(label: "counter")
func increment() {
queue.sync {
self.count += 1
}
}
}
フレームワークの型もドキュメントなどでスレッドセーフか確認すれば良い。
(内部的にはLock
, GCD
などでスレッドセーフになっていることが多いので本質的には上と同じ)
UserDefaults
はドキュメントによるとスレッドセーフなので"実質的"にSendable
。
extension UserDefaults: @unchecked Sendable {}
他には
-
@propertyWrapper
がvar
でしか宣言できない問題によって怒られたことがある。これも「実質的にSendable」のはず。
@propertyWrapper
struct LocalStorage<T: Sendable>: Sendable {
// これはSendableでスレッドセーフ
let storage = ...
var wrappedValue: T {
get {
storage.get(...)
}
set {
storage.set(newValue)
}
}
}
final class Hoge: @unchecked Sendable {
@LocalStorage
var hogeValue: Int
}
- とあるクラウドサービスのSDKが巨大な型を提供しており、自分が使っていたメソッドはスレッドセーフだという言及がドキュメントにあったので(実装を見ても排他処理があった、他のメソッドは不明)「今の用途では」「実質的にSendable」と判断した。
この手順2の変更によって、手順1が適応できる型が増えることがある。そのため手順1-2を繰り返し行う。
手順3 Sendableになるように修正する
手順1, 2 の結果、Sendable
でない型があった場合は以下のどれかのアプローチで修正をする必要がある。
(ここで、「"実質的"にSendable」な型でも、実装を見直す目的で修正を行なっても良い)
手順3の変更によって、手順1,2が適応できる型が増えることがある。そのため手順1-3を繰り返し行う。
アプローチa: actorにする
actor
はSendable
なので、actorで適切に実装することでSendable
になる。
actor Counter {
var count = 0
func increment() {
count += 1
}
}
let counter = Counter()
await counter.increment()
使い所
- 複数状態を持つ場合・状態操作が多い場合
- それぞれに
Lock
等を取るとコードが見にくい・めんどくさい
- それぞれに
- メソッドや操作が
async
になっても良い場合
具体例
ハードウェアの機能を操作するクラスは大体actor
にした。
- 状態操作が多かった
- 元々
delegate
をAsyncStream
や普通のasync
に変換して活用していたため、async
になっても問題ない
public final actor BluetoothManager: NSObject, BluetoothManagerProtocol {
private var centralManager: CBCentralManager?
private var statusContinuation: CheckedContinuation<Void, Never>?
public var isBluetoothOn: Bool {
guard let centralManager = centralManager else {
preconditionFailure("need call setupAndrequestBluetoothAuthrizationIfNeeded in BluetoothManager")
}
return centralManager.state == .poweredOn
}
public func setupAndrequestBluetoothAuthorizationIfNeeded() async {
if centralManager != nil { return }
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
self.statusContinuation = continuation
centralManager = CBCentralManager()
centralManager?.delegate = self
}
}
private func removeContinuation() {
statusContinuation = nil
}
}
extension BluetoothManager: CBCentralManagerDelegate {
public nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
Task {
if let statusContinuation = await statusContinuation, CBCentralManager.authorization != .notDetermined {
statusContinuation.resume()
await removeContinuation()
}
}
}
}
ちなみにActor
プロトコルを使うことで抽象化もできる。
public protocol BluetoothManagerProtocol: Actor {
var isBluetoothOn: Bool { get }
func setupAndrequestBluetoothAuthorizationIfNeeded() async
}
globalActor
でisolateする
アプローチb: globalActor
でisolateすることでもその型はSendable
になる。
@globalActor
public final actor CounterActor {
public static let shared = CounterActor()
}
@CounterActor
final class Counter {
var count = 0
func increment() {
count += 1
}
}
let counter = Counter()
await counter.increment()
使い所
基本はactorと同じ。
- 複数状態を持つ場合・状態操作が多い場合
- メソッドや操作が
async
になっても良い場合
加えて
actor外のメソッド・変数・Taskのスコープでisolateしたい場合
actorは基本的にはその型内の変数・メソッドのみisolateされるため以下の問題がある。
- そこら中の型をactorにすると、awaitが大量発生してしまい使いにくくなる。
- 型を分離できないため、設計面と相性が悪いことがある。
一方でglobalActor
を利用する場合
- 同じ
globalActor
でisolateされているsyncメソッド同士はasync無しで呼べる。 - 複数の型に付与できる。
-
@HogeActor var hoge: Int
や@HogeActor func hoge()
のように、変数や関数単位で付与できる。 -
Task { @HogeActor in }
のように簡単にisolateされたクロージャーを生成できる
などの点でglobalActor
の方が小回りが効く側面がある。
isolateするインスタンスが少ない場合
actorは型ではなくインスタンスによってisolate・排他制御を行う。
つまり以下のコードでc1
とc2
のメールボックスは別であり、c1へのタスクがc2へのタスクを排他することはない。
actor CounterActor {
var count = 0
func increment() {
count += 1
}
}
let c1 = CounterActor()
let c2 = CounterActor()
ここで、globalActor
はシングルトンのactorであるため、@CounterActor
とisolateした変数・型・メソッドがすべて一つのメールボックスで管理されてしまい、実行が遅れる(渋滞する)可能性がある。
つまり以下のコードでc1
とc2
のメールボックスは同じであり、c1へのタスクがc2へのタスクを排他する。
@globalActor
public final actor CounterActor {
public static let shared = CounterActor()
}
@CounterActor
final class Counter {
var count = 0
func increment() {
count += 1
}
}
let c1 = Counter()
let c2 = Counter()
つまり、isolateするインスタンスが多い場合はactorで作るのが適切だと考えられる。
(isolateの小回りが効く点とのバランス)
具体例
RealmのDBを触る専用のglobalActor
@globalActor
public final actor DatabaseActor {
public static let shared = DatabaseActor()
}
@DatabaseActor
public protocol DatabaseManagerProtocol: Sendable {
// ObjectはActor-sensitive (取得したActor以外から触るとクラッシュする)
func get<T: Object>(_ object: T.Type, primaryKey: String) async -> T?
func set<T: Object>(_ object: T) async throws
...
}
// ここはnonisolated
final class LectureRepository: LectureRepositoryProtocol {
func fetchAndCache(lectureId: Lecture.ID) async throws -> Lecture {
// apiからLectureを取得
let lecture = try await api.call(FetchLectureRequest(lectureId: lectureId))
// DBにキャッシュを保存
// 部分的に`@DatabaseActor`のスコープを生成できる
try await Task { @DatabaseActor in
// DBのEntity
let object = LectureObject(base: lecture)
try await databaseManager.set(object)
}.value
return lecture
}
// 関数ごとisolateもできる
@DatabaseActor
private func doSomething() {
...
}
}
AsyncLockedValue
アプローチc: 変数をピンポイントでactorでラップするアプローチ。
public final actor AsyncLockedValue<T> {
private var value: T
public init(initialValue: @autoclosure @Sendable () -> T) {
self.value = initialValue()
}
public func use(_ action: @Sendable (T) -> Void) {
action(value)
}
public func mutate(_ mutation: @Sendable (inout T) -> Void) {
mutation(&value)
}
public func set(_ value: T) {
self.value = value
}
}
型自体がactorになるのを回避できる。
final class Counter: Sendable {
let count = AsyncLockedValue(initialValue: 0)
func increment() async {
await count.mutate { $0 += 1 }
}
}
使い所
- 状態が少ない場合
- 型自体をactorにはしたくない/できない場合
- すべてのsync関数にawaitが必要になってしまうため
- 「
protocol
がactorではないからactorにはできない」など
具体例
個人的にはテストでよく使います。
protocol LoggerProtocol: Sendable {
func send(log: Log) async throws
}
final class StubLogger: LoggerProtocol {
// 普通にvar calledLogs: [Log]を使うと同時アクセスで落ちることがあった
let calledLogs = AsyncLockedValue<[Log]>(initialValue: [])
func send(log: Log) async throws {
await calledLogs.mutate { $0.append(log) }
}
}
アプローチd: 従来通りの同期ロック
使い所
- asyncにしたくない/できない場合
asyncの伝搬を防ぎたい場合などは従来通りNSLock
などを使う。
具体例
NSLock
のラッパーを使ってます
public final class LockedValue<T>: @unchecked Sendable {
private var value: T
private let lock = NSLock()
public init(initialValue: T) {
self.value = initialValue
}
public func use(_ action: (T) -> Void) {
lock.withLock {
action(value)
}
}
public func mutate(_ mutation: (inout T) -> Void) {
lock.withLock {
mutation(&value)
}
}
public func set(_ value: T) {
lock.withLock {
self.value = value
}
}
}
final class Counter: @unchecked Sendable {
let count = LockedValue(initialValue: 0)
func increment() {
count.mutate { $0 += 1 }
}
}
Synchronous Mutual Exclusion Lock 🔒
この辺りが来たら置き換えれそう?
@preconcurrency
import
アプローチe: Sendable
対応を「完全に」先送りにする場合に使うものだと思った。
特に、フレームワーク丸ごと警告が消えてしまうので、緊急性の高い警告を同時に消してしまう恐れがあり良くないと思った。対応開始後は使っていない。
現状のフレームワークの型の状態で上記の対応をするのが良いと思った。
何らかの事情で今対応できないなら
- 「警告を出し続ける」
または
- 「FIXMEコメントと共に
@unchecked Sendable
をつける」
をして、具体的にどの型がどういう問題で対応できないのか明確にするのが良いと思った。
@preconcurrency
import は以下のケースで使いました
- 開発中に警告の範囲を特定のモジュール群に抑制する
-
Sendable
対応の中でリプレースすることに決めたモジュールにつける
前者は作業中の補助として使うだけなので、コードに残るのは後者だけですね
@preconcurrency
import は基本封印するべきなのは同意です
共有ありがとうございます!!!
開発中に警告の範囲を特定のモジュール群に抑制する
なるほど!
警告が多すぎて見にくくなる場合や、原因のモジュールが分かりにくいときに使うのはすごい良さそうですね!
Sendable対応の中でリプレースすることに決めたモジュールにつける
読んで「これは良いな」と感じて気づいたのですが、
あるフレームワークの一部の型を見て反射的に@preconcurrency import
を付けると
フレームワーク丸ごと警告が消えてしまうので、緊急性の高い警告を同時に消してしまう恐れがあり良くない
ですが
モジュール全体を調査・部分的対応した後でつけるなら、すべての警告を把握した後なので上記の事故がなく良い
と自分の中で整理できました。