一日目: Swift 6の並行性とSendable
Swift 6は、言語史上最も重要な欠落を埋めた。データ競合の安全性を コンパイル時に保証する仕組み、それがSendableプロトコルである。 このマーカープロトコルに準拠した型だけが、タスクやアクター間、 あるいは隔離領域を越えて安全に値を受け渡しできる。コンパイラは これを静的に検証する。Sendableなプロパティのみで構成された型は 暗黙的に準拠し、そうでない型には明示的な準拠宣言が必要となる。
これは、ランタイムでのデバッグ(スレッドサニタイザ、ハイゼンバグとの格闘) からコンパイル時での正しさの証明へと、責任の所在を根本的に移す。 特に、可変な共有状態が当然だった既存コードベースにとって、 この移行は急峻な学習曲線をもたらす。
Sendableの本質
値型(構造体、列挙型)は、そのすべての格納プロパティがSendableであれば 自動的にSendableとみなされる。一方、クラスは自動的にはSendableにならない。finalかつ不変な格納プロパティのみで構成されるか、@unchecked Sendableで明示的にオプトインする必要がある。
準拠 / 非準拠
// 良: Sendableな部品のみで構成された構造体 → 暗黙的にSendable
struct AFMPreset: Sendable {
let name: String
let parameters: [String: Float]
}
// 悪: 可変状態を持つ非Sendableなクラス
// このインスタンスは並行性の境界を越えられない
class AFMConfiguration {
var frequency: Float = 440.0
var amplitude: Float = 0.5
var waveform: String = "sine"
var modulation: [String: Float] = [:]
var presetName: String = "default"
var isActive: Bool = false
var lastModified: Date = Date()
var tags: [String] = []
func applyModulation(_ params: [String: Float]) {
modulation.merge(params) { _, new in new }
}
}
// 修正: 安全にする - 隔離するか保護する
final class SafeAFMConfiguration: @unchecked Sendable {
private let lock = NSLock()
private var _frequency: Float = 440.0
var frequency: Float {
get { lock.lock(); defer { lock.unlock() }; return _frequency }
set { lock.lock(); defer { lock.unlock() }; _frequency = newValue }
}
}
上記のAFMConfigurationクラスは、Swift 6以前の典型的なパターンだ。 共有され、可変で、完全にスレッドセーフではない。Dateや[String]のような非Sendableなプロパティを保持し、 すべての格納プロパティがvarで宣言されており、 二つのスレッドが同時にfrequencyとtagsに 書き込むことを何も防いでいない。Swift 6のコンパイラは、 これをアクター境界の向こう側に渡そうとするあらゆる試みを拒否する。
安全なバージョンではNSLockでアクセスを保護し、finalでサブクラス化を防ぎ、@unchecked Sendableで 明示的にオプトインしている。同期の責任が自分たちにあることを 受け入れた上での選択だ。
解法: AFMConfigurationをSendableにする三つの道
非SendableなAFMConfigurationをSwift 6の世界で 安全に扱うための三つの設計パターン。それぞれに異なる哲学的基盤がある。
値型は並行性の基本単位である。構造体のインスタンスはコピーされて渡され、 元のインスタンスと宛先のインスタンスが互いに影響を与えることはない。 すべてのプロパティが不変(let)でSendableであれば、 構造体そのものも自動的にSendableとなる。これは最も簡潔で、 コンパイラの推論に最も依存する解法だ。
構造体によるAFMConfiguration
// すべてのプロパティがlet + Sendable → 暗黙的にSendable
struct AFMConfiguration: Sendable {
let frequency: Float
let amplitude: Float
let waveform: String
let modulation: [String: Float]
let presetName: String
let isActive: Bool
let lastModified: Date
let tags: [String]
func applyingModulation(_ params: [String: Float]) -> AFMConfiguration {
AFMConfiguration(
frequency: frequency,
amplitude: amplitude,
waveform: waveform,
modulation: modulation.merging(params) { _, new in new },
presetName: presetName,
isActive: isActive,
lastModified: lastModified,
tags: tags
)
}
}
値型であるため、ヒープ上での共有が発生しない。コピーは独立しており、 並行タスクに渡してもデータ競合の可能性はゼロだ。 ただし、変更のたびに新しいインスタンスを生成する必要があり、 大規模なデータ構造ではパフォーマンスへの配慮が求められる。 このアプローチは「変更よりもコピー」という関数型の思想に根ざしている。
アクターは自身の状態を直列化されたキューで保護する。 外部からはawaitを介してのみアクセス可能であり、 コンパイラがデータ競合を防止する。ここでは入れ子のSendableなSnapshot構造体を定義し、アクターの外部に安全な 値のスナップショットを取り出せるようにする。
アクター + スナップショットパターン
actor AFMConfiguration {
var frequency: Float = 440.0
var amplitude: Float = 0.5
var waveform: String = "sine"
var modulation: [String: Float] = [:]
var presetName: String = "default"
var isActive: Bool = false
var lastModified: Date = Date()
var tags: [String] = []
// 安全な値抽出のためのスナップショット
struct Snapshot: Sendable {
let frequency: Float
let amplitude: Float
let waveform: String
let presetName: String
let isActive: Bool
}
func takeSnapshot() -> Snapshot {
Snapshot(
frequency: frequency,
amplitude: amplitude,
waveform: waveform,
presetName: presetName,
isActive: isActive
)
}
}
// 複数のAFMアクターを管理するマネージャーアクター
actor AFMModelManager {
private var configurations: [String: AFMConfiguration] = [:]
func register(_ id: String, config: AFMConfiguration) {
configurations[id] = config
}
func snapshot(for id: String) -> AFMConfiguration.Snapshot? {
await configurations[id]?.takeSnapshot()
}
func updateFrequency(_ id: String, _ frequency: Float) {
await configurations[id]?.frequency = frequency
}
}
アクターは共有された可変状態に対する最も自然な解決策だ。 しかし、アクターの内部状態を外部で参照したい場合、毎回awaitで個別のプロパティにアクセスするのは非効率である。 スナップショットパターンは、その瞬間の一貫した状態を Sendableな値として切り出すことで、この問題を解決する。AFMModelManagerもアクターとして定義することで、 設定全体の管理までもが並行安全性の傘の下に入る。
すべてのプロパティをletで宣言したfinalクラスは、 構造体と同じく不変性を保証する。ヒープ上に存在するため参照の共有は 発生するが、値が不変であればデータ競合は起こりえない。 ここでは.with()メソッドによるビルダーパターンを導入し、 不変インスタンスの「変更」を表现する。
不変クラス + withビルダー
final class AFMConfiguration: Sendable {
let frequency: Float
let amplitude: Float
let waveform: String
let modulation: [String: Float]
let presetName: String
let isActive: Bool
let lastModified: Date
let tags: [String]
init(
frequency: Float = 440.0,
amplitude: Float = 0.5,
waveform: String = "sine",
modulation: [String: Float] = [:],
presetName: String = "default",
isActive: Bool = false,
lastModified: Date = Date(),
tags: [String] = []
) {
self.frequency = frequency
self.amplitude = amplitude
self.waveform = waveform
self.modulation = modulation
self.presetName = presetName
self.isActive = isActive
self.lastModified = lastModified
self.tags = tags
}
func with(
frequency: Float? = nil,
amplitude: Float? = nil,
waveform: String? = nil,
modulation: [String: Float]? = nil,
presetName: String? = nil,
isActive: Bool? = nil,
tags: [String]? = nil
) -> AFMConfiguration {
AFMConfiguration(
frequency: frequency ?? self.frequency,
amplitude: amplitude ?? self.amplitude,
waveform: waveform ?? self.waveform,
modulation: modulation ?? self.modulation,
presetName: presetName ?? self.presetName,
isActive: isActive ?? self.isActive,
lastModified: Date(),
tags: tags ?? self.tags
)
}
}
// 使用例
let defaultConfig = AFMConfiguration()
let updatedConfig = defaultConfig.with(frequency: 880.0, isActive: true)
.with()パターンは、変更したいプロパティだけを 名前付き引数で指定し、残りは元のインスタンスの値を引き継ぐ。 SwiftUIのButtonやTextのビルダーと 同じ発想だ。このパターンにより、不変性を保ちながらも 直感的なAPIを提供できる。
不変なAFMConfigurationを管理するマネージャーには、 二つの設計が考えられる。どちらを選ぶかは、このクラスが どの隔離領域からアクセスされるかに依存する。
アプローチA: NSLock + @unchecked Sendable
マルチスレッド環境の古典的解法。NSLockで 内部状態へのアクセスを直列化し、クラス自体を@unchecked Sendableでマークする。 明示的なロック管理が必要だが、きめ細かい制御が可能だ。
NSLockによる手動同期
final class AFMManager: @unchecked Sendable {
private let lock = NSLock()
private var configurations: [String: AFMConfiguration] = [:]
func register(_ id: String, config: AFMConfiguration) {
lock.lock()
configurations[id] = config
lock.unlock()
}
func configuration(for id: String) -> AFMConfiguration? {
lock.lock()
defer { lock.unlock() }
return configurations[id]
}
func update(_ id: String, with builder: (AFMConfiguration) -> AFMConfiguration) {
lock.lock()
if let existing = configurations[id] {
configurations[id] = builder(existing)
}
lock.unlock()
}
}
アプローチB: @MainActorによる隔離
現代のAppleエコシステムで推奨される方法。@MainActorで クラス全体をメインスレッドに隔離する。手動のロックは不要で、 コンパイラがアクセスを保証する。SwiftUIのビューの多くが すでに@MainActor上で動作するため、自然な親和性がある。
@MainActorによる自動隔離
@MainActor
final class AFMManager {
private var configurations: [String: AFMConfiguration] = [:]
func register(_ id: String, config: AFMConfiguration) {
configurations[id] = config
}
func configuration(for id: String) -> AFMConfiguration? {
configurations[id]
}
func update(_ id: String, with builder: (AFMConfiguration) -> AFMConfiguration) {
if let existing = configurations[id] {
configurations[id] = builder(existing)
}
}
}
// SwiftUI Viewからの利用
struct ConfigEditor: View {
let manager: AFMManager
var body: some View {
Text("周波数: \(manager.configuration(for: "main")?.frequency ?? 440)")
}
}
アプローチAはロジックが明白で、どのスレッドからでも呼び出せる柔軟性がある。 一方アプローチBは、コードがより簡潔になり、SwiftUIとの統合が シームレスだ。プロジェクトがすでにSwiftUIを中心に構築されているなら、@MainActorが自然な選択となる。一方、バックグラウンドでの 大量処理が必要な場面では、NSLockを用いた明示的な管理の方が 適していることもある。
どのパターンを選ぶにせよ、一貫性が最も重要だ。プロジェクト全体で 同じ戦略を採用し、隔離領域の境界を明確にすることで、 Swift 6の並行性モデルは強力な味方となる。