SwiftProgrammingiOS Developer

なぜSwiftの標準配列実装は、値型であるにもかかわらず同時にアクセスされる場合に明示的な同期を必要とするのか?

Hintsage AIアシスタントで面接を突破

質問への回答。

質問の歴史 この質問は、SwiftObjective-Cの手動メモリ管理と可変クラス階層から現代の値型中心のパラダイムに移行する際に生まれました。初期のSwiftバージョンでは、値型(ArrayDictionaryなど)が変更されるまで基盤ストレージを共有する最適化として**Copy-on-Write (CoW)**が導入されました。しかし、開発者たちは当初、値セマンティクスが自動スレッドセーフを暗示すると考えており、そのために同時処理コードで微妙なレース条件が発生しました。この誤解は、Grand Central Dispatch (GCD)や後のSwift Concurrencyの導入時に重要になり、値型内部の共有可変状態が予測不可能なクラッシュを引き起こし、再現が難しいという問題を引き起こしました。

問題 Arrayは言語レベルでは値型として振る舞いますが、その内部実装は要素を保存するために参照カウントされたヒープバッファを使用しています。複数のスレッドが同じArrayインスタンスに同時にアクセスする場合、たとえappendのような安全そうな操作であっても、CoWメカニズムがトリガーされます。ユニーク性のチェック(isKnownUniquelyReferenced)とその後のバッファ変更は別々の非原子的な操作です。これにより、2つのスレッドが共にバッファがユニークでないと判断したり、同時に複製したり、さらに悪化して適切な同期なしに共有バッファを変更することが可能になり、メモリの破損、参照カウントの不均衡、またはEXC_BAD_ACCESSクラッシュを引き起こす可能性があります。

解決策 Swiftは、値型のスレッド境界を越える孤立境界をプログラマーが強制することに依存しています。この言語は、可変状態がSendableプロトコルに準拠して直列にアクセスされることを保証するために、actorsSwift 5.5で導入)を優先メカニズムとして提供しています。あるいは、従来の同期プリミティブ(NSLockや直列のDispatchQueueバリアなど)を用いて配列の変更をカプセル化することも可能です。重要なポイントは、Swift 6が厳格な同時実行チェックを通じてコンパイル時データレース検出を強制し、同時実行ドメインを跨いで可変値型の暗黙の共有を実行時の失敗ではなくコンパイルエラーにすることです。

// 安全でない同時アクセス var sharedArray = [1, 2, 3] DispatchQueue.concurrentPerform(iterations: 100) { _ in sharedArray.append(Int.random(in: 0...100)) // データ競合! } // Actorを使用した安全な解決策 actor SafeArray { private var storage: [Int] = [] func append(_ element: Int) { storage.append(element) } func getAll() -> [Int] { return storage } } let safeArray = SafeArray() Task { await safeArray.append(42) }

実生活からの状況

高スループットの画像処理パイプラインでは、複数の同時フィルター操作からのメタデータタグを中央のリポジトリに蓄積する必要がありました。各DispatchQueueワーカーは、結果を共有Arrayに追加していましたが、値セマンティクスがデータ競合に対する原子的保証を本質的に提供すると誤って想定していました。この仮定は、負荷が高いときにCopy-on-Writeメカニズムがバッファの再割り当て中にレース条件に遭遇し、内部の参照カウントとストレージポインタが破損したときに断続的なEXC_BAD_ACCESSクラッシュを引き起こしました。

重負荷における断続的なクラッシュを解決するために、3つのアプローチを検討しました。まず、NSLockを使用してクラスに配列をラップすることを評価しました。これはクリティカルセクションの詳細な制御を提供しましたが、例外安全性と、ロックを保持している間にコールバックがトリガーされた場合のデッドロックの潜在的なリスクが大幅に複雑化しました。このアプローチは、複数の共有リソース間のロック階層の手動管理を必要とし、メンテナンス中の人的エラーのリスクを増加させました。

次に、同時実行メカニズムとして直列のDispatchQueueを使用することをテストしました。書き込みにはqueue.syncを、読み込みにはqueue.asyncを活用してFIFOの順序を保証しました。この方法ではデータ競合が排除されましたが、すべての操作が直列化され、数千の画像を同時に処理する際に重大なボトルネックとなりました。キューの競合により、ピーク負荷時にスループットが約40%減少し、並列処理の利点を事実上無効にしました。

三つ目として、Arrayを隔離し、変更のために非同期メソッドのみを公開するカスタムActor MetadataStoreを実装しました。これは、Swiftの構造化されている同時実行モデルを活用しています。このアプローチは、すべての状態アクセスがアクターの直列エグゼキュータで行われることを保証し、手動同期プリミティブではなく構造によってデータ競合を防ぎました。この保証は、コンパイラがSendableプロトコルを使用して強制しました。

私たちはActorアプローチを選択しました。これは、Swiftの静的な同時実行分析を介してコンパイル時にデータ競合の安全性を提供しました。これにより、低レベルプリミティブに関連する手動ロック管理のオーバーヘッドなしで、バグの全クラスが排除されました。移行には、同期コールバックを非同期/待機パターンにリファクタリングする必要がありましたが、その結果、運用中のクラッシュ率が0%になり、ロックアプローチと比較してコンテンツの減少により15%のパフォーマンス改善がありました。

候補者が見逃しがちなこと

なぜisKnownUniquelyReferencedは、他の参照が存在しないにもかかわらず、予期せずfalseを返すのか?

これは、コンパイラがSwiftタイプをObjective-Cにブリッジングする際や、サニタイザーが有効になっているデバッグビルド中に一時的な参照を作成する場合があるためです。さらに、値がクロージャにキャプチャされるか、inoutパラメータを取る関数に渡されると、コンパイラは参照カウントをインクリメントするシャドウコピーを挿入します。候補者は、ユニーク性が静的解析ではなくランタイムの参照カウントによって決定されること、最適化レベル(-O、-Onone)がこの動作に大きく影響することを見逃しがちです。

Copy-on-Writeは、大規模データ変換のパフォーマンスに対して永続データ構造とどのように影響するか?

多くの人が、CoWが不変の永続データ構造と同じ複雑さの保証を提供すると考えています。しかし、SwiftのCoWは、共有後の最初の変更でO(n)のコピーをトリガーし、中間ステップを持つアルゴリズムでレイテンシスパイクを引き起こす可能性があります。候補者はしばしばwithUnsafeMutableBufferPointerinoutパラメータを使用することで中間コピーを回避できること、またはContiguousArrayを使用することで非クラス要素の参照カウントのオーバーヘッドを排除できることを見逃します。

Swiftの今後のCopyableおよびEscapable制約の文脈におけるスレッドセーフな値セマンティクスとスレッドセーフな参照型の違いは何ですか?

Swift 6でコピー不可な型が導入されることにより、値型はユニークな所有権を強制することができ(~Copyable)、CoWが機能しない真の線形型を提供します。候補者は、これが同時実行モデルを「CoWで共有」から「移動のみのユニーク性」にシフトさせ、スレッドセーフが同期ではなく排他性によって保証されることを見逃すことがよくあります。値が同時実行の境界を越えて渡される方法を変えるborrowingconsumingパラメータ修飾子を理解することは、今後のSwift開発において重要です。