Low-Level Atomic Operations
01 何が問題だったのか
Swiftはアプリケーションレベルのコードに対しては async/await、Task、actor、AsyncSequence、Dispatch、Foundationのロックなど、ミュータブルな状態へのアクセスを同期する高水準な手段を提供してきました。しかし、Swiftをシステムプログラミング言語として使ううえでは、これらの高水準な同期構成要素自体をSwiftで実装するための 低レベルの同期プリミティブ が必要です。具体的には、
- 複数スレッドから同時にアクセス・更新しても未定義動作にならない型
- CPU命令レベルの挙動と紐づいた、明示的なメモリオーダリング(
std::memory_order_*に相当するもの)
の二つが揃った仕組みが不可欠です。これらは lock-free なデータ構造の実装や、型を @unchecked Sendable にするうえで内部のミュータブルな状態を保護する目的にも使えます。
Swiftには従来そのような公式APIが標準ライブラリに存在せず、実験的に swift-atomics パッケージ が設計・検証されてきた段階でした。また、SE-0282 でSwiftのメモリ整合性モデル自体はC/C++に合わせて明確化されていましたが、それを使うためのAPIは未整備でした。
アトミックAPIの設計には、Swift固有の難しさもありました。
- アトミック値は「単一の安定したメモリ位置」にある必要があり、コピーされてはいけません。
var束縛にするとSwiftの排他性チェック(Law of Exclusivity)により動的な排他性チェックが挿入され、「純粋なアトミック操作」の保証が崩れてしまいます。- メモリオーダリングは性能に直結するため、実行時にswitchで分岐するのではなく、コンパイル時に単一のCPU命令に畳み込まれる必要があります。
標準ライブラリには、こうした制約を言語機能で表現したうえで、lock-free な実装を全プラットフォームで保証する公式のアトミックAPIが求められていました。
02 どのように解決されるのか
標準ライブラリに新しいモジュール Synchronization を追加し、その中に低レベルのアトミック操作APIを導入します。アトミックは多くのSwiftプログラムでは直接使わないため、デフォルトの名前空間には入れず、利用側で明示的に import します。
import Synchronization
import Dispatch
let counter = Atomic<Int>(0)
DispatchQueue.concurrentPerform(iterations: 10) { _ in
for _ in 0 ..< 1_000_000 {
counter.wrappingAdd(1, ordering: .relaxed)
}
}
print(counter.load(ordering: .relaxed))
メモリオーダリング
アトミック操作には、他スレッドから見た前後のメモリアクセスの可視順序を制御するためのメモリオーダリングを 常に明示的に 渡します。C++の std::memory_order_* と対応する5種類が導入されます。
| C++ | Swift |
|---|---|
memory_order_relaxed |
.relaxed |
memory_order_acquire |
.acquiring |
memory_order_release |
.releasing |
memory_order_acq_rel |
.acquiringAndReleasing |
memory_order_seq_cst |
.sequentiallyConsistent |
オーダリングは操作の種類ごとに別の型(AtomicLoadOrdering / AtomicStoreOrdering / AtomicUpdateOrdering)で提供され、「releasingなload」のような組み合わせはコンパイルエラーになります。
オーダリング引数はコンパイル時定数でなければなりません。これは、内部実装が switch でオーダリングごとのビルトインを呼び分ける構造になっているため、最適化で単一のCPU命令に畳み込むうえで必須の条件です。定数でない式を渡すとコンパイルエラーになります。
特定のアトミック操作に紐づかない独立したメモリフェンスとして atomicMemoryFence(ordering:) も提供されます(C++の std::atomic_thread_fence 相当)。
Atomic<Value> 型
すべてのアトミック値は単一の型 Atomic<Value> から使います。
public struct Atomic<Value: AtomicRepresentable>: ~Copyable {
public init(_ initialValue: consuming Value)
}
~Copyable で、内部的にはアトミックのためのストレージ領域と同じレイアウトを持ちます。Value: Sendable のときに Sendable です。
基本操作は load / store / exchange と、3種の compareExchange(強い版/成功・失敗でオーダリングを分ける版/spurious failureを許す weakCompareExchange)です。
let state = Atomic<Int>(0)
state.store(1, ordering: .releasing)
let v = state.load(ordering: .acquiring)
let (exchanged, original) = state.compareExchange(
expected: 1,
desired: 2,
ordering: .acquiringAndReleasing
)
compareExchange は「現在値が expected と等しければ desired に置き換える」を1トランザクションで行う汎用プリミティブで、これだけで他のすべてのアトミック操作を表現できます。weakCompareExchange は稀に偽の失敗を返す代わりに、ループで呼ぶ用途では高速です。
標準の固定幅整数(Int、UInt8 など)と Bool には、CPUに専用命令があることが多い操作が追加で用意されます。
- 整数:
wrappingAdd/wrappingSubtract/add/subtract(後者2つはオーバーフローでトラップ)/bitwiseAnd/bitwiseOr/bitwiseXor/min/max Bool:logicalAnd/logicalOr/logicalXor
いずれも (oldValue, newValue) を返す @discardableResult です。
let counter = Atomic<Int>(0)
counter.wrappingAdd(42, ordering: .relaxed)
let oldMax = counter.max(82, ordering: .relaxed).oldValue
AtomicRepresentable / AtomicOptionalRepresentable
Atomic で使える型は AtomicRepresentable に適合した型です。標準の整数型、Bool、Float16 / Float / Double、Duration、Never、各種 Unsafe*Pointer、Unmanaged、OpaquePointer、ObjectIdentifier、および新しく追加される WordPair などが適合します(32bit環境では Int64 など一部が除外されます)。
Optional は Wrapped: AtomicOptionalRepresentable のとき AtomicRepresentable になります。こちらはポインタ系と参照系の型が適合しており、Atomic<UnsafeMutablePointer<Node>?> のような lock-free データ構造でよくある形がそのまま書けます。
RawRepresentable 型にもデフォルト実装が提供されるため、raw値がアトミックな列挙型はそのまま AtomicRepresentable に適合宣言するだけで使えます。
enum MyState: Int, AtomicRepresentable {
case starting
case running
case stopped
}
let currentState = Atomic<MyState>(.starting)
if currentState.compareExchange(
expected: .starting,
desired: .running,
ordering: .sequentiallyConsistent
).exchanged {
// 開始処理
}
currentState.store(.stopped, ordering: .sequentiallyConsistent)
WordPair
単一ワードのアトミックポインタはABA問題(解放と再割り当てで同じアドレスが返り、載せ替えに気付けない問題)に弱いため、2ワードを1トランザクションで扱える型 WordPair が追加されます。64bit環境では128bit、32bit環境では64bitのストレージを持ち、「値+世代カウンタ」のような用途で使えます。double-word atomicsを持たないプラットフォームでは AtomicRepresentable 適合は提供されません。
AtomicLazyReference<Instance>
クラス参照の一般的なアトミック操作は、load と retain を1トランザクションで行う必要があり、メモリ回収の難しさから本提案のスコープ外です。代わりに、一度だけ初期化できる遅延参照として AtomicLazyReference<Instance> が提供されます。
public struct AtomicLazyReference<Instance: AnyObject>: ~Copyable {
public init()
public borrowing func storeIfNil(_ desired: consuming Instance) -> Instance
public borrowing func load() -> Instance?
}
値は初期状態で nil で、storeIfNil による最初の書き込みだけが成功し、それ以降の呼び出しは引数を捨てて現在値を返します。スレッドセーフな遅延初期化が書けます。
let _foo: AtomicLazyReference<Foo> = .init()
nonisolated var atomicLazyFoo: Foo {
if let foo = _foo.load() { return foo }
// 複数スレッドが同時にここに入ることがあるが、
// 最終的に採用される Foo インスタンスは一つだけで、
// それ以外はすぐに破棄される
let foo = Foo()
return _foo.storeIfNil(foo)
}
この型だけは load / storeIfNil にオーダリング引数がなく、内部的に acquiring / releasing が固定で使われます。
var / inout / mutating の禁止
Atomic と AtomicLazyReference は、「単一の安定した位置にある値」であることをコンパイル時に保証するため、次のような使い方がコンパイルエラーになります。
// error: variable of type 'Atomic<Int>' must be declared with a 'let'
var myAtomic = Atomic<Int>(123)
// error: parameter of type 'Atomic<Int>' must be declared as either 'borrowing' or 'consuming'
func passAtomic(_: inout Atomic<Int>)
extension Atomic {
// error: type 'Atomic' cannot have mutating function 'greet()'
mutating func greet() { ... }
}
これは var 束縛だとSwiftの動的排他性チェックが入ってアトミック操作の純粋性が損なわれること、inout や mutating は呼び出し中に排他アクセスを宣言してしまい「アトミック」ではなくなることを避けるための制約です。Atomic 値を別の関数やアクターに渡すときは borrowing または consuming を使います。
actor Updater {
func update(_ counter: borrowing Atomic<Int>) { ... }
}
計算プロパティも Atomic を直に返せません(毎回新しいインスタンスを返すことになるため)。必要なら初期値だけを返すプロパティにしておき、呼び出し側で Atomic を作るか、ファクトリ関数にします。
今後の見通し
本提案は意図的にスコープを限定しており、次のような拡張は将来の提案に委ねられています(speculativeであり、実現を約束するものではありません)。
- 一般的なクラス参照のアトミック版
AtomicReference(swift-atomicsパッケージ相当の、メモリ回収方式を伴う実装) - 浮動小数点数向けの追加アトミック操作や、consumingオーダリング、tearable atomics、volatile atomics など