`async` なメソッドは、宣言時に限定しない限り隔離領域を超えます

:::note
この記事は[Swift Advent Calendar 2024](https://qiita.com/advent-calendar/2024/swift)への寄稿です。
:::

# どう言う現象か

ちょっとわかりにくいタイトルとなってしまいますが、最小限のコードにするとこんな感じです：

```swift
final class NonSendableObject {
    func foo() async {
        // ...
    }
}

struct Demo {
    let object = NonSendableObject()
    func doSomething() async {
        await object.foo() // ←このメソッドは、`doSomething()` の呼び出しと同じアクターで実行される保証をしません
    }
}
```

まあこれまでなら別にこれはどうでもいい（本当は良くない）ですが、Swift 6でData Race Safetyが導入されて以来、これによって大きな問題が発生します。上記のコードだけならまだいいですが、例えばその `Demo` は本当は `View` だと想像して見てください、そうすると必ずこのようなエラーが発生します

```swift
struct Demo: View {
    // ...
    func doSomething() async {
        await object.foo() // Error: Sending 'self.object' risks causing data races
    }
}
```

# 何が発生しているのか

まず、Swift 6では、`View` が `@MainActor` 縛りがつくようになりました。この時、View自身が持つStored Propertyも自動的に `@MainActor` 縛りがつきます。つまり `object` プロパティーも自動的に `@MainActor` 縛りになります。

逆に、`NonSendableObject` の `foo` は、指定のアクター縛りがないのと、非同期で動く `async` なメソッドとして実行アクターもデフォルトでは継承しないため、実際に実行するアクターがどれに当たるかわからないのです。つまり、`object` のMainActorで実行される保証ができないのです。

この時、ちょっとわかりにくいのですが、`foo` はメソッドとして暗黙的に自分自身、すなわち `object` インスタンスを持っている（そうでないとメソッド内で自分自身のプロパティーなどにアクセスできないから）ため、`object` がMainActorの隔離領域を超えてしまうけど、`Sendable` 適合がないので、Data Race発生するリスクがあるとして、ビルドエラーになってしまいます。

:::note
このような仕様になった理由としては[こちらのProposal](https://github.com/hborla/swift-evolution/blob/async-function-isolation/proposals/0338-clarify-execution-non-actor-async.md)で詳しく書かれています。簡単に言うと、もし隔離領域を継承する仕様だったら、何も考えずに重いタスクがMainActorでawaitしてしまう可能性が高いのと、せっかくのマルチコア設計が最大限に活かせにくいからパフォーマンスの低下を招きやすいからだそうです。

ただこの仕様の場合、Non-Sendableな型が間に入ると途端に対応が大変になるし、そのNon-Sendableな型が外部依存の場合は更に対応が難しくなるので、今は[デフォルトで隔離領域を継承するように変更する議論](https://forums.swift.org/t/pitch-inherit-isolation-by-default-for-async-functions/74862)も盛り上がっています。
:::

# ではどう対応すればいいのか

## `NonSendableObject` は自分たちで作ったものの場合

もし `NonSendableObject` は自分たちで作ったなら、まだ比較的に対応が楽です。主な対応法は2つあるかと思います：

### `NonSendableObject` を `Sendable` にできないか確認

たまにある話ですが、今までは特に `Sendable` 適合をしなくてもなんとなくやってこれたから、本当は `NonSendableObject` は `Sendable` 適合できるのにコード上でそう宣言しなかったことも考えられます。例えば自身になんの可変状態も持たない全て純粋関数で構成されているヘルパーオブジェクトなどはまさにこれに該当します。なのでとりあえずまずは `Sendable` に適合できないか確認してみるのが一番いいかと思います。もし本当に `Sendable` に適合できたら、パフォーマンスの問題も気にする必要なく、今まで通りにアプリが動きます。

:::note
この記事では `NonSendableObject` が参照型（`class`）の前提で書かれていますが、もちろん値型（`struct`）であっても同様の問題が発生します。ただ値型の場合 `Sendable` 適合は筆者の肌感覚としてはみんなきちんとこなしてるような気がするので、ここで敢えて取り上げませんでした。
:::

### `foo()` メソッドをアクター継承させる

ただやはり参照型として、可変状態の塊であることが多いから、その場合はどう足掻いても `Sendable` 適合できないので、上の方法は諦めるしかないですが、その代わりに手動で `foo()` メソッドの実行をアクター継承させるようにする方法もあります：下記のように引数にアクターを指定すればいいです：

```diff_swift
final class NonSendableObject {
-    func foo() async {
+    func foo(isolation: isolated (any Actor) = #isolation) async {
```

こうすれば、`await object.foo()` を呼び出す時、呼び出しのアクターが `foo` に引き継がれるので、隔離領域がちゃんと守られます。

## `NonSendableObject` が外部依存のものの場合

もし `NonSendableObject` が外部依存のものの場合、残念ながら自分たちで定義したものではないので、きちんとData Raceを考慮した対応が非常に難しくなります。この場合は、「悪いのは自分じゃない！」の精神で割り切るのも、一つの対応法とも言えるのではないでしょうか。

### 無理矢理 `Sendable` 適合宣言をする

もし前章のヘルパーオブジェクトと同じように、`NonSendableObject` は本当はSendableなのに、ただ宣言がされていないだけでしたら、無理矢理後付けで `Sendable` 適合する方法もあります。本来 `Sendable` はできることを制限するプロトコルなので、必ず宣言時にしないといけないのですが、今回のようなシチュエーションを見据えて `@retroactive` と `@unchecked` をつけることであとから `Sendable` 適合もできます。可変状態が含まれないヘルパーオブジェクトの他に、通常の可変オブジェクトが含まれない完全にImmutable ClassであるKMPの `data class` などでしたら、これが最適な解決法かと思います。

```swift
extension NonSendableObject: @retroactive @unchecked Sendable {}
```

### 別関数でラップして `Task` で囲む

これはどっちかというと、おそらく SwiftUI のバグだと思います（隔離領域が守られないはずなので）が…とりあえずこんな感じで一回 `object` を引数として受け取るラップ関数を作って、その関数の中で更に `Task` 作れば、このラップ関数を呼び出すのは可能になります：

```swift
    func doSomething(with object: NonSendableObject) async {
        await Task {
            await object.foo()
        }.value // もし `foo()` は戻り値を返す場合は、このように `Task{}.value` で呼び出すことでその戻り値を `doSomething(with:)` でも返せます
    }

// ...

    // 呼び出し例
    Button("demo") {
        Task {
            // await object.foo() // ←これはビルドエラー
            await doSomething(with: object) // これはOK
        }
    }
```

ただしこの場合、最初に書いた通り本当は隔離領域が守られていないはずなので単純にSwiftのバグの可能性が高いのと、`Task` を別途作ったことによってキャンセルが途絶えてしまうから場合によっては注意が必要です。

### `@preconcurrency` を付与する

まあそもそも外部モジュール自体がSwift 6対応していないことが多いだけなので、モジュール全体を `@preconcurrency` で `import` すれば、このモジュール全体に対してData Raceチェックが対象から外されるから、上記のような対応をいちいち適用する必要が全くないから、面倒くさい場合はこれが最適かもですね

```swift
@preconcurrency import SomeModule
```

ただしこの場合は、SomeModuleのすべての非同期処理のData Raceチェックが外されるので、本当は利用するこちらがある程度カバーしてあげないといけない処理でもワーニングやエラーがなくなって気づきにくいこともあるから、これもこれで気をつける必要があります

### そもそも `object` の持ち方を再考する

`NonSendableObject` が実はSendableである場合以外、ぶっちゃけあとからちゃんと隔離領域を守らせる方法が現状皆無ですので、もしビルドを通すだけでなく、ちゃんとData Race Safetyも守りたいなら、もうちょっとした工夫が必要です。

とはいえ、大抵の場合は `object` を `nonisolated` で持たせるのもできないと思います。`Sendable` 適合していないから、`nonisolated` のままビルドが通る方法があったら苦労しないので。

ここで紹介できる方法としては、もう一つの `Sendable` なラッパーオブジェクトを作るやり方です。`Sendable` といっても、`object` を持ってる時点でそのまま `Sendable` にできないから、`@unchecked` にして、自分自身で排他処理を入れてデータレースから守る必要があります。

そのためには、まずはこの `NonSendableObject` を処理するためのアクターを定義しておきましょう、名前は `NSOActor` としましょう：

```swift
@globalActor
final class NSOActor {
    actor ActorType {}
    static let shared: ActorType = .init()
}
```

これで、`@MainActor` と同じような感じで、`@NSOActor` を使えます。

次に、ラッパーを作ります。

```swift
final class NSOWrapper: @unchecked Sendable {
    private let object: NonSendableObject = .init()

    @NSOActor
    private var isLocked = false
    
    @NSOActor
    func foo() async {
        while isLocked {
            await Task.yield()
        }
        isLocked = true
        defer {
            isLocked = false
        }
        await object.foo()
    }
}
```

このラッパーオブジェクトは、自分自身でSendableを保証する必要があるので、`@unchecked Sendable` で適合します。そしてその内容として `object: NonSendableObject` を持つ以外に、`@NSOActor` 隔離の `isLocked` ロックと、同じく `@NSOActor` 隔離の `foo` 関数があります。このラップされた `foo` 関数の中では、まず `isLocked` ロックされているかを確認して、されていたらロックが外れるまで待ちます。そしてロックが解除されたら、自分でロックを掛けて、処理が終わるまでロックを外します。その間に `await object.foo()` を実行します。そして呼び出し側は `NonSendableObject` の代わりに `NSOWrapper` を持ち、`NSOWrapper` の　`foo` メソッドを呼び出せばいいです

```swift
    // 呼び出し例
    Button("demo") {
        Task {
            await wrapper.fo()
        }
    }
```

こうすることで、`object.foo()` 自身は確かにどのアクターで実行されるかわかりませんが、`wrapper.foo()` は必ず `NSOActor` で実行し、空くまでずっと待ち続けるので、同時実行されることがあり得ないから、これでData Race Safetyも保てます。ただまあ対応コストがそこそこ高いですよね、コンポーネントとメソッドの数に比例して対応箇所が増えてしまいますので。

:::note
こんな面倒くさいことやるより、普通に `NSOWrapper` を `@NSOActor` 隔離すればいいのでは？と思うかもしれませんが、もしそれでビルドできるなら問題ないですが、特にSwiftUIの場合、そうしてしまうと `@State` や `@Environment` で使えなくなるので、`@unchecked Sendable` の方が使いやすいことが多いです。
:::

# 終わりに

まあパフォーマンスの懸念はわかりますが、`@preconcurrency` で入れたモジュールについてだけ暗黙的に隔離領域を継承する仕様でもいいじゃないかな :thinking: 難しいか…