Swift Digest
SE-0282 | Swift Evolution

Clarify the Swift memory consistency model

Proposal
SE-0282
Authors
Karoy Lorentey
Review Manager
Joe Groff
Status
Implemented (Swift 5.3)

01 何が問題だったのか

Swift で同期プリミティブ(ロックや並行データ構造、アトミックなカウンタなど)を自前で実装しようとすると、どうしても「複数のスレッドから同じメモリに触るのはいつ安全で、どの書き込みがどの読み取りに対して必ず見えるのか」という 並行メモリモデル(concurrency memory model) の話になります。ところが Swift にはこれまで、そこに踏み込んだ規定が存在しませんでした。

Swift アプリケーションの多くは Dispatch や Foundation の NSLocking を使って同期を行ってきたため、通常の利用ではこの曖昧さは表に出ません。しかし、Swift をシステムプログラミング言語としても使えるようにするには、そうした同期機構そのものを Swift で書けなければなりません。そのためには、低レベルなアトミック操作とメモリ順序(memory ordering)の意味が言語仕様として定まっている必要があります。

C とのインターオペラビリティの欠落

現実問題として、既存の Swift コードはすでに C 由来の並行プリミティブ(Dispatch、POSIX Threads、stdatomic.h など)に深く依存しています。それにもかかわらず、Swift 側のメモリモデルが定義されていないため、

  • C の atomic_load_explicitatomic_thread_fence などを Swift から呼んだとき、それが Swift の変数アクセスに対してどんな順序保証を与えるのか、
  • C のアトミック操作が Swift の Law of Exclusivity(SE-0176 で導入された排他アクセス規則)と衝突しないのか、

といった基本的な疑問に、仕様上の答えがない状態でした。特に後者は深刻で、SE-0176 では unsafe pointer 経由であっても「同じ領域への読み書きアクセスが重なれば排他性違反」と定義されています。C のアトミック操作は 本質的に並行に呼ばれることを前提とした API なので、この規則をそのまま適用すると、アトミック操作を複数スレッドから同時に呼ぶこと自体が未定義動作になってしまいます。

ポインタ化の落とし穴

さらに、Swift 変数をアトミック操作に渡す方法にも落とし穴があります。Swift の &value は C の「アドレス取得演算子」のように見えますが、実際には withUnsafePointer(to:) 相当の処理が走り、

  • それ自体が変数への 書き込みアクセス として扱われる、
  • 得られるポインタが一時コピーを指している可能性があり、呼び出しごとにアドレスが変わり得る、

という2つの性質を持ちます。つまり、次のようなコードは一見自然に見えても、排他性違反かつ「アトミック操作のたびに別の場所を触る」状態になってしまい、原子性すら保証されません。

// BROKEN, DO NOT USE
var counter = AtomicIntStorage() // zero init
DispatchQueue.concurrentPerform(iterations: 10) { _ in
  for _ in 0 ..< 1_000_000 {
    atomicFetchAddInt(&counter, 1)  // Exclusivity violation
  }
}

ライブラリ作者が Swift で安全にアトミックを扱えるようにするには、「並行メモリモデルとしてどの規格に従うのか」「Law of Exclusivity とどう整合させるのか」「ポインタ化のセマンティクスをどう扱うのか」を明確にする必要がありました。

02 どのように解決されるのか

Swift の並行メモリモデルとして、C/C++ 風の弱いメモリモデル(weak concurrency memory model)を採用 することを明文化します。この Proposal は新しい API を導入するものではなく、「C から取り込んだアトミック操作とメモリ順序を Swift から使ったときに、何が保証され、どう書けば安全なのか」を言語仕様として定めるものです。

具体的には次のような立場を取ります。

  • 同じメモリ位置に対する 書き/書き・読み/書きの並行アクセス は、通常は未定義動作のままとする。ただし、すべてのアクセスが所定のアトミック操作を介していれば許容する
  • アトミック操作に付随する メモリ順序制約(memory ordering) や、操作と独立な メモリフェンス によって、スレッドをまたいだアクセスの前後関係を確立できる。
  • memory_order_consume は C/C++ の現行コンパイラでも実装されていないため、Swift でも扱いを規定せず、利用を推奨しない。当面は relaxed / acquire・release / sequentially consistent の順序を使う。
  • 将来的には Swift ネイティブなアトミック API が導入されうるが、本 Proposal のスコープ外。当面は C ラッパを介した swift-atomics パッケージ のような形で提供される。

Law of Exclusivity の修正

Swift のメモリモデルが C と整合するよう、SE-0176 の Law of Exclusivity を次のように緩めます。

同じ変数への 2 つのアクセスは、両方が読み取りであるか、または両方が atomic access であるとき にのみ重なってよい。

ここで言う atomic access は、C 標準ライブラリの以下の関数群の呼び出しと定義されます。

atomic_flag_test_and_set         atomic_flag_test_and_set_explicit
atomic_flag_clear                atomic_flag_clear_explicit
atomic_store                     atomic_store_explicit
atomic_load                      atomic_load_explicit
atomic_exchange                  atomic_exchange_explicit
atomic_compare_exchange_strong   atomic_compare_exchange_strong_explicit
atomic_compare_exchange_weak     atomic_compare_exchange_weak_explicit
atomic_fetch_add                 atomic_fetch_add_explicit
atomic_fetch_sub                 atomic_fetch_sub_explicit
atomic_fetch_or                  atomic_fetch_or_explicit
atomic_fetch_xor                 atomic_fetch_xor_explicit
atomic_fetch_and                 atomic_fetch_and_explicit

同じメモリ位置に対する 2 つの操作がこれらのいずれかを通じて行われている限り、アクセスが時間的に重なっても排他性違反にはなりません。一方、atomic と非 atomic のアクセスが混在した場合は、たとえ時間的に重ならなくても未定義動作 です(ただしストレージの初期化・破棄中のアクセスは例外で、常に非 atomic として扱われます)。

この修正は既存の実装上の振る舞いを追認するものであり、コンパイラの排他性チェックや Thread Sanitizer の挙動を変更する必要はありません。unsafe pointer 経由のアクセスはそもそもランタイム検査の対象外であり、Thread Sanitizer はすでに C 互換のメモリモデルを前提に Swift コードを解析しているためです。

ライブラリ作者が注意すべきこと

C のアトミック API を Swift から安全に使うためには、Swift 固有の2つの性質を踏まえる必要があります。これらは新しい制約ではなく、既存の Swift のセマンティクスから自然に導かれるものですが、アトミックと組み合わせると間違えやすい論点です。

瞬間的でないアクセスとの相互作用

Swift では、mutating メソッド呼び出しや inout 引数、setter の呼び出しなどが 瞬間的ではないアクセス として扱われ、呼び出しのあいだずっと対象変数への書き込みアクセスが続いているとみなされます。メモリ順序制約はアトミック操作そのものと重ならないアクセスにしか効かないため、「中身をロックで守れば mutating メソッドをスレッドセーフにできる」ということはありません

次のコードは一見すると NSLock で守られているように見えますが、atomicIncrement() の呼び出し自体が i への長い書き込みアクセスであり、同時に複数スレッドから呼ばれた時点で排他性違反となり、未定義動作になります。

import Dispatch
import Foundation

let _mutex = NSLock()

extension Int {
  mutating func atomicIncrement() { // BROKEN, DO NOT USE
    _mutex.lock()
    self += 1
    _mutex.unlock()
  }
}

var i: Int = 0
DispatchQueue.concurrentPerform(iterations: 10) { _ in
  for _ in 0 ..< 1_000_000 {
    i.atomicIncrement()  // Exclusivity violation
  }
}

同じ問題は、nonmutating でない setter や、関数の inout 引数にも当てはまります。一方、クラスのような参照型のメソッドは自身を mutating と宣言しなくてもインスタンス変数を書き換えられるため、この制約の対象外です(当然ながらメソッド内部でプロパティごとの排他性は守る必要があります)。

暗黙のポインタ変換との相互作用

Swift の &value は C の address-of 演算子のように見えますが、実体は withUnsafePointer(to:) 相当の操作で、その呼び出し中だけ有効な 一時的なポインタ を生成します。Swift 変数は必ずしもメモリ上に安定した場所を持たないため、毎回異なるアドレスになることもあります。

そのため、アトミック用のストレージを Swift 変数として宣言して &storage をアトミック関数に渡す書き方は誤りです。アクセスが重なる(= 排他性違反)うえに、スレッドごと・ループの反復ごとに別のアドレスを触ってしまい、原子性そのものが壊れます。

並行処理の範囲がひとつのコードブロックに収まっている場合は、withUnsafeMutablePointer(to:)単一のポインタ をブロック内に共有させることで正しく動かせます。

var counter = AtomicIntStorage() // zero init
withUnsafeMutablePointer(to: &counter) { pointer in
  DispatchQueue.concurrentPerform(iterations: 10) { _ in
    for _ in 0 ..< 1_000_000 {
      atomicFetchAddInt(pointer, 1) // OK
    }
  }
  print(atomicLoadInt(pointer)) // 10_000_000
}

スレッドの寿命が単一ブロックに収まらない場合は、動的にメモリを手で確保するか、ManagedBuffer の内部ストレージを利用して 安定したアドレス を得るのが正攻法です。Swift 変数の & 変換は、アトミック対象のストレージを渡す手段としては使わないでください。

直接 C のアトミックを呼ばず、ラッパを挟む

本 Proposal では C のアトミック API を Swift から直接呼ぶこと自体は許されますが、実用上は薄い Swift ラッパを介することを推奨 しています。プロトタイプ実装として swift-atomics パッケージ が提供されており、初版 Proposal に含まれていた Swift ネイティブ風の API をそちらで試すことができます。

Future Directions

speculative な見通しとして、Proposal では次の方向性が示されています。いずれも本 Proposal のスコープ外で、実現を約束するものではありません。

  • Swift 標準ライブラリにネイティブなアトミック操作を導入し、atomic access の定義にそれを追加する。
  • 並行データ構造の効率的な走査を支える memory_order_consume 相当の dependency chain を Swift でどう扱うかを、別 Proposal で検討する。