この記事は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
で入れたモジュールについてだけ暗黙的に隔離領域を継承する仕様でもいいじゃないかな 難しいか…