Open3

[Swift] Sendable対応の所感(意見求む)

kntkymtkntkymt

雑多ですが意見ください!!!「うちではこうやった」「AAAのケースをBBBのアプローチで解決したことがあります」「XXXのアプローチはYYYの問題がありました」などなど、お気軽にコメントください!!

Sendable

SE-0302 Sendable and @Sendable closures

https://developer.apple.com/documentation/swift/sendable

平たく言えば「スレッドセーフかどうか」

手順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

https://developer.apple.com/documentation/foundation/userdefaults

extension UserDefaults: @unchecked Sendable {}

他には

  • @propertyWrappervarでしか宣言できない問題によって怒られたことがある。これも「実質的に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にする

actorSendableなので、actorで適切に実装することでSendableになる。

actor Counter {
    var count = 0

    func increment() {
        count += 1
    }
}

let counter = Counter()
await counter.increment()

使い所

  • 複数状態を持つ場合・状態操作が多い場合
    • それぞれにLock等を取るとコードが見にくい・めんどくさい
  • メソッドや操作がasyncになっても良い場合

具体例

ハードウェアの機能を操作するクラスは大体actorにした。

  • 状態操作が多かった
  • 元々delegateAsyncStreamや普通の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
}

アプローチb: globalActorでisolateする

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・排他制御を行う。
つまり以下のコードでc1c2のメールボックスは別であり、c1へのタスクがc2へのタスクを排他することはない。

actor CounterActor {
    var count = 0

    func increment() {
        count += 1
    }
}

let c1 = CounterActor()
let c2 = CounterActor()

ここで、globalActorはシングルトンのactorであるため、@CounterActorとisolateした変数・型・メソッドがすべて一つのメールボックスで管理されてしまい、実行が遅れる(渋滞する)可能性がある。
つまり以下のコードでc1c2のメールボックスは同じであり、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() {
        ...
    }
}

アプローチc: AsyncLockedValue

変数をピンポイントで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 🔒
この辺りが来たら置き換えれそう?

アプローチe: @preconcurrency import

Sendable対応を「完全に」先送りにする場合に使うものだと思った。
特に、フレームワーク丸ごと警告が消えてしまうので、緊急性の高い警告を同時に消してしまう恐れがあり良くないと思った。対応開始後は使っていない。

現状のフレームワークの型の状態で上記の対応をするのが良いと思った。
何らかの事情で今対応できないなら

  • 「警告を出し続ける」

または

  • 「FIXMEコメントと共に@unchecked Sendableをつける」

をして、具体的にどの型がどういう問題で対応できないのか明確にするのが良いと思った。

elmetalelmetal

@preconcurrency import は以下のケースで使いました

  • 開発中に警告の範囲を特定のモジュール群に抑制する
  • Sendable対応の中でリプレースすることに決めたモジュールにつける

前者は作業中の補助として使うだけなので、コードに残るのは後者だけですね
@preconcurrency import は基本封印するべきなのは同意です

kntkymtkntkymt

共有ありがとうございます!!!

開発中に警告の範囲を特定のモジュール群に抑制する

なるほど!
警告が多すぎて見にくくなる場合や、原因のモジュールが分かりにくいときに使うのはすごい良さそうですね!

Sendable対応の中でリプレースすることに決めたモジュールにつける

読んで「これは良いな」と感じて気づいたのですが、
あるフレームワークの一部の型を見て反射的に@preconcurrency importを付けると

フレームワーク丸ごと警告が消えてしまうので、緊急性の高い警告を同時に消してしまう恐れがあり良くない

ですが
モジュール全体を調査・部分的対応した後でつけるなら、すべての警告を把握した後なので上記の事故がなく良い
と自分の中で整理できました。