6️⃣

事例別!Strict Concurrency対応方法

2024/08/05に公開2

はじめに

Swift 6への移行にあたって、Strict Concurrencyの対応は避けては通れない作業です。
この記事では、実際によく起きるであろう問題に対して、エラーベース/やりたいことベースで対応方法をまとめました。

この記事に出てくるビルド結果は、明記されていない限り全てSwift 5モード + StrictConcurrencyでビルドした結果です。
Swift 5モード + StrictConcurrencyで警告になるものの多くは、Swift 6モードではコンパイルエラーになりますので「なんだ警告か」と思わず全て対処する必要があります。

プレイグラウンド

この記事に出てくるコードは全てリポジトリ(mtj0928/strict-concurrency-tips)にまとめて公開してあります。
このリポジトリにはあらかじめ、Swift 5モードSwift 5モード + StrictConcurrency, Swift 6モードの3つのビルド設定を用意しています。
ビルド設定による結果の違いを、同じソースコードで確認できて便利ですので、ぜひご利用ください。
(Swift 6モードが必要なのでXcode 16が必要です)

異なるビルド設定をSchemeで簡単に変更できます

同じソースコードに対する実行結果

Swift 5での実行結果

Swift 5 + StrictConcurrencyでの実行結果

Swift 6での実行結果

[actor-1] UIView/UIViewControllerがMainActorに隔離される

UIに関係するものはMainActorに隔離され、MainActor以外からはアクセスできません。
以下のpresent関数はMainActorに隔離されていないので、警告になります。

Subject
func present(_ viewController: UIViewController) {
    let newViewController = UIViewController() // ⚠️警告
    viewController.present(newViewController, animated: true) // ⚠️警告
}

presnet関数をMainActorに隔離することでコンパイルエラーを解決できます。

Solution
+@MainActor
 func present(_ viewController: UIViewController) {
     let newViewController = UIViewController()
     viewController.present(newViewController, animated: true)
 }

(ref: MainActor-1)

[actor-2] 呼び出し箇所が多くてMainActorを今すぐ付けられない

上で見たようにUIを触っているコードはMainActorから触る必要があります。
しかし、MainActorに隔離すると、その関数を呼び出している箇所も隔離する必要があり、さらにそれを呼び出している箇所も隔離する必要があり...、と芋づる式に多くの場所の変更が求められます。
影響範囲が狭いのであれば一度に対応することもできますが、呼び出している箇所が例えばプロジェクトの中に多数あり、一つMainActorをつけるだけで多くのエラーが出る場合など、一度に対応できない場合もあります。

そういう場合は@preconcurrencyをつけることで、その影響範囲を限定できます。

Solution
+@preconcurrency @MainActor
 func present(_ viewController: UIViewController) {
     let newViewController = UIViewController()
     viewController.present(newViewController, animated: true)
 }

例えば以下のようにpresent関数を呼び出しているpresentCaller関数の場合、本来はMainActorに隔離されていないのでpresent関数を呼び出せないのですが、Swift 5モードの間は呼び出すことができます。

呼び出し側
func presentCaller(_ viewController: UIViewController) {
    // - Swift 5: 警告/エラーなし
    // - Swift 5 + StrictConcurrency: ⚠️警告
    // - Swift 6: 🚨エラー
    present(viewController)
}

(ref: MainActor-2)

[actor-3] UIに関するdelegateがMainActorに隔離されていない

Appleが提供するSDK、およびサードパーティーのライブラリの中には、UIに関するdelegateも存在します。
AppleのSDKは比較的StrictConcurrencyの対応をしていますが、サードパーティーのライブラリの中には対応がなかなかされないものもあるでしょう。

例えば、以下の様なコードを考えてみます。

Subject
// 外部のライブラリが定義したプロトコル
public protocol FooViewDelegate {
    func didTapFooView(_ fooView: FooView)
}

// 自分が定義したVieController
final class FooViewController: UIViewController, FooViewDelegate {
    func didTapFooView(_ fooView: FooView) { // ⚠️警告
        // ...
    }
}

このコードは警告が発生します。
その原因はFooViewDelegateはどのactorにも隔離されていないdidTapFooView関数を要求していますが、FooViewControllerMainActorに隔離されているので、protocolが要求している関数と実装されている関数が異なるインターフェースと認識され、コンパイラに指摘されてしまいます。

これに対する解決方法は以下の2種類あります。

  • preconcurrency conformance
  • nonisolated + MainActor.assumeIsolated

preconcurrency conformance

Xcode 16から使える機能です。
@preconcurrencyをつけることでactorの違いによる指摘が抑制されます。

Solution1 (preconcurrencyを付与)
-final class FooViewController: UIViewController, FooViewDelegate {
+final class FooViewController: UIViewController, @preconcurrency FooViewDelegate {
     func didTapFooView(_ fooView: FooView) {
         // ...
     }
 }

この方法はprotocolが定義されているライブラリがまだ活発に開発をされており、近い将来StrictConcurrencyに対応されることが見込まれる場合に有効です。

nonisolated + MainActor.assumeIsolated

nonisolatedは関数やプロパティをactorに隔離しないようにする機能です。
これをつけることで、didTapFooView関数はもうMainActorに隔離されないので、protocolが要求している関数と実装されている関数が同じインターフェースと認識されます。

nonisolatedを付与
 final class FooViewController: UIViewController, FooViewDelegate {
-    func didTapFooView(_ fooView: FooView) {
+    nonisolated func didTapFooView(_ fooView: FooView) {
         // ...
     }
 }

しかしこの方法だと、didTapFooView関数の中でMainActorに隔離されている値に同期的にアクセスできず、Taskを発行して非同期にする必要があります。

final class FooViewController: UIViewController, FooViewDelegate {
    nonisolated func didTapFooView(_ fooView: FooView) {
        // MainActorに隔離されているFooViewの値にアクセスするには、
        // Taskを発行してアクセスする必要がある(=非同期になってしまう)
        Task { @MainActor in
            let foo = fooView.value
            // ...
        }
    }
}

これを避ける方法がassumeIsolatedです。
これは今いるコードがそのactorの上で実行されていると仮定し、そのactorに切り替えます。

Solution2 (nonisolated + MainActor.assumeIsolated)
 final class FooViewController: UIViewController, FooViewDelegate {
-    func didTapFooView(_ fooView: FooView) {
+    nonisolated func didTapFooView(_ fooView: FooView) {
+        // 同期的にMainActorに切り替える
+        MainActor.assumeIsolated { 
+            let foo = fooView.value
+            // ...
+        }
    }
}

どちらの方法もdidTapFooView関数がメインスレッド以外から呼ばれていた場合、危険な方法です。
メインスレッドから呼ばれていることを保証できない場合、nonisolatedを付与してTaskを発行する方法が安全です。

(ref: MainActor-3)

[Sendable-1] Sendableじゃない型をactorに渡せない

以下のコードはFooActorSendableじゃないNonSendableClassを渡していて警告が出ます。

Subject
actor FooActor {
    func doSomething(_ object: NonSendableClass) { /* ... */ }
}

struct Foo {
    let fooActor = FooActor()

    func doSomething(_ object: NonSendableClass) async {
        await fooActor.doSomething(object) // ⚠️警告
    }
}

Sendableではない型のインスタンスをactor間で共有することができません。
FoodoSomething関数はどのactorにも隔離されておらず、FooActorと隔離されていない関数の間で、Sendableではないインスタンスを共有することになり、データ競合を起こす可能性があります。

一番シンプルな解決方法は、共有するインスタンスをSendableにすることです。

Solution1 (Sendableにする)
 actor FooActor {
-    func doSomething(_ object: NonSendableClass) { /* ... */ }
+    func doSomething(_ object: SendableClass) { /* ... */ }
 }

 struct Foo {
     let fooActor = FooActor()

-    func doSomething(_ object: NonSendableClass) async {
+    func doSomething(_ object: SendableClass) async {
         await fooActor.doSomething(object)
     }
 }

どうしてもSendableにできない場合、別の方法としてsendingキーワードを関数の引数に付与することでも解決できます。

Solutionn2 (sending)
 actor FooActor {
    func doSomething(_ object: NonSendableClass) { /* ... */ }
 }

 struct Foo {
     let fooActor = FooActor()

-    func doSomething(_ object: NonSendableClass) async {
+    func doSomething(_ object: sending NonSendableClass) async {
         await fooActor.doSomething(object)
     }
 }

sendingキーワードをつけるだけで解決できならこの方法一択にも思いますが、実際は使用できるシチュエーションは限定的で、呼び出し側での制限もあります。

例えば以下のコードは警告が出ます。

let nonSendable = NonSendableClass()
await Foo2().doSomething(nonSendable)
print(nonSendable.value) // ⚠️警告

詳しく別の記事を書きましたので、そちらを参照してください。
https://zenn.dev/matsuji/articles/4f1858ce294474

(ref: Sendable-1)

[Sendable-2] ミュータブルなclassをSendableにできない

次のようなミュータブルなclassSendableにしたいシチュエーションを考えます。
ただこのCounterは可変な状態を持っているので、Sendableを付与するだけで警告が出てしまいます。

Subject
final class Counter: Sendable {
    private var value = 0 // ⚠️警告

    func increment() -> Int {
        value += 1
        return value
    }
}

参照型をSendableにしたい場合、まずはactorにすることを検討してください。
(actorは自動でSendableになります)

Solution 1 (actor)
-final class Counter: Sendable {
+final actor Counter {
     private var value = 0

     func increment() -> Int {
         value += 1
         return value
     }
 }

しかし、場合によってはどうしてもactorにできない場合があります。
そういう場合、ロック機構を使って内部の状態をデータ競合から防ぐ必要があります。

Solution 2 (Lock)
final class Counter: Sendable {
    private let valueLock = OSAllocatedUnfairLock<Int>(initialState: 0)

    func increment() -> Int {
        return valueLock.withLock { value in
            value += 1
            return value
        }
    }
}

これについて詳しくはこちらの記事で紹介されているので、参照してください。

https://zenn.dev/treastrain/articles/6ef73efccb33ff

(ref: Sendable-2)

[Sendable-3] 外部モジュールの型がSendableじゃない

外部ライブラリのStrict Concurrency対応が進んでおらず、スレッドセーフなのにSendableに準拠していない場合があります。
そういう場合、actor間でインスタンスを共有できずに不便です。

Subject
// MARK: - FooModule (外部モジュール)
public final class NonSendableButThreadSafe {
    // スレッドセーフな実装
}

// MARK: - 自分のモジュール
import Module

func doSomething() {
    let nonSendable = NonSendableButThreadSafe()
    Task {
        nonSendable.doSomething() // ⚠️警告
    }
    nonSendable.doSomething()
}

このような場合、@preconcurrency import、もしくは@unchecked Sendableが使えます。

@preconcurrency importはimport文の前に@preconcurrencyをつけることで、そのモジュールの型はactor間で共有されても警告が出ません。

@preconcurrency import Module

この方法を用いた場合、Moduleの中の型で、本来はSendableであるべきではない他の型もactor間で共有できてしまうので注意が必要です。

他の解決方法として、Sendableではない型に@unchecked SendableをつけることでコンパイラのチェックなしでSendableにすることができます。
この方法だと型単位でコントロールできますが、他のファイルや他のモジュールに影響がありますので注意が必要です。(とはいえ本当にスレッドセーフなら悪影響はないと思いますが)

// Xcode 16未満
extension NonSendableButThreadSafe: @unchecked Sendable {}

// Xcode 16以上 (Xcode 16からは`@retroactive`をつける必要があります)
extension NonSendableButThreadSafe: @retroactive @unchecked Sendable {}

どちらの方法を用いたとしても、その型が本当にスレッドセーフかどうかドキュメントやコードを確認する必要があります。

(ref: Sendable-3)

[グローバル変数-1] Sendableなインスタンスをグローバル変数にする

グローバル変数はどのコードからでもアクセスできます。つまり、どのスレッドからでもアクセスできてしまい、場合によってはデータ競合を引き起こす可能性があります。

Subject
enum Foo {
    static var sendableValue = SendableStruct() // ⚠️警告
}

DispatchQueue.main.async {
    Foo.sendableValue = SendableStruct() // メインスレッドから更新できてしまう
}
Task { @MainActor in
    Foo.sendableValue = SendableStruct() // MainActorから更新できてしまう
}
DispatchQueue.global().async {
    Foo.sendableValue = SendableStruct() // バックグラウンドスレッドから更新できてしまう
}
Task {
    Foo.sendableValue = SendableStruct() // 隔離されていないTaskから更新できてしまう
}

この場合、以下の選択肢が取れます。

  1. static letにする
  2. computed propertyにする
  3. actorに隔離する
  4. nonisolated(unsafe)をつける

static letにする / computed propertyにする

一つ目のstatic letと二つ目のcomputed propertyはsetterがないので、データ競合が起きません。
この方法が取れるならこの方法が間違いない方法です。

Solution 1 (static let) and 2 (computed property)
enum Foo {
    // 方法 1
    static let sendableValue = SendableStruct()

    // 方法 2
    static var sendableValue: SendableStruct {
        SendableStruct()
    }
}

actorに隔離する

グローバル変数へのアクセスをactorに隔離することで、複数のスレッドから同時に読み書きされることがなく、データ競合が起きません。

Solution 3 (actorに隔離)
enum Foo {
    @MainActor static var sendableValue = SendableStruct()
}

DispatchQueue.main.async {
    _ = Foo.sendableValue // メインスレッドからなら値を取得できる
    Foo.sendableValue = SendableStruct() // メインスレッドで変数の上書きもできる
}
Task { @MainActor in
    _ = Foo.sendableValue // MainActorからなら値を取得できる
    Foo.sendableValue = SendableStruct() // MainActorで変数の上書きもできる
}
DispatchQueue.global().async {
    // バックグラウンドスレッドからは更新できない
    // Foo.sendableValue = SendableStruct()
}
Task {
    // 非同期でなら取得できる
    _ = await Foo.sendableValue
    // 隔離されていないTaskからは更新できない
    // Foo.sendableValue = SendableStruct() 
}

nonisolated(unsafe)をつける

四つ目のnonisolated(unsafe)については、コンパイラのチェックを入れない方法です。
データ競合が起きる可能性があり、ロック機構など、なにかしらの方法でデータ競合が起きないように手動で管理する必要があります。
あまり安全な方法ではないので、どうしようもない場合の最後の手段として使うに留めるべきです。

Solution 4 (nonisolated(unsafe))
enum Foo {
    nonisolated(unsafe) private static var _sendableValue = SendableStruct()
    private static let lock = NSLock()

     // どこからでも更新できるので、lock機構などでデータ競合を手動で防ぐ必要がある
    static var sendableValue: SendableStruct {
        get {
            lock.withLock { _sendableValue }
        }
        set {
            lock.withLock { _sendableValue = newValue }
        }
    }
}

DispatchQueue.global().async {
    _ = Foo.sendableValue
    Foo.sendableValue = SendableStruct()
}
Task {
    _ = Foo.sendableValue
    Foo.sendableValue = SendableStruct()
}

(ref: GlobalVariable-1)

[グローバル変数-2] Sendableではないインスタンスをグローバル変数にする

StrictConcurrencyにおいて、Sendableに準拠していない型のインスタンスをグローバル変数で管理するのはかなり難しいです。
まずはなんとか対象の型をSendableにして、上の[グローバル変数-1]で紹介している方法がとれないか考えてみてください。

それでも難しい場合に取れる方法について紹介します。
まずは以下のようなコードを考えます。Sendableの時と同様、あらゆるスレッドから変数の読み書きが同期的にできてしまいます。

Subject
enum Foo {
    static var nonSendableInstance = NonSendableClass() // ⚠️警告
}

DispatchQueue.main.async {
    _ = Foo.nonSendableInstance
    Foo.nonSendableInstance = NonSendableClass()
}
Task { @MainActor in
    _ = Foo.nonSendableInstance
    Foo.nonSendableInstance = NonSendableClass()
}
DispatchQueue.global().async {
    _ = Foo.nonSendableInstance
    Foo.nonSendableInstance = NonSendableClass()
}
Task {
    _ = Foo.nonSendableInstance
    Foo.nonSendableInstance = NonSendableClass()
}

考えられる対応策は以下の3つです。

  1. computed propertyにする (Sendableの場合と同様なので省略)
  2. actorに隔離する
  3. nonisolated(unsafe)をつける

actorに隔離する

Sendableの場合と同様actorで隔離すること自体は可能です。

Solution 2 (actor)
enum Foo {
    @MainActor
    static var nonSendableInstance = NonSendableClass()
}

DispatchQueue.main.async {
    _ = Foo.nonSendableInstance // 値を取得できる
    Foo.nonSendableInstance = NonSendableClass() // 変数の更新もできる
}
Task { @MainActor in
    _ = Foo.nonSendableInstance // 値を取得できる
    Foo.nonSendableInstance = NonSendableClass() // 変数の更新もできる
}

しかしSendableではない場合、少し状況が違います。
MainActor以外の場所からアクセスしてしてまうと、Sendableではない変数を複数のactor間で共有することになり、警告が出てしまいます。

Task {
    _ = await Foo.nonSendableInstance // ⚠️警告
    // 更新できない
    // Foo.nonSendableInstance = NonSendableClass() 
}

そのため、Sendableではない値をactorに隔離したとき、その値を他のactorからはたとえ非同期であっても取得することができません。

nonisolated(unsafe)をつける

この方法を用いる場合、Sendableの時以上に気を付ける必要があります。
Sendableの時と同様にグローバル変数へのアクセスをロック機構を用いてガードしてみます。

enum Foo {
    nonisolated(unsafe) private static var _nonSendableInstance = NonSendableClass()
    private static let lock = NSLock()
    static var nonSendableInstance: NonSendableClass {
        get {
            lock.withLock { _nonSendableInstance }
        }
        set {
            lock.withLock { _nonSendableInstance = newValue }
        }
    }
}

グローバル変数へのアクセスをロックしているので一見安全そうに見えますが、複数のactor間でnonSendableInstanceのインスタンスが共有できてしまうため、安全ではありません。
例えば、以下のようなコードはnonSendableInstanceのプロパティを複数のスレッドから更新しており、これはデータ競合を引き起こす可能性があります。

DispatchQueue.global().async {
    let nonSendableInstance = Foo.nonSendableInstance
    nonSendableInstance.value = ...
}

DispatchQueue.global().async {
    let nonSendableInstance = Foo.nonSendableInstance
    nonSendableInstance.value = ...
}

そのため、nonSendableInstanceというグローバル変数だけをロックするのではなく、そのインスタンスを使っている箇所もロックする必要があります。

Solution 3 (nonisolated(unsafe))
enum Foo {
    static let lock = NSLock()
    static var nonSendableInstance = NonSendableClass()
}
DispatchQueue.global().async {
    Foo.lock.withLock {
        let nonSendableInstance = Foo.nonSendableInstance
        nonSendableInstance.value = ...
    }
}

DispatchQueue.global().async {
    Foo.lock.withLock {
        let nonSendableInstance = Foo.nonSendableInstance
        nonSendableInstance.value = ...
    }
}

これでデータ競合を引き起こすことはありませんが、今後の開発で常に注意する必要があり、避けられるなら避けた方が良い方法です。

(ref: GlobalVariable-2)

[Others-1] deinitがactorに隔離されない

deinitactorに隔離されずに困ることが時々あります。

Subject
@MainActor class Foo {
    private var nonSendableObserver: NonSendableObserver?

    deinit {
        nonSendableObserver?.stop() // ⚠️警告
    }
}

これはMainActorに隔離されていないdeinitから、MainActor に隔離されているnonSendableObserverにアクセスしているからです。

このような場合、nonSendableObserverSendableにすることで解決できます。
もし、Sendableにすることができない場合は、以下の方法が考えられます。

  • nonisolated(unsafe)を付け足す
  • MainActorに隔離された型でラップする

nonisolated(unsafe)を付け足す

nonSendableObserverがMainActorに隔離されたFooクラスの中でしか触らないのであれば、nonisolated(unsafe)nonSendableObserverに付け足す方法が一番シンプルです。

Solution 1 (nonisolated(unsafe))
@MainActor class Foo {
    nonisolated(unsafe) private var nonSendableObserver: NonSendableObserver?

    deinit {
        nonSendableObserver?.stop()
    }
}

この方法はシンプルですが、deinit以外の場所ではMainActorからしか触らないように注意する必要があります。
この方法は基本的にはMainActorからしか触らず、Fooインスタンスが解放される時のみMainActor以外からのアクセスを許可することで、同時にアクセスされる心配がなく、データ競合を発生させません。

getterを公開したり、他のnonsiolated関数の中で触る場合には安全でない可能性があるので気をつけてください。

@MainActor class Foo {
    // 🚨getterが公開されてる
    nonisolated(unsafe) private(set) var nonSendableObserver: NonSendableObserver?

    deinit {
        nonSendableObserver?.stop()
    }

    func startObserve() {
        nonSendableObserver?.observe()
    }

    // 🚨deinit以外の隔離されていない関数でアクセスしている
    nonisolated func stop2() {
        nonSendableObserver?.stop()
    }
}

この方法はkoherさんがコメントで教えてくださいました。
ありがとうございます。

MainActorに隔離された型でラップする

もしなにかしらの事情でnonSendableObserverを外に公開する必要があったり、他のnonisolatedな関数の中で触る必要がある場合は、別の方法としてnonSendableObserverMainActorでに隔離した別の型でラップすることで解決できます。

Solution 2 (MainActor)
@MainActor class MainActorObserver {
    private var internalObserver: NonSendableObserver?
    func stop() { internalObserver?.stop() }
}

@MainActor class Foo {
    private(set) var mainActorObserver: MainActorObserver?

    deinit {
        Task { @MainActor [mainActorObserver] in
            mainActorObserver?.stop()
        }
    }
}

(ref: deinit-1)

おわりに

できるだけ思いつくパターンを列挙しましたが、これで全ての対応を網羅できているとは思いませんので、もし漏れている内容がありましたら、コメントで教えてください。
また、間違ったことを記述していたり、より良い方法があれば、ぜひ教えてください。

Discussion

Yuta KoshizawaYuta Koshizawa

deinit の例は、 nonSendableObserverFoo インスタンスの内部からのみの利用で安全であれば( MainActor isolatedなメソッドと deinit からのみのアクセスであれば、メソッド同士は排他的だし deinit が呼ばれるときにインスタンスメソッドが呼ばれていることはないので、同時にアクセスされることはないので)、 nonisolated(unsafe) にするのはどうでしょう?

matsujimatsuji

コメントありがとうございます!
actorの中でnonisolatedを書くことも多くはないでしょうし、シンプルで良い方法に思いました。
そのアイデアを追加させていただきました。ありがとうございました!
nonisolated(unsafe)を付け足す