この記事はSwift Advent Calendar 2024への寄稿です。
どう言う現象か
ちょっとわかりにくいタイトルとなってしまいますが、最小限のコードにするとこんな感じです:
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 だと想像して見てください、そうすると必ずこのようなエラーが発生します
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発生するリスクがあるとして、ビルドエラーになってしまいます。
このような仕様になった理由としてはこちらのProposalで詳しく書かれています。簡単に言うと、もし隔離領域を継承する仕様だったら、何も考えずに重いタスクがMainActorでawaitしてしまう可能性が高いのと、せっかくのマルチコア設計が最大限に活かせにくいからパフォーマンスの低下を招きやすいからだそうです。
ただこの仕様の場合、Non-Sendableな型が間に入ると途端に対応が大変になるし、そのNon-Sendableな型が外部依存の場合は更に対応が難しくなるので、今はデフォルトで隔離領域を継承するように変更する議論も盛り上がっています。
ではどう対応すればいいのか
NonSendableObject は自分たちで作ったものの場合
もし NonSendableObject は自分たちで作ったなら、まだ比較的に対応が楽です。主な対応法は2つあるかと思います:
NonSendableObject を Sendable にできないか確認
たまにある話ですが、今までは特に Sendable 適合をしなくてもなんとなくやってこれたから、本当は NonSendableObject は Sendable 適合できるのにコード上でそう宣言しなかったことも考えられます。例えば自身になんの可変状態も持たない全て純粋関数で構成されているヘルパーオブジェクトなどはまさにこれに該当します。なのでとりあえずまずは Sendable に適合できないか確認してみるのが一番いいかと思います。もし本当に Sendable に適合できたら、パフォーマンスの問題も気にする必要なく、今まで通りにアプリが動きます。
この記事では NonSendableObject が参照型(class)の前提で書かれていますが、もちろん値型(struct)であっても同様の問題が発生します。ただ値型の場合 Sendable 適合は筆者の肌感覚としてはみんなきちんとこなしてるような気がするので、ここで敢えて取り上げませんでした。
foo() メソッドをアクター継承させる
ただやはり参照型として、可変状態の塊であることが多いから、その場合はどう足掻いても Sendable 適合できないので、上の方法は諦めるしかないですが、その代わりに手動で foo() メソッドの実行をアクター継承させるようにする方法もあります:下記のように引数にアクターを指定すればいいです:
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 などでしたら、これが最適な解決法かと思います。
extension NonSendableObject: @retroactive @unchecked Sendable {}
別関数でラップして Task で囲む
これはどっちかというと、おそらく SwiftUI のバグだと思います(隔離領域が守られないはずなので)が…とりあえずこんな感じで一回 object を引数として受け取るラップ関数を作って、その関数の中で更に Task 作れば、このラップ関数を呼び出すのは可能になります:
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チェックが対象から外されるから、上記のような対応をいちいち適用する必要が全くないから、面倒くさい場合はこれが最適かもですね
@preconcurrency import SomeModule
ただしこの場合は、SomeModuleのすべての非同期処理のData Raceチェックが外されるので、本当は利用するこちらがある程度カバーしてあげないといけない処理でもワーニングやエラーがなくなって気づきにくいこともあるから、これもこれで気をつける必要があります
そもそも object の持ち方を再考する
NonSendableObject が実はSendableである場合以外、ぶっちゃけあとからちゃんと隔離領域を守らせる方法が現状皆無ですので、もしビルドを通すだけでなく、ちゃんとData Race Safetyも守りたいなら、もうちょっとした工夫が必要です。
とはいえ、大抵の場合は object を nonisolated で持たせるのもできないと思います。Sendable 適合していないから、nonisolated のままビルドが通る方法があったら苦労しないので。
ここで紹介できる方法としては、もう一つの Sendable なラッパーオブジェクトを作るやり方です。Sendable といっても、object を持ってる時点でそのまま Sendable にできないから、@unchecked にして、自分自身で排他処理を入れてデータレースから守る必要があります。
そのためには、まずはこの NonSendableObject を処理するためのアクターを定義しておきましょう、名前は NSOActor としましょう:
@globalActor
final class NSOActor {
actor ActorType {}
static let shared: ActorType = .init()
}
これで、@MainActor と同じような感じで、@NSOActor を使えます。
次に、ラッパーを作ります。
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 メソッドを呼び出せばいいです
// 呼び出し例
Button("demo") {
Task {
await wrapper.fo()
}
}
こうすることで、object.foo() 自身は確かにどのアクターで実行されるかわかりませんが、wrapper.foo() は必ず NSOActor で実行し、空くまでずっと待ち続けるので、同時実行されることがあり得ないから、これでData Race Safetyも保てます。ただまあ対応コストがそこそこ高いですよね、コンポーネントとメソッドの数に比例して対応箇所が増えてしまいますので。
こんな面倒くさいことやるより、普通に NSOWrapper を @NSOActor 隔離すればいいのでは?と思うかもしれませんが、もしそれでビルドできるなら問題ないですが、特にSwiftUIの場合、そうしてしまうと @State や @Environment で使えなくなるので、@unchecked Sendable の方が使いやすいことが多いです。
終わりに
まあパフォーマンスの懸念はわかりますが、@preconcurrency で入れたモジュールについてだけ暗黙的に隔離領域を継承する仕様でもいいじゃないかな
難しいか…