Skip to content

Instantly share code, notes, and snippets.

@S-Shimotori
Last active February 22, 2025 13:31
Show Gist options
  • Save S-Shimotori/9d511d10a8a2f79b072d526087001fdb to your computer and use it in GitHub Desktop.
Save S-Shimotori/9d511d10a8a2f79b072d526087001fdb to your computer and use it in GitHub Desktop.
⚠️「Synchronizationで学ぶSwift(仮)」Preview版です⚠️内容が間違っているかもしれません⚠️もっと上手い説明があるかもしれません⚠️

Synchronizationフレームワークとは何者でしょうか?普段アプリ開発をしているときに使ったことはなく、名前を聞いた覚えすらない人がほとんどでしょう。

実はWWDC24のWhat's new in Swiftの最後で少しだけ触れられていますが、それだけです。Migrate your app to Swift 6のほうには登場しません。
What’s new in Swift - WWDC24 - Videos - Apple Developer
Migrate your app to Swift 6 - WWDC24 - Videos - Apple Developer

第1部ではSynchronizationの使い方を学んでいきます。まずは本章でSynchronizationフレームワークそのものについて紹介します。

Synchronizationとは

WWDC24のWhat's new in Swiftで取り上げられていることからもわかるように、Synchronizationは2024年からの新しいフレームワークです。しかし動画を見てもほとんど解説はないのでドキュメントをあたりましょう。ドキュメントには次のように書かれています:

Build synchronization constructs using low-level, primitive operations.

出典:Synchronization | Apple Developer Documentation

ドキュメントを直訳すると
「ローレベルでプリミティブな操作を用いて同期機構を構築する。」
です。Synchronizationが扱うのはローレベル(低水準)でプリミティブ(原始的/素朴/未熟)な操作です。後の章で紹介するように、ローレベルな操作をなるべくシンプルに扱っています。

【募集】低水準とか高水準といった概念がわかる記事または本

このSynchronizationはとても小さなフレームワークです。 AtomicMutex の2つの構造体、 Atomic に関連する列挙型くらいしかありません。Synchronizationのドキュメントの目次もSwiftコンパイラの実装も短く小さくまとまっています。

この AtomicMutex は他の言語にも存在する一般的なものです。詳細を知りたい方は、並行処理分野の本のほか、Swift以外の言語の記事も参考にしてください。

ちなみに、iOSやmacOSなどで使えるosフレームワークにもSynchronizationというAPI群がありますが、それとは別物です。
Synchronization | Apple Developer Documentation
※後に紹介するように無関係ではありません。

Synchronizationを使う

SynchronizationはSwiftコンパイラに同梱されているフレームワークです。Swift Package Managerなどで追加のダウンロードをする必要はありません。Appleプラットフォーム以外の環境、具体的にはLinuxやWindowsやWebAssemblyでも使うことができます。

ただし AtomicMutex を使うには import Synchronization をする必要があります。 AtomicMutex は低水準なので標準の名前空間( Swift )には追加されませんでした。

Swift.Atomic // ❗️ Module 'Swift' has no member named 'Atomic'

【募集】Swiftの名前空間がわかる記事または本

Swift AtomicsからSynchronizationへ

SwiftにはすでにSwift Atomicsというライブラリがあります。名前の通り Atomic だけが収録されていて、 Mutex はありません。こちらを使うにはSwift Package Managerが必要です。
apple/swift-atomics: Low-level atomic operations for Swift

Atomic はいきなりSwift言語に収録されたのではなく、外付けのライブラリとして試作するところから始まりました。後継であるSynchronizationの Atomic (SE-0410「Low-Level Atomic Operations ⚛︎」)のレビューにはswift-atomicsの話が何度も登場します。

参考:Swiftをもっと知るために役に立つリポジトリSwift-Evolutionのご紹介 - Speaker Deck

両者の機能を比較するとSynchronizationでは使用時の制約が強くなっているように感じます。これは、安全のための厳しい制約を設けられるようになった、というのが正確です。Swift Atomicsの最初のリリースは2020年10月ですが、Synchronizationを実現するための機能や制約が使えるようになったのはもっと後になります。

参考:Release Atomics 0.0.1 · apple/swift-atomics

機能 効果 使えるようになった頃
~Copyable コピー不可 2023年9月(Swift 5.9)
@_rawLayout raw storage 2023年7月ごろ?
@_staticExclusiveOnly let束縛 2023年11月ごろ?

それぞれの詳細は第2部で紹介します。

本章のまとめ

本章ではSynchronizationと前身のSwift Atomicsについて紹介しました。Synchronizationのほうはimport文さえあればお手元のPlaygroundなどでも気軽に試すことができます。

次章からSynchronizationの AtomicMutex について紹介していきます。

さっそく本章から Atomic を見ていきます。 MutexAtomic の後に紹介します。

Atomic のドキュメントには次のように書かれています:

An atomic value.

出典:Atomic | Apple Developer Documentation

直訳すると
「アトミック性のある値」
になります。

本章ではアトミック性そして Atomic がどういったものか例題を用いて説明します。

例題:100万×10回インクリメントする!

100万回インクリメントする処理を並行して10回行うことを考えます。100万×10で1000万になればOKです。SE-0410 Low-Level Atomic Operations ⚛︎のサンプルソースコードのお題です。

次のソースコードは1000万にならない実装です。試す場合はSwift 5で試してください。Concurrency Checkでエラーになってしまいます。

import Dispatch

var counter = 0

DispatchQueue.concurrentPerform(iterations: 10) { _ in
    for _ in 0 ..< 1_000_000 {
        counter += 1
    }
}

print(counter)

ここでは DispatchQueueconcurrentPerform(iterations:execute:) を使用しています。渡したものを同時に iterations 回実行してくれるメソッドです。
出典:concurrentPerform(iterations:execute:) | Apple Developer Documentation

これを実際に実行してみます。XcodeのDebug Navigatorを見ると、複数のスレッドで実行されていることを確認できます。

ところがカウントの結果は450万〜550万程度にしかなりません。次のスクリーンショットはSwift Testingで実行した結果です。4737604回分のインクリメント作業しか反映されておらずテスト失敗となっています。

残りの5262396回分はどこに行ってしまったのでしょうか?どうすればきちんと1000万回数えられるのでしょうか。

原因

counter += 1

こちらの += 1 は人間の目にはひとつの処理に見えますが、実際には

  1. 現在の値を読む
  2. 新しい値(ステップ1で得た値に 1 を足したもの)に書き換える

の2つの処理からなっています。次のソースコードは整数の += の実装です。

public static func +=(lhs: inout Self, rhs: Self) {
    lhs = lhs + rhs
}

出典:swift/stdlib/public/core/Integers.swift at 80190ad5397c7cf3bfe07ac21d3aa550679e9afd · swiftlang/swift

引数で受け取った lhs から現在の値を読み、 rhs と足し合わせ、その結果を新しい値として書き込んでいます。

次の図は1つのスレッドで「現在の値を読む」「新しい値(+1した値)を書く」を実行したときのタイムラインです。上段はスレッドが += 1の操作を何度も繰り返していることを表します。下段は counter に対する操作と値の変化を表したものです。「読む」「書く」が一体となっていないというのがポイントです。

今回は concurrentPerform(iterations:execute:) により並行してインクリメント処理を行っています。複数のスレッドが一斉に counter の値を読んだり書き換えたりします。その結果「スレッドAが現在の値を読む」「スレッドBが現在の値を読む」「スレッドAが新しい値に書き換える」「スレッドBが新しい値に書き換える」などお互いがお互いの処理に割り込んでしまうことがあります。すると

  1. スレッドAが現在の値(例えば14)を読む
  2. スレッドBが現在の値(14)を読む
  3. スレッドAが新しい値(15)を書き込む
  4. スレッドBが新しい値(15)を書き込む

という処理になります。スレッドAによるインクリメント1回とスレッドBによるインクリメント1回をあわせて14+1+1=16になってほしいのに15になってしまいました。同じ変数に読込と書込が殺到すると順番が入り乱れ、その結果として一部のインクリメント処理がなかったことになってしまうわけです。

今回の例題では1000万回のうち500万回分ものインクリメント処理が失われていて、影響がとても大きいことがわかります。

解決編

1000万にならない原因は counter という共有の資源に読込と書込が殺到して順番が入り乱れてしまうことにありました。「現在の値を読む」→「新しい値に書き換える」は必ず連続して処理してほしいです。

この問題の解決策(そしてComplete Concurrency Checkの面でも問題ない方法)として次の2つがあります。

  • 1000万回のインクリメントを1つずつ順番に実行する
  • 「現在の値を読む」→「新しい値を書き込む」の部分だけは必ず連続で実行する

1000万回のインクリメントを1つずつ順番に実行する

この問題は一斉に共有資源を読み書きしたことが原因なので、1度に1回ずつ落ち着いて処理すれば問題ありません。

これはアクターを使えば簡単に実現できます。

actor MyCounter {
    private(set) var value = 0

    func increment() {
        value += 1
    }
}

func count() async {
    let counter = MyCounter()

    await withTaskGroup(of: Void.self) { group in
        for _ in 0 ..< 10 {
            group.addTask {
                for _ in 0 ..< 1_000_000 {
                    await counter.increment()
                }
            }
        }
        await group.waitForAll()
    }

    print(await counter.value)
}

一度に1つのタスクしかアクターを操作できないので「現在の値を読んで+1した値を書き込む」「現在の値を読んで+1した値を書き込む」……と正確にインクリメント処理を進めることができます。

参考:Swift Concurrency チートシート

タイムラインで表すとこんな感じ?

図中では3度 increment() を呼んでいますが、アクターの仕様上アクターのメソッドが同時に実行されることはありません。つまり読み書きが同時に実行されてカウントを誤ることもありません。

参考:事例別!Strict Concurrency対応方法

「現在の値を読む」→「新しい値に書き換える」の部分だけは必ず連続で実行する

アクターの例では increment() メソッド同士が被らないように管理していましたが、 increment() 全体が被らないようにする必要はありません。「現在の値を読む」と「新しい値を書き込む」の間に割り込みがないよう保護すればそれで十分です。「現在の値を読んで+1した値を書き込む」の部分を一気に行い、読込と書込の間に他者が割り込まないようにします。もし処理中に他のインクリメント処理が来たら完了するまで待たせます。

分割できない一連の処理、割り込むことができない一連の処理、完了するまで外から途中の状態をうかがいしれない一連の処理のことを「アトミック性(原子性)がある」といいます。「現在の値を読んで+1した値を書き込む」にアトミック性を持たせれば1000万回正しく数え上げることができます。

Synchronizationの Atomic にはそのような機能があります。アトミックという名前の通りです。サンプルソースコードを見てみましょう:

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))

出典:swift-evolution/proposals/0410-atomics.md at 821970ae986219f88eb3f950ed787a55ce31d512 · swiftlang/swift-evolution

Int の代わりに Atomic<Int> を使い、 += 1 の代わりに wrappingAdd(_:ordering:) を使っています。5章で紹介するように、 wrappingAdd(_:ordering:) は「現在の値を読んで+1した値を書き込む」をアトミックに実行するメソッドです。これでお互いの処理に割り込むことがなくなり正しく1000万回数え上げることができます。

タイムラインで表すとこんな感じ。 wrappingAdd(_:ordering:) やそれを呼び出すブロック/タスクが同時に実行されたとしても、 counter: Atomic<Int> に対する読み書きの間に別の操作が割り込むことはありません。

wrappingAdd(_:ordering:) にはアトミック性があって読み書きが一体となっているので、 counter の時系列上では点ひとつで読み&書きの操作を表しておきました。

本章のまとめ

本章ではアトミック性という特性を学び、Synchronizationに Atomic というものがあることを紹介しました。

次章では Atomic の使い方を紹介していきます。

前章ではSynchronizationの Atomic が読込と書込をアトミックに実行できることを紹介しました。さっそくいろんな共有資源を Atomic に与えて使ってみたくなるところですが、 Atomic が扱える型には制限があります。

本章ではアトミック性とCPU命令の関係性について、そしてどんなものなら Atomic が扱えるのかについて紹介します。

アトミック性とCPU命令

読込と書込の間に誰も割り込んでこない、読込と書込が完全に一体となっているなんてすごい話です。果たしてどのようにアトミックな操作を実現しているのでしょうか。

答えはCPU命令です。ARMやx86といったCPUたちがそのような機能を持っていて、アトミックな操作をするための命令が存在します。それをSwift言語から便利に呼べるようにしたのが Atomic です。 Atomic はCPU命令をラッピングしているだけなのです(だけではないですが)。

【募集】CPU命令がわかる記事や本(特にCASとかLL/SC)

しかしそれは、 Atomic で扱えるものがCPU命令で扱える範囲のものに限られるということを意味します。いくらSwiftが表現力豊かになっても扱える範囲は広がりません。CPU命令の限界がそのまま Atomic の限界となります。

CPUが1度に扱えるデータの長さのことをwordと呼びます(他の定義もありますが本書ではそのように定義します)。64bit CPUなら1wordは64bit、32bit CPUなら32bitです。

実をいうとwordは整数型でお馴染みです。普段私たちは整数を扱うときに Int 型を使いますが、これは64bit環境なら Int64 を表し32bit環境なら Int32 を表します。CPUによって扱えるデータ長が整数型にも影響しているわけです。

参考:Int | Apple Developer Documentation

その制約は Atomic にも影響します。64bit環境で Atomic が扱えるデータ長は(基本的には)64bitまでです。32bit環境なら32bitまでです。

以上の理由から、 Atomic は(基本的には)最大1wordの長さのデータを受け取ってアトミック操作をするように設計されています。

Atomic が扱える型

Atomic が(基本的には)最大でも1wordぶんのデータしか受け取らないことを紹介しました。Swift上ではどのように実現しているのかみていきましょう。

Atomic | Apple Developer Documentationには次のように記載があります。

struct Atomic<Value> where Value : AtomicRepresentable

init(_ initialValue: consuming Value)

つまり AtomicRepresentable に準拠した型だけを受け取ってアトミック操作をするようになっています。 AtomicRepresentable がwordと関係あるに違いありません。

さっそくAtomicRepresentable | Apple Developer Documentationをみてみます。

static func decodeAtomicRepresentation(consuming Self.AtomicRepresentation) -> Self

static func encodeAtomicRepresentation(consuming Self) -> Self.AtomicRepresentation

デコードメソッドとエンコードメソッドが出てきました。 AtomicRepresentable に準拠した型とは

  • AtomicRepresentation からデコードできる
  • AtomicRepresentation へエンコードできる

ような型を指していることがわかります。

AtomicRepresentation な型は具体的には次のとおりです。 Atomic にアトミック操作をしてもらいたいなら次の型のいずれかと相互変換できなければいけません。

  • UInt8
  • UInt16
  • UInt32
  • UInt64
  • UInt
  • Int8
  • Int16
  • Int32
  • Int64
  • Int
  • WordPair

たくさんあるように見えてどれも整数ばかりです。整数型のデータ長は(基本的には)1wordを超えないので「アトミック操作できるデータの長さは最長でも1wordまで」というルールが守られます。 WordPair には見覚えがありませんが、名前にwordとあるのでこれもwordを意識しているに違いありません。

【募集】LLVMでの整数型の扱い。この辺かな……?

AtomicRepresentation には Int しかありませんが整数という形式に考えをとらわれる必要はありません。1wordにデータが収まるか収まらないか、ただそれだけです。変換や圧縮をした結果1word以内の長さに収まれば Atomic で扱うことができます。AtomicRepresentable | Apple Developer Documentationには列挙型を対応させる例が載っています。

enum TrafficLight: UInt8 {
    case red
    case yellow
    case green
}

extension TrafficLight: AtomicRepresentable {}

信号の色を表す TrafficLight があります。赤・黄・緑の3つしかないので2進数8桁( UInt8 )もあれば十分です。

標準で Atomic が扱える型

幸い、いくつかの型が標準で AtomicRepresentable に準拠済みで、すぐに Atomic に与えることができます。例えば次の型が対応しています:

  • IntUInt はじめとする整数型
  • Double などの浮動小数点数型
  • Bool
  • Optional
  • UnsafePointer はじめとするポインタ型
  • Never
  • WordPair

【募集】浮動小数点数の仕様がよくわかる本または記事

整数型や浮動小数点数型はもともと1wordに収まるようにしているためアトミック操作で扱えると予想がつきます。 Booltruefalse をたったの1bitで表せるので問題ありません。それ以外の型はどのように対応しているのでしょうか?

ポインタ型

UnsafePointer はじめとするポインタ型はメモリアドレスを扱っています。メモリアドレスは1wordの長さで表されます。つまり問題なく Int 型に収まる(変換する)ことができます。
例えば
0x0000600002c1a580 ( 11000000000000000000010110000011010010110000000 )
というアドレスがあったとしたら、それをそのまま Int 型として Atomic のなかに収めればよいです。

public static func encodeAtomicRepresentation(
    _ value: consuming UnsafePointer<Pointee>
) -> AtomicRepresentation {
    Int.encodeAtomicRepresentation(
        Int(bitPattern: value)
    )
}

出典:swift/stdlib/public/Synchronization/Atomics/AtomicPointers.swift at main · swiftlang/swift

init(bitPattern:) というイニシャライザを使うと適切に Int 型になることができます。

参考:init(bitPattern:) | Apple Developer Documentation

【募集】整数型のメモリ表現がわかる本または記事(単に 0x0000600002c1a580 という数を Int にするだけではエンディアンでアドレスが破壊されちゃうと思う)

Optional

Optional なものを Atomic で扱いたいなら AtomicOptionalRepresentable にも準拠している必要があります。

protocol AtomicOptionalRepresentable : AtomicRepresentable

static func decodeAtomicOptionalRepresentation(consuming Self.AtomicOptionalRepresentation) -> Self?
static func encodeAtomicOptionalRepresentation(consuming Self?) -> Self.AtomicOptionalRepresentation

出典:AtomicOptionalRepresentable | Apple Developer Documentation

標準で AtomicOptionalRepresentable に準拠している型は Unsafe*Pointer などのごく一部だけです。 Int すら準拠していません。

let counter = Atomic<Int?>(0) // ❗️'Atomic' requires that 'Int' conform to 'AtomicOptionalRepresentable'

なぜ Optional<Int> はNGなのに Optional<Unsafe*Pointer> はOKなのでしょうか。それは Optional な値の表し方の違いにあると考えられます。

Int は構造体です。構造体の場合、 Optional を表現するには追加でもう1byte必要になります。この追加の1byteには「 nil かそうでないか」を表す情報が入ります。つまりもともと Int64 だけで1wordのデータを必要とするところさらにデータ長が伸びます。これではアトミック命令で扱えません。

一方ポインタの場合は Optional であろうとなかろうと常に1wordのデータ長です。 nil のことを 0x0000000000000000 (いわゆるヌルポインタ)で表すのでデータ長は1wordから増えません。
UnsafePointer がどのように AtomicOptionalRepresentable のデコード&エンコードを実装しているのか見てみましょう。

public static func encodeAtomicOptionalRepresentation(
    _ value: consuming UnsafePointer<Pointee>?
) -> AtomicOptionalRepresentation {
    Int.encodeAtomicRepresentation(
        Int(bitPattern: value)
    )
}

〜〜中略〜〜

public static func decodeAtomicOptionalRepresentation(
    _ representation: consuming AtomicOptionalRepresentation
) -> UnsafePointer<Pointee>? {
    UnsafePointer<Pointee>(
        bitPattern: Int.decodeAtomicRepresentation(representation)
    )
}

出典:swift/stdlib/public/Synchronization/Atomics/AtomicPointers.swift at e91633e9a1a95da58999c9e617d3366c2cefbd4a · swiftlang/swift

AtomicRepresentable のほうのデコード&エンコードメソッドと実装が同じであることがわかります。そして UnsafePointer のイニシャライザのドキュメントを見返すとヌルポインタと nil についての記述が見つかります。

bitPattern A bit pattern to use for the address of the new pointer. If bitPattern is zero, the result is nil.

出典:init(bitPattern:) | Apple Developer Documentation

直訳すると
「新しいポインタのアドレスとして用いるビットパターン。 bitPattern がゼロなら戻り値は nil になる。」
となります。

参考:Swiftのメモリレイアウトを調べる #Swift - Qiita

Never

Never は性質上変換不要です。

public static func encodeAtomicRepresentation(
    _ value: consuming Never
) -> Never {}

出典:swift/stdlib/public/Synchronization/Atomics/AtomicRepresentable.swift at main · swiftlang/swift

【募集】 Never がわかる本または資料
【謎】なぜ NeverAtomicRepresentable にしたし。SE-0319を読む限りではボトム型になろうとしている気がするが・・

WordPair

CPUが1度に扱えるデータは基本的に1wordを1つだけですが、2wordぶんの長さのデータをアトミック操作できる環境もあります。そのような操作をdouble wide atomics操作といい、その操作に対応するために WordPair が用意されています。

WordPairUInt 型の値を2つ、つまり1word長のデータを2つ受け取って保持します。 WordPair ではそれぞれ firstsecond と呼んでいます。

let atomicPair = Atomic<WordPair>(WordPair(first: 0, second: 0))

出典:WordPair | Apple Developer Documentation

64bit CPUかつdouble wide atomics操作ができる環境の場合、アトミック操作は64×2=128bitのデータを操作できます。 WordPair は2つの UInt64 型の値をつなぎ合わせて128bitの長さのデータにし、その128bitのデータに対してアトミック操作をするよう命じます。

WordPairAtomicRepresentable に準拠している( Atomic に渡すことができる)のはdouble wide atomics操作に対応している環境のときだけです。使える環境なら AtomicWordPair を扱えるようにし、そうでない環境のときに使おうとしたらコンパイルエラーになるとうっかりミスを防げて安心です。 WordPair のドキュメントには次のように書かれています:

Note: This type only conforms to AtomicRepresentable on platforms that support double wide atomics.

出典:WordPair | Apple Developer Documentation

直訳すると
「この型はdouble wide atomicsをサポートしているプラットフォーム上でのみ AtomicRepresentable に準拠する。」
です。つまり extension WordPair: AtomicRepresentable に条件分岐が付属しているはずです。

実装を見に行くと確かにそのような条件分岐があります。

#if (_pointerBitWidth(_32) && _hasAtomicBitWidth(_64)) || (_pointerBitWidth(_64) && _hasAtomicBitWidth(_128))

@available(SwiftStdlib 6.0, *)
extension WordPair: AtomicRepresentable {

出典:swift/stdlib/public/Synchronization/Atomics/WordPair.swift at c2b89508af2c8712c73d5866b556348124cd717d · swiftlang/swift

#if でビット幅に関する条件を設けています。

  • ポインタのビット幅は32bitなのにアトミックで扱えるビット幅は倍の64bit
  • ポインタのビット幅は64bitなのにアトミックで扱えるビット幅は倍の128bit

のいずれかに該当するならdouble wide atomics操作ができる環境なので AtomicWordPair を受け入れて通常の倍の長さのデータを操作してよい、というわけです。

ちなみに hasAtomicBitWidth がいくつになるのかはこちらに直書きされています:
swift/lib/Basic/LangOptions.cpp at b073d3d186c3dfe173ec8eefa4de87a8860aa506 · swiftlang/swift

余談

WordPair はどのように128bitの長さのデータを用意しているか

64bit CPUかつdouble wide atomics操作ができる環境なら、2つの UInt64 を繋ぎ合わせて128bitの長さのデータを作っていると紹介しました。次のようなビット演算をすれば2つのデータを繋ぎ合わせることができるはずです。

let first: UInt64 = ...
let second: UInt64 = ...

let data: UInt128 = UInt128(first) | UInt128(second) << 64

【募集】ビット演算がわかる本または資料

WordPair はLLVM IRの型やビット演算命令を使って128bitの整数を操作しています。次のソースコードは実際の実装を抜粋したものです。

【募集】LLVM IRがわかる本または資料

var i128 = Builtin.zext_Int64_Int128(value.first._value)
var high128 = Builtin.zext_Int64_Int128(value.second._value)
let highShift = Builtin.zext_Int64_Int128(UInt(64)._value)
high128 = Builtin.shl_Int128(high128, highShift)
i128 = Builtin.or_Int128(i128, high128)

return AtomicRepresentation(i128)

出典:swift/stdlib/public/Synchronization/Atomics/WordPair.swift at c2b89508af2c8712c73d5866b556348124cd717d · swiftlang/swift

【募集】Builtinがわかる記事または本

ここで使われているLLVM IR由来のメソッドは次の3つです。

  • zext_Int64_Int128 (→ zext 命令)
  • shl_Int128 (→ shl 命令)
  • or_Int128 (→ or 命令)

アンダースコアで区切られたうちの最初の単語がLLVM IRの命令の名前です。つまりLLVMの公式ドキュメントを zextshlor で検索すればどんなことをする命令なのかがわかります。例えば zext で調べると次の命令が見つかります。

zext .. to’ Instruction Syntax: <result> = zext <ty> <value> to <ty2> Semantics: The zext fills the high order bits of the value with zero bits until it reaches the size of the destination type, ty2.

出典:LLVM Language Reference Manual — LLVM 20.0.0git documentation

直訳すると
zext は、目標の型 ty2 と同じサイズになるまで value の上位ビットを0で埋める」
になります。つまり Builtin.zext_Int64_Int128(数値) は64bit整数型の数値を受け取って上位64bitを0で埋めて128bit整数型にするのだとわかります。

ちなみにLLVM IRにもSwiftと同様に整数型があり、64bitなら i64 、32bitなら i32 です。 Builtin.zext_Int64_Int128(数値) をLLVM IRらしく記述すると zext i64 数値 to i128 になります。

参考:こわくないLLVM入門! #LLVM - Qiita

【募集】LLVMの整数型がわかる記事または本

同様に調べると、 shl は左シフト演算、 or はOR演算をする命令であるとわかります。
Swiftからこれらの命令を使うときは _value を使ってLLVM IRで扱える整数型(例えば BuiltIn.Int64 )を取り出して与えます。

参考:swift/stdlib/public/core/IntegerTypes.swift.gyb at 5e8550edc13a176cde86a2d3595ac54f124105cb · swiftlang/swift

ここまでのことを合わせると、WordPair.swiftは次の操作をしているということがわかります。

  1. ゼロ拡張を用いて、 firsti64 型から i128 型にした値を得る。
  2. ゼロ拡張を用いて、 secondi64 型から i128 型にした値を得る。
  3. 4行目のために 64 を用意する。
  4. 2行目で得た値を64桁ぶん左にシフトする。
  5. 1行目で得た値と4行目で得た値のORをとる。

これで i128 で表したデータのできあがりです。double wide atomics操作を活用できるようになります。

Int128 とは

LLVM IRに128bit整数型 i128 があるならLLVMを前提とするSwiftにも Int128UInt128 があってよさそうですし、128bit整数をそのままアトミック命令に渡せてもよさそうです。

実はSwift 6から使えるようになっています。

【TODO】気が向いたら詳細を書く

参考:swift-evolution/proposals/0425-int128.md at main · swiftlang/swift-evolution

本章のまとめ

Atomic が扱えるものはCPU命令が扱えるものに限られるという話を紹介しました。また、その制限をSwift上でどのように表現しているかも紹介しました。CPUが扱えないものを Atomic が誤って受け取らないように、プロトコルや #if を使ってうまく表現していることがわかりました。

余談としてSwiftコンパイラの実装も少し覗いてみました。swift/stdlib/public/core at main · swiftlang/swiftには私たちが普段使う型がSwiftで実装されているので、Swiftコンパイラを初めて読む方におすすめです。

次章では Atomic に共有資源を渡したあとどんな操作ができるのかを見ていきます。

前章では Atomic が対応している型について紹介しました。本章では Atomic ができる操作を見ていきます。

アトミック操作は割り込まれたくない一連の操作が一体となっていることが特徴です。 Atomic にあるメソッドは「現在の値を読み、何かしらの計算を1つだけ行なって結果を新しい値として書く」というものがほとんどです。アトミック性があるので、読込と書込の間に他者が割り込むことはなく期待通りの値になります。

他にどんなメソッドがあるのかAtomic | Apple Developer Documentationを見てみると、見慣れない名前のメソッドがあるし、同じ名前のメソッドばかりです。一体どうしてそうなってしまっているのでしょうか?

同じメソッドがたくさんあることには一旦目をつむって、それぞれのメソッドで何ができるのかを見ていきます。

Atomic のメソッドでできること

中身がどんな型であっても使えるメソッド

Atomic の中身 Value がどんな型であっても使えるメソッドとして次の5種類があります。

  • load(ordering:)
  • store(_:ordering:)
  • exchange(_:ordering:)
  • compareExchange(expected:desired:ordering:)
  • weakCompareExchange(expected:desired:ordering:)

load(ordering:) は単純に今の値を読むだけのメソッド、 store(_:ordering:) は単純に与えられた値を書き込むだけのメソッドです。 exchange(_:ordering:) も与えられた値を書き込みますが、書き込む前の元の値を戻り値として受け取ることができます。

compareExchange 2種のシグネチャを見てみましょう。まずは compareExchange(expected:desired:ordering:) から。

func compareExchange(
    expected: consuming Value,
    desired: consuming Value,
    ordering: AtomicUpdateOrdering
) -> (exchanged: Bool, original: Value)

Perform an atomic compare and exchange operation on the current value, applying the specified memory ordering.

出典(代表して Value.AtomicRepresentation_Atomic64BitStorage であるもの):compareExchange(expected:desired:ordering:) | Apple Developer Documentation

直訳すると
「アトミックなcompare and exchange操作を、現在の値に対して指定されたメモリオーダリングで実行する。」
というメソッドです。

こちらは weakCompareExchange(expected:desired:ordering:) です。先ほどと引数は同じです。

func weakCompareExchange(
    expected: consuming Value,
    desired: consuming Value,
    ordering: AtomicUpdateOrdering
) -> (exchanged: Bool, original: Value)

Perform an atomic weak compare and exchange operation on the current value, applying the memory ordering. This compare-exchange variant is allowed to spuriously fail; it is designed to be called in a loop until it indicates a successful exchange has happened.

出典(同上):weakCompareExchange(expected:desired:ordering:) | Apple Developer Documentation

こちらは
「アトミックなweak compare and exchange操作を、現在の値に対して指定されたメモリオーダリングで実行する。このcompare-exchangeの変種はspuriously failを許容する。これは、exchangeが成功するまでループで呼び出されるように設計されている。」
です。

compare and exchange操作は次の処理を行うものです。 compareExchange(expected:desired:ordering:)weakCompareExchange(expected:desired:ordering:) もこの処理を行います。

  1. 現在の値が expected と一致するかみてみる。
  2. 一致していれば desired の値に書き換える。
  3. 書き換えたかどうかの Bool の値と元の値を返す。

【募集】Swift(というかLLVM)ではswapと呼ばない理由。atomic::compare_exchange_weak - cpprefjp C++日本語リファレンスでは思いっきりCASと呼んでいる……。

両者の違いはspurious failureを許容するかどうかにあります。

【募集】spurious failureがわかる本または記事

【参考】atomic compare_exchange_weak/strong関数 - yohhoyの日記
【参考】Spurious Failureをどう訳すか - Togetter [トゥギャッター]

IntUInt のためのメソッド

Atomic の中身 Value が整数型なら、単純な読み書きだけでなく加算や減算などの計算をすることができます。

  • add(_:ordering:)
  • wrappingAdd(_:ordering:)
  • subtract(_:ordering:)
  • wrappingSubtract(_:ordering:)
  • bitwiseAnd(_:ordering:)
  • bitwiseOr(_:ordering:)
  • bitwiseXor(_:ordering:)
  • max(_:ordering:)
  • min(_:ordering:)

加算、減算、ビット演算、そして maxmin の計算ができます。

addwrappingAddsubtractwrappingSubtract という似たような名前のものがありますが、これはオーバーフロー発生時の処理が異なります。いわゆるラップアラウンドです。

Int.maxwrappingAdd(_:ordering:) でインクリメントするとエラーなく Int.min になるのですが、 add(_:ordering:) の場合は実行時エラーになります。

var counter = Atomic(Int.max)
counter.add(1, ordering: .relaxed) // ❗️ Thread 3: Swift runtime failure: arithmetic overflow

【募集】ラップアラウンドがわかる本または記事

Bool のためのメソッド

Atomic の中身 ValueBool ならAND、OR、XOR演算ができます。

  • logicalAnd(_:ordering:)
  • logicalOr(_:ordering:)
  • logicalXor(_:ordering:)

同じ名前のメソッドがたくさんある理由

Atomic ではいろいろな計算をするメソッドがあることを確認しました。ドキュメントを見てみると同じ名前のメソッドが複数あります。 load(ordering:) などは5つもあります。

出典:Atomic | Apple Developer Documentation

引数や戻り値の型も同じなので一見区別がつきません。

func load(ordering: AtomicLoadOrdering) -> Value

個別のページをよく見てみると違いが見つかります。

  • Available when Value conforms to AtomicRepresentable and Value.AtomicRepresentation is _Atomic128BitStorage.
  • Available when Value conforms to AtomicRepresentable and Value.AtomicRepresentation is _Atomic8BitStorage.
  • Available when Value conforms to AtomicRepresentable and Value.AtomicRepresentation is _Atomic16BitStorage.
  • Available when Value conforms to AtomicRepresentable and Value.AtomicRepresentation is _Atomic64BitStorage.
  • Available when Value conforms to AtomicRepresentable and Value.AtomicRepresentation is _Atomic32BitStorage.

Value.AtomicRepresentation がどの _Atomic数字BitStorage なのかによってどの load(ordering:) が使えるのかが変わるようです。 _ が先頭についていることからSwift内部だけで使われている型だろうと予想できます。また、名称からストレージのサイズ違いを表しているのだろうとも予想できます。これら5つのメソッドはいわゆるオーバーロードされたもので、8bitのサイズのデータが入っているなら _Atomic8BitStorage 用の load(ordering:) が、16bitなら _Atomic16BitStorage 用の load(ordering:) が呼ばれて使われます。

なぜサイズごとにメソッドを使い分けているのでしょうか。それは load(ordering:) が呼んでいるLLVM IRの命令の都合であると予想されます。

次の命令はLLVM IRでアトミックに値を読むための命令です。 load(ordering:) はこの命令を呼ぶように実装されています。

<result> = load atomic [volatile] <ty>, ptr <pointer> [syncscope("<target-scope>")] <ordering>, align <alignment> [, !invariant.group !<empty_node>]

出典:LLVM Language Reference Manual — LLVM 20.0.0git documentation

load(ordering:) に関係のある引数だけを残すと次のようになります。

load atomic <ty>, ptr <pointer> <ordering>
  • <ty>:型
  • <pointer>:読み込む対象を指すポインタ
  • <ordering>:オーダリング

対象のポインタを指定するだけではなくどんな型(サイズ)なのかも明示する必要があるのです。

Atomic の内部では次の5つの load 関数を使い分けています(オーダリングに .relaxed を指定した場合)。

Builtin.atomicload_relaxed_Int128(_rawAddress)
Builtin.atomicload_relaxed_Int8(_rawAddress)
Builtin.atomicload_relaxed_Int16(_rawAddress)
Builtin.atomicload_relaxed_Int64(_rawAddress)
Builtin.atomicload_relaxed_Int32(_rawAddress)

対象のデータ長が128bitなのか8bitなのか16bitなのか64bitなのか32bitなのかで呼ぶべき関数が変わるので、異なるデータ長のぶん同じシグネチャのメソッドが複数存在するというわけです。

Swiftコンパイラがたくさんのメソッドを量産できるワケ

Atomic にはさまざまなメソッドがあり、同じ操作をするメソッドが型ごとに用意されていることを紹介しました。

Atomic にはたくさんのメソッドがあります。いくら Atomic がCPU命令をラップしているだけといっても、それらひとつひとつを手書きでラップしていたら実装するのもメンテナンスするのも大変です。Stencil&SwiftGenのようなテンプレート&生成ツールが欲しくなります。

これだと面倒くさい…:

extension Atomic where Value == Int64 {
    func add(...) {
        ...
    }

    func store(...) {
        ...
    }

    他のメソッドの実装が続く...
}

extension Atomic where Value == Int32 {
    ...
}

Int16やInt8やUIntも実装しなくちゃ...

こう書けたら最高ですね!:

extension Atomic where Value == ${ここにいろんなIntが入る} {
    func ${ここにいろんなメソッド名が入る}(...) {
        BuiltIn.${メソッドに対応するLLVMの命令の名前が入る}(...)
    }
}

SwiftコンパイラはXXX.swift.gybというテンプレートファイルを使って類似の実装を量産することができます。gybとはGenerate Your Boilerplateの略です。 Atomic 以外でもさまざまな場所でさまざまな実装を量産しています。

参考:What are .swift.gyb files? - Using Swift - Swift Forums

Int 用にメソッドを量産している現場を見にいってみましょう。 ${} に当てはめるための文字列のリストと、テンプレートファイルであるgybファイルが存在しているはずです。

こちらは ${} に当てはめるための文字列のリストで、Pythonで書かれています:

integerOperations = [
    # Swift name, llvm name, operator, doc name
    ("WrappingAdd", "add", "&+", "wrapping add"),
    ("WrappingSubtract", "sub", "&-", "wrapping subtract"),
    ("BitwiseAnd", "and", "&", "bitwise AND"),
    ("BitwiseOr", "or", "|", "bitwise OR"),
    ("BitwiseXor", "xor", "^", "bitwise XOR"),

    # These two are handled specially in source.
    ("Min", "min", "", "minimum"),
    ("Max", "max", "", "maximum")
]

出典:swift/utils/SwiftAtomics.py at b073d3d186c3dfe173ec8eefa4de87a8860aa506 · swiftlang/swift

「Swiftにおけるメソッド名」「LLVMにおける命令の名前」「その操作に相当する演算子」「ドキュメント上での名前」が並んでいます。これらの情報があれば、Swiftのメソッドを作って、中でLLVMの命令を呼んで、さらに丁寧なドキュメンテーションコメントまで用意できます。

そして次のソースコードがテンプレートファイル、AtomicIntegers.swift.gybです。${intType} には Int64UInt64 といった整数型が入ります。

extension Atomic where Value == ${intType} {
% for (name, builtinName, op, doc) in integerOperations:

for文で integerOperations を回してメソッドを量産します。

% for (name, builtinName, op, doc) in integerOperations:
  /// Perform an atomic ${doc} operation and return the old and new value,
  /// applying the specified memory ordering.
〜〜途中省略〜〜
  public func ${lowerFirst(name)}(
    _ operand: ${intType},
    ordering: AtomicUpdateOrdering
  ) -> (oldValue: ${intType}, newValue: ${intType}) {
〜〜途中省略〜〜
      Builtin.atomicrmw_${atomicOperationName(intType, builtinName)}_${llvmOrder}_Int64(
        _rawAddress,
        operand._value
      )

出典:swift/stdlib/public/Synchronization/Atomics/AtomicIntegers.swift.gyb at 66ec7abd540d28477376bb5f647d6663d62a29ec · swiftlang/swift

私たちがAtomic | Apple Developer Documentationで見たメソッドやドキュメントたちはこのようにして量産されているというわけです。

本章のまとめ

本章では Atomic がどんなアトミック操作メソッドを提供しているか、Swiftコンパイラがどのようにメソッドを実装しているかを紹介しました。

ところで各メソッドの引数にある ordering とはなんでしょうか。デフォルト値が設定されておらず省略できないので、使う側の私たちが何かしらの値を渡さなければ Atomic を使えません。

次章で ordering の謎に迫ります!

前章では Atomic のメソッドを紹介しました。メソッドには必ず ordering という引数があり、どんな操作をするにも何かしら値を指定しないといけないことがわかりました。

本章では ordering が何者なのか、何を指定すればいいのかを紹介します。

Atomic のメソッドにある order

代表して wrappingAdd(_:ordering:) を見てみましょう。

@discardableResult
func wrappingAdd(
    _ operand: Int,
    ordering: AtomicUpdateOrdering
) -> (oldValue: Int, newValue: Int)

AtomicUpdateOrdering なるものを要求されていて、ドキュメントには次の説明が書かれています:

The memory ordering to apply on this operation.

出典:wrappingAdd(_:ordering:) | Apple Developer Documentation

「この操作に適用するメモリオーダリング」を与えなければならないようです。選択肢は次の5つです。5つもあります。

  • AtomicUpdateOrdering.acquiring
  • AtomicUpdateOrdering.acquiringAndReleasing
  • AtomicUpdateOrdering.relaxed
  • AtomicUpdateOrdering.releasing
  • AtomicUpdateOrdering.sequentiallyConsistent

出典:AtomicUpdateOrdering | Apple Developer Documentation

なぜこのような値を設定しなければならないのでしょうか。いったいどれが何で、どんな場面でどれを使うと適切なのでしょうか?

例題:lock-free, single-consumer stack

なぜオーダリングを指定しなければいけないのか、指定しないとどうなってしまうのかを確かめるために例題を用いて説明します。例題はSE-0410に掲載されている例「lock-free, single-consumer stack」です。スタックを作成します。push操作やpop操作ができるあのスタックです。

次の図はSE-0410の例題のスタック LockFreeSingleConsumerStack<Element> の構造を表したものです。内部には単方向のリンクドリストが入っていて、pushすると要素が1つ増えてpopすると1つ減ります。単方向リンクドリストには無数のノードを入れることができますがスタックのクラスが参照するのは1つだけです。そのノードが次にpopした時に取り出されます。

【募集】スタックやリンクドリストのしくみがわかる本や記事

ノード自体は単純な構造体です。

struct Node {
    let value: Element
    var next: UnsafeMutablePointer<Node>?
}

Node はpushで渡された値 value と次のノードへのポインタ next を持ちます。一番最初に入れたノードの next は次のノードがないため nil になります。

さて、このスタックは次の条件を満たさなければいけません。

  • lock-free:ロックを使わずに動く。
  • (multi-producer):同時にたくさんpush操作をされても大丈夫。
  • single-consumer:同時にpop操作をしてはいけない。並行して同時にpop操作されることを想定しなくてよい。

【募集】producerやconsumerがわかる記事または本

LockFreeSingleConsumerStack<Element> はどのように単方向リンクドリストを管理すればよいでしょうか?

※ロックについては後の章で紹介します。ロックの代わりにアトミック操作を活用して作るのだなと思ってもらえれば大丈夫です。

スタックの詳細設計

LockFreeSingleConsumerStack<Element> は次の2つの共有資源を持ちます。

  • スタッククラスが持っている単方向リンクドリストへのポインタ
  • 今pop作業をしているコンシューマがいくつあるか

どちらもストアドプロパティの形式で持っています。

class LockFreeSingleConsumerStack<Element> {
    typealias NodePtr = UnsafeMutablePointer<Node>

    private let _last = Atomic<NodePtr?>(nil)
    private let _consumerCount = Atomic<Int>(0)

〜〜以下pushとpopの実装が続く〜〜

Atomic が必要なのはなぜなのかも含めて、2つのプロパティそれぞれを見ていきましょう。

コンシューマのカウント( _consumerCount )

先に _consumerCount から紹介します。 _consumerCount はpop作業をしている最中のコンシューマがいくつあるのかを数えておくプロパティです。single-consumerを想定しているので、コンシューマの数は0か1になるはずです。なのでpop作業に入る直前に値が想定内に収まっていることを確認しつつインクリメントします。pop作業が終わったらデクリメントします。

コンシューマの数を正しく数えるには Atomic が必要です。一斉に pop メソッドを呼ばれてインクリメント/デクリメントのための読み書きが同時に行われても問題ないようにしなければいけません。そうでなければ3章の counter のように間違ったカウントをしてしまう恐れがあります。

次のソースコードは pop() メソッドの冒頭です。

precondition(
    _consumerCount.wrappingAdd(1, ordering: .acquiring).oldValue == 0,
    "Multiple consumers detected")
defer { _consumerCount.wrappingSubtract(1, ordering: .releasing) }

precondition(_:_:file:line:) のなかで wrappingAdd(_:ordering:) を呼んでインクリメントしています。戻り値の oldValue が0であれば誰もpop作業をしていないということなので安心してpop作業に取り掛かれます。そして defer のなかで wrappingSubtract(_:ordering:) を呼ぶことでpop作業のあとにデクリメント処理が行われます。

リンクドリスト( _last

もう1つの _last はスタッククラスが持っているリンクドリストへのポインタです。 Atomic<UnsafeMutablePointer<Node>?> の形式でリンクドリストを持ちます。名前の通り、スタックの最後のノードを指します。

pushとpopの実装を見てみましょう。

push時のノード操作

スタックが最後のノードを持っているので、以下の3つの処理をすればよいです。

  1. 新しく Node を作る。 value にはpushされた値が入る。
  2. ステップ1で作った Nodenext に、現時点の最後のノードを指すポインタを渡す。
  3. スタッククラスがステップ1で作った Node を指すようにする。

![](https://storage.googleapis.com/zenn-user-upload/62938bfd0955-20241210.png =400x)

ただし一斉にpushしてもスタッククラスやリンクドリストの繋がりが壊れないようにしなければいけません。同時にたくさん Node が作られてもそれが単方向リンクドリストとして1列に並ぶようにする必要があります。

次の図は繋ぎ込みに失敗した例です。新しく作られた2つの Nodenext がどちらも同じ Node を指し、そのうえ片方は LockFreeSingleConsumerStack からも Node からも参照してもらえていません。

![](https://storage.googleapis.com/zenn-user-upload/e13ea0bb4491-20241210.png =400x)

あるいは、pushと同時にpopもしようとして、今まさに破棄されようとしているノードを指してしまうかもしれません:

![](https://storage.googleapis.com/zenn-user-upload/2d8515ad794d-20241210.png =600x)

どのようにすれば失敗を避けられるでしょうか?次のフローチャートで示すように、正しく繋げられる状態にあるか確かめてから繋ぐ必要がありそうです:

![](https://storage.googleapis.com/zenn-user-upload/a32ad186ce94-20241210.png =500x)

次のソースコードは LockFreeSingleConsumerStackpush(_:) の実装です。

func push(_ value: Element) {
    let new = NodePtr.allocate(capacity: 1)
    new.initialize(to: Node(value: value, next: nil))

    var done = false
    var current = _last.load(ordering: .relaxed)
    while !done {
        new.pointee.next = current
        (done, current) = _last.compareExchange(
            expected: current,
            desired: new,
            ordering: .releasing
        )
    }
}

2〜3行目は Node オブジェクトを作成しています。4行目の done 変数は正しい繋ぎ込みに成功したかどうかを表す Bool 値、5行目の current 変数は現時点の最後のノード( push 処理完了後には最後から2番目になるノード)を指すポインタです。

pop時のノード操作

次はpop操作の実装を見てみましょう。スタックが最後のノードを持っていることと、single-consumerであることから、次の処理が必要です。

  1. _consumerCount の今の値を確認する。もしも値が0になっていなかったら(自分以外の誰かがpop作業中だったら)エラーとする。
  2. _consumerCount の値をインクリメントする。
  3. スタッククラスが指す最後のノードを、最後から2番目のノードにする。
  4. 最後のノードが格納されていたメモリを解放する。
  5. 最後のノードの中に入っていた値を返す。
  6. _consumerCount の値をデクリメントする。

![](https://storage.googleapis.com/zenn-user-upload/480939fdd0ad-20241210.png =400x)

ただし、同時にpush操作が行われうる中で正しいノードをpopして正しいメモリを解放しなければなりません。

こちらは LockFreeSingleConsumerStackpop() の実装です。

func pop() -> Element? {
    precondition(
        _consumerCount.wrappingAdd(1, ordering: .acquiring).oldValue == 0,
        "Multiple consumers detected")
    defer { _consumerCount.wrappingSubtract(1, ordering: .releasing) }
    var done = false
    var current = _last.load(ordering: .acquiring)
    while let c = current {
        (done, current) = _last.compareExchange(
            expected: c,
            desired: c.pointee.next,
            ordering: .acquiring
        )

        if done {
            let result = c.move()
            c.deallocate()
            return result.value
        }
    }
    return nil
}

pop()whilecompareExchange(expected:desired:ordering:) を併用することで、正確にpopできる状況であることを確認してからpop処理を行なっています。7行目でその時点での最後のノードを取得していますが、9行目の compareExchange で実際にリンクドリストから外すまでに誰かがリンクドリストを操作しないとも限りません。それを確認しないまま処理を進めたら間違った値を返却したり間違ったノードを解放したりしてしまいます。

無事にノードを単方向リンクドリストから外せたら、不要になったノードのメモリを初期化しつつ値を返します。

うまくいかないケース

Atomic を使いさえすれば LockFreeSingleConsumerStack はきちんと動作しそうに思えます。しかしオーダリングを正しく設定しなければ不具合が発生しかねません。

スレッドAが pop() し始めてすぐスレッドBも pop() しようとしたとします。このスタックはsingle-consumerなので同時に pop() の操作をされることを想定していません。 pop() 内に記述した precondition(_:_:file:line:) で停止してもらわなければいけません。

スレッドAが pop() を呼んだ時点では他の誰もpop作業をしていないのでpop作業に取り掛かることができます。 _consumerCount が0であることを確認しつつインクリメントを行い、続けてpop処理を行います。

ここで問題になるのが、実はプログラムの命令が実装順の通りに実行されるとは限らないということです。プログラムを高速に実行するための工夫としてアウトオブオーダ実行というものがあり、これは処理に時間がかかるステップから先に着手することで全体の実行時間を短縮する方式です。

【募集】アウトオブオーダ実行がわかる記事や本

pop() 操作は「インクリメントする→popする→デクリメントする」というステップから成り立っていますが、高速化を目的に「インクリメントする→デクリメントする→popする」という順番に変わっていても pop() が1つのスレッドだけで動作している限りは問題ありません。 _consumerCount_last の処理は互いに独立している(片方の計算結果がもう片方に影響しない)ので、高速化を目的に順番を入れ替えたとしても、リンクドリストのノードが1つ減って _consumerCount の値が0に戻ったという結果は維持されます。

次の図は、スレッドAによるpop処理のタイムライン、そして _consumerCount_last に対する操作のタイムラインを示したものです。私たちは「 _last の指すノードが変わる→ _consumerCount が0に戻る」という順を想定していましたが、実際には _consumerCount を0に戻す処理が先に行われてしまいました。

そこにスレッドBが現れ pop() を呼びます。 _consumerCount が0となっているので precondition(_:_:file:line:) をパスしその後の処理を続けます。

しかし実際にはまだスレッドAによる pop() の処理が _last に反映されていません。まだスレッドAによる pop() が終わっていないにも関わらず _consumerCount が0を指していたため precondition(_:_:file:line:) が意図通りに機能しませんでした。

これでは誤った値がpopされたりリンクドリストが壊れたりしてしまいます。

解決編

バグが発生した原因は良かれと思って処理の順番が入れ替えられてしまったことにあります。これを解決するには、良かれと思っていたとしても順番を入れ替えないように指示して、 _consumerCount が0に戻った時にはpop処理(特に _last に対する更新操作)がきちんと完了しているようにします。

イベントAがイベントBの前に発生するという関係性をhappens before関係といい、「A happens before B」と言ったり「A→B」と書いたりします。イベントBがイベントAの後に発生するなら「B happens after A」です。

という記号を使って解決方法を言い表してみます:

  • (他のスレッドによる _consumerCount を-1する操作→)_consumerCount に+1する操作 → その他の操作
  • その他の操作 → _consumerCount を-1する操作(→他のスレッドによる _consumerCount を+1する操作)

という関係性を厳命して順番が入れ替わらないようにできれば、先ほどの問題を解決できそうです。

ここでオーダリングのドキュメントを見てみましょう。例えば AtomicUpdateOrdering.acquiring には次のような説明が書かれています:

An acquiring update synchronizes with a releasing operation whose value its reads. It ensures that the releasing and acquiring threads agree that all subsequent variable accesses on the acquring thread happen after the atomic operation itself.

出典:acquiring | Apple Developer Documentation

これを直訳すると、
「acquiringによる更新は、その値を読み込むreleasing操作と同期する。これは、acquiringを行うスレッド上において、後続の全変数アクセスがアトミック操作の後に発生することを、releasingするスレッドとacquiringするスレッドが同意することを保証する。」
となります。

文中にhappen afterという言い回しが登場しています。なんとなくhappenという動詞を使ったのではなくてhappens before/after関係のことを言いたくてhappen afterと言ったのだと解釈すべきでしょう。どうやらこれを使えば「_consumerCount に+1する操作 → その他の操作(ノード操作を含む)」という関係性を作れそうです。「その他の操作(ノード操作を含む) → consumerCount を-1する操作」のほうには AtomicUpdateOrdering.releasing を使います。こちらのドキュメントにもhappens beforeという言い回しが登場します。

もう1度 pop() の実装を見てみましょう。

precondition(
    _consumerCount.wrappingAdd(1, ordering: .acquiring).oldValue == 0,
    "Multiple consumers detected")
defer { _consumerCount.wrappingSubtract(1, ordering: .releasing) }

wrappingAdd(_:ordering:) には .acquiring が、 wrappingSubtract(_:ordering) には .releasing が設定されています。これにより「wrappingAdd(_:ordering:) → その他 → wrappingSubtract(_:ordering)」という関係性ができあがります。

改めて .acquiring のドキュメントを読んでみると、次の2点が今回の例題で _consumerCount に行なった操作と合致することがわかります。

  • acquiringによる更新とreleasingによる読込は同期する(=2つ1組で使用する)。
  • 「acquiringによる書き込み→その他のアクセス」を行うスレッド(=acquiringするスレッド)と、連携して動作するもう1つのスレッド(=releasingするスレッド)が存在する。

図に示したように、オーダリングがあるおかげで、先にpop作業に取り掛かったスレッドAがデクリメントするタイミングと、後からpop作業に取り掛かるスレッドBがインクリメントするタイミングがうまく調整されます。そして「スレッドAのpop処理→スレッドAのデクリメント→スレッドBのインクリメント→スレッドBのpop処理」となるよう互いにうまく連携しています。

【募集】半順序関係がわかる記事または本

_last に対する操作も振り返ってみましょう。push処理は「最後のノードを取得する→新しいノードの next にセットする→ _last に新しいノードをセットする」となっています。仮に _last に対する書き込み操作が同時に発生しなければ、「最後のノードを取得する→ _last に新しいノードをセットする→新しいノードの next にセットする」と処理の順番が入れ替わっても最後に得られる結果は同じになります。しかし実際には同時に別のpush操作やpop操作が行われるかもしれないので入れ替えてはいけません。つまりアウトオブオーダ実行による処理の順番入れ替えの恐れがあるのでオーダリング指定で防がなくてはいけません。

一貫性モデル

実装した処理順とは異なる順番で実際の処理がされうること、オーダリングを使えば順番を入れ替えないよう指示できることを紹介しました。

【助けて】一貫性モデルのうまい説明
【募集】一貫性モデルの正確な定義とわかりやすい説明

参考:Eventual Consistencyまでの一貫性図解大全 #分散システム - Qiita

一貫性モデルにはさまざまな種類がありますが、プログラミング言語で指定できるものはごく一部に限られます。C++やSwiftで登場するのは次の2つです。

一貫性モデル名 C++におけるオーダリング Swiftにおけるオーダリング
Sequential Consistency / 逐次一貫性 seq_cst sequentiallyConsistent
Release Consistency / リリース一貫性 acquire & release acquiring & releasing

参考:memory_order - cpprefjp C++日本語リファレンス

Swiftのオーダリングの扱いはC++と同じです。Swiftのドキュメントの説明がぴんとこない場合はC++の memory_order を調べてC++erの皆さんの解説を読むといいかもしれません。

Atomic におけるオーダリングの扱い

先に紹介したように、 Atomic はLLVMの命令をラップしているだけです。開発者が指定したオーダリングもそのままLLVMに渡すだけです。

一方で、開発者が正しくアトミック操作とオーダリングを扱えるようにさまざまな工夫が施されています。 Atomic はただラップしているわけではないのです。

いろいろな Atomic*Ordering

C++では memory_order という列挙型に全てのオーダリングがまとまっています。書き込みに指定できないオーダリングも読み込みに指定できないオーダリングもひとまとまりです。

acquire acquire操作としての読み込みを行うことを指示する。 store() など、書き込みのみを行う操作に対しては指定できない。

出典:memory_order - cpprefjp C++日本語リファレンス

一方Swiftのドキュメントを見るとオーダリングに関する列挙型が複数あることがわかります。

  • AtomicLoadOrdering
  • AtomicStoreOrdering
  • AtomicUpdateOrdering

これにより、操作と合わないオーダリングを間違って指定してしまってもコンパイルエラーになるようになっています。

例を見てみましょう。書き込みのみを行う store(_:ordering:)AtomicStoreOrdering を受け取ります。

func store(
    _ desired: consuming Value,
    ordering: AtomicStoreOrdering
)

出典:store(_:ordering:) | Apple Developer Documentation

AtomicStoreOrdering には relaxed / releasing / sequentiallyConsistent のみがあり、acquire操作を誤って指定することがないようになっています。

デフォルト値を用意していない

Atomic のメソッドを呼ぶときは必ずオーダリングを指定する必要がありますが、 store(_:ordering:) を見ての通りどこにもデフォルト値(例えば = .sequentiallyConsistent )がありません。これは Atomic の操作ごとにどんなオーダリングが指定されているのか開発者が気を付けるようにするためです。

もし私たちが Atomic を操作するような実装をすれば、ソースコード中にオーダリングの設定が明記されて人間の目に触れることになります:

+ counter.store(0, ordering: .releasing)

順序を厳しく指定するオーダリングを誤って指定してパフォーマンスに影響が出ることを避けるため Atomic はあえてデフォルト値を設定していません。

変数で渡すことができない

Atomic のメソッドを呼ぶときは必ずオーダリングを指定する必要がありますが、 Atomic*Ordering をコンパイル時定数で渡さないとコンパイルエラーになります。コンパイル時定数については12章で紹介します。ここでは .relaxed などをそのまま直接メソッドに与えなければならないと思っておけばよいです。

次のソースコードは変数でオーダリングを指定したためにエラーになる例です。

let counter = Atomic(0)
var ordering = AtomicUpdateOrdering.relaxed
counter.wrappingAdd(1, ordering: ordering) // ❗️Ordering argument must be a static method or property of 'AtomicUpdateOrdering'

引数で受け取ったものを渡すのもNGです。

func method(ordering: AtomicUpdateOrdering) {
    let counter = Atomic(0)

    counter.wrappingAdd(1, ordering: ordering) // ❗️Ordering argument must be a static method or property of 'AtomicUpdateOrdering'
}

その理由、そしてどうやってSwiftコンパイラがこのエラーを検知しているのかは12章と13章で紹介します。

本章のまとめ

本章ではオーダリングについて紹介しました。私たちが実装した通りの順で処理を実行してくれるとは限らない、という話には驚かされますが、低水準な分野や高速化について考える時にはきちんと考慮する必要があります。

Atomic については一旦ここまでにして、次の章ではSynchronizationのもう1人の主役である Mutex を紹介します。

ロックとはミューテックスとは

【募集】一般的なロックとミューテックスがわかる本や記事

これまでのロックいろいろ

ロックを実現するためのしくみは以前から存在していました。ここでは、Synchronizationの Mutex の特徴を掴むために os_unfair_lockOSAllocatedUnfairLock を紹介します。

参考:明日から使えない!Swiftの排他制御 | Supership Tech Blog

【募集】Objective-C〜Swiftのロックの歴史がわかる本または記事

os_unfair_lock

os_unfair_lock はosフレームワークにある関数です。iOS 8.0やmacOS 10.10の時代から存在しています。

【募集】アンフェアロックおよび os_unfair_lock がわかる本や資料

しかしSwiftで使うには不便です。後継の OSAllocatedUnfairLock のドキュメントには次のように書かれています:

However, it’s unsafe to use os_unfair_lock from Swift because it’s a value type and, therefore, doesn’t have a stable memory address. That means when you call os_unfair_lock_lock or os_unfair_lock_unlock and pass a lock object using the & operator, the system may lock or unlock the wrong object.

出典:OSAllocatedUnfairLock | Apple Developer Documentation

直訳すると
「しかし、Swiftから os_unfair_lock を使うのは安全ではない。なぜなら値型であるために安定したメモリアドレスを持たないからである。つまり、 os_unfair_lock_lockos_unfair_lock_unlock を呼んで & 演算子を使ってロックオブジェクトを渡した時にシステムが誤ったオブジェクトをロック/アンロックする可能性がある」
となります。

os_unfair_lock のドキュメントを見てみましょう。structとあるのでこれは値型です。

typedef struct os_unfair_lock_s os_unfair_lock;

出典:os_unfair_lock | Apple Developer Documentation

そのため、次のようなコードをうっかり書くだけでコピーが発生してしまいます。

var unfairLock = os_unfair_lock()
var unfairLock2 = unfairLock // コピー発生!1行目のunfairLockとは別物

【募集】値型だとコピーが発生することがわかる本または記事

全員が同一の os_unfair_lock を使わなければ正しくロックすることができません。次の例は誤った os_unfair_lock の使い方の例です:

var unfairLock = os_unfair_lock()

var counter = 0

DispatchQueue.concurrentPerform(iterations: 10) { _ in
    var unfairLock = unfairLock // `os_unfair_lock` をコピーしてしまう
    for _ in 0 ..< 1_000_000 {
        os_unfair_lock_lock(&unfairLock)
        counter += 1
        os_unfair_lock_unlock(&unfairLock)
    }
}

var unfairLock = unfairLock でわざと os_unfair_lock のコピーを発生させました。この行があると正しく1000万回カウントされません。コピーして生まれた別物の os_unfair_lock を参照してしまい counter に対するロックが意味をなさなくなってしまうからです。 var unfairLock = unfairLock を消せば常に1行目の os_unfair_lock が参照されるようになって正しくロックが働き1000万回カウントできるようになります。

【募集】もう少し良い例があれば採用したい!

OSAllocatedUnfairLock

OSAllocatedUnfairLock はSwift Concurrency対応でもお馴染みです。iOS 16.0やmacOS 13.0以降で使うことができます。

参考:事例別!Strict Concurrency対応方法
参考:Swift の actor を使いたくない時でもロックで値を保護して Sendable にする(OSAllocatedUnfairLock)

OSAllocatedUnfairLock のドキュメントの続きを見てみましょう:

Instead, use OSAllocatedUnfairLock, which avoids that pitfall because it doesn’t function as a value type, despite being a structure. All copied instances of an OSAllocatedUnfairLock control the same underlying lock allocation.

直訳すると
「その代わりに OSAllocatedUnfairLock を使用する。構造体であるにもかかわらず値型として機能しないため、この落とし穴を避けることができる。 OSAllocatedUnfairLock のコピーされたインスタンスはすべて、同じロックの割り当てを制御する。」
となります。

OSAllocatedUnfairLockos_unfair_lock と同様構造体です。

@frozen
struct OSAllocatedUnfairLock<State>

出典:OSAllocatedUnfairLock | Apple Developer Documentation

構造体つまり値型なので os_unfair_lock と同じく少しのミスでコピーが発生するように思われます。しかし実際には正しく1000万回カウントできます。

let unfairLock = OSAllocatedUnfairLock()

var counter = 0

DispatchQueue.concurrentPerform(iterations: 10) { _ in
    let unfairLock = unfairLock

    for _ in 0 ..< 1_000_000 {
        unfairLock.withLock {
            counter += 1
        }
    }
}

実は OSAllocatedUnfairLock はコピーされてもその中身は同一のままなのです。よって同一のものでロックすることになり、ロック機能が意図した通りに働いて期待通りに1000万回インクリメントされます。

次のスクリーンショットは OSAllocatedUnfairLock の中にある __lock のアドレスを確認したものです:

キャプチャされてきた方の OSAllocatedUnfairLock も、コピーして作ったローカル変数の方の OSAllocatedUnfairLock も、どちらも同一の __lock を指しています。これがロックの実体でしょう。 __lock の型は参照型である ManagedBuffer です。つまりロックの実体をヒープ領域に置いて参照するというやり方で使っています。そうしておけばコピーしてしまうミスが防がれるというわけです。 OSAllocatedUnfairLock の"Allocated"とはヒープ領域を利用していることを明示しているのでしょう。

【参考】Swiftのメモリ割り当てを知る. 本記事では、Swiftにおけるメモリ割り当てについて解説します。 | by Satsuki Hashiba | Medium

ちなみに OSAllocatedUnfairLock に共有資源自体を渡して管理してもらうこともでき、その場合でもきちんと1000万回インクリメントされます。

let unfairLock = OSAllocatedUnfairLock(initialState: 0)

DispatchQueue.concurrentPerform(iterations: 10) { _ in
    let unfairLock = unfairLock

    for _ in 0 ..< 1_000_000 {
        unfairLock.withLock {
            $0 += 1
        }
    }
}

Synchronization.Mutex

Synchronizationの Mutex はプラットフォームごとのロックをラップしたものです。Darwin系の環境なら中身は os_unfair_lock です。

internal borrowing func _lock() {
    os_unfair_lock_lock(value._address)
}

【出典】:swift/stdlib/public/Synchronization/Mutex/DarwinImpl.swift at 3efa770c8642ccc303c204cf2dc757298b6e337d · swiftlang/swift

使い方は OSAllocatedUnfairLock からさらに変わっていますが、根本的な部分は相変わらず os_unfair_lock のままというわけです。

Mutex とコピー

os_unfair_lockOSAllocatedUnfairLock でうっかりコピーするサンプルソースコードを紹介したので、 Mutex でも試してみましょう。

Synchronizationの Mutex も構造体つまり値型です。

@frozen
struct Mutex<Value> where Value : ~Copyable

出典:Mutex | Apple Developer Documentation

1000万回のインクリメントを試してみます。

let unfairLock = Mutex(0)

DispatchQueue.concurrentPerform(iterations: 10) { _ in
    let unfairLock = unfairLock

    for _ in 0 ..< 1_000_000 {
        unfairLock.withLock {
            $0 += 1
        }
    }
}

しかしこれをコンパイルすることはできません。

MutexCopyable ではないのでコピーできません。コピーが発生するようなことをするとコンパイルエラーになります。

public struct Mutex<Value: ~Copyable>: ~Copyable

出典:swift/stdlib/public/Synchronization/Mutex/Mutex.swift at 4e0bdad17273ae6c28f6cbc0d602bd0a284bb060 · swiftlang/swift

let unfairLock = unfairLock という余計な行を取り除けばコンパイルでき、1000万回正しくカウントできます。

OSAllocatedUnfairLock の登場はiOS 16.0(=2022年9月リリース)で、 ~Copyable の登場はその1年後のSwift 5.9(=2023年9月リリース)です。 ~Copyable という機能を得て「わざわざヒープを使わずともコピーを禁止にすればいいだけ」という解決をするに至ったように思えます。

具体的にどんな設計になっているのかは後の章で確認しましょう。

OSごとの実装

Mutex はプラットフォームによって異なるものをラップしていると紹介しました。 os_unfair_lock はDarwin系OSでしか使えません。LinuxやWindowsやWebAssemblyでは違うものをラップしないといけません。

【募集】Darwinについてわかる記事または本

Linuxではfutexシステムコールを使用します。futexとはfast userspace mutexの略です。

参考:futex(2) - Linux manual page

【募集】futexについてわかる記事または本

WindowsではSRW lockを使用します。SRW lockとはSlim reader/writer lockの略です。

【募集】SRWLOCKについてわかる記事または本。Slim Reader/Writer (SRW) Locks - Win32 apps | Microsoft Learnが公式ドキュメントなのかなあ・・?

【TODO】 WebAssemblyについて書く。何もわからん。

@_extern(c, "llvm.wasm.memory.atomic.wait32")
internal func _swift_stdlib_wait(
    on: UnsafePointer<UInt32>,
    expected: UInt32,
    timeout: Int64
) -> UInt32

出典:swift/stdlib/public/Synchronization/Mutex/WasmImpl.swift at f802b67fc06447f7a04616d02508225b9fd657d2 · swiftlang/swift

【募集】 WebAssemblyのロック周りの事情がわかる記事または本

アクターとの違い

Mutex は「1スレッドだけが共有資源を独占できる」、アクターは「アクターを操作できるのは1度に1タスクまで」という点で両者はよく似ています。しかし相違点もあります。

使いたい共有資源が Mutex でロックされていると共有資源が解放されるまでスレッドは待機状態になってしまいます。次の図で示す例では、2つ目(2段目)のスレッドがロックを要求してから実際にロックを獲得するまでの期間が待機状態になっています。

また、 Mutex にはデッドロックやライブロックのリスクもあります。

【募集】デッドロックやライブロックがわかる記事または本

一方、アクターで保護するなら await で中断してスレッドを解放するのでスレッドが他の作業を実行できるようになります。しかしアクターにはreentrancy problem(直訳:再入可能性問題)があります。

次の図で示す例では、アクターの非同期メソッド doSomething()await で中断したあと再開するまでの間に別のメソッド update() が実行されています。この update() のなかでactorの状態が変更されると、 doSomething() の中断前後でアクターの状態が変わってしまうという事態に陥ります。

【募集】再入可能性がわかる記事または本

本章のまとめ

本章では os_unfair_lockOSAllocatedUnfairLockMutex を紹介しました。

※以下Darwin環境に限った話
この後の第2部で詳しく触れますが、 Mutex は安全性を維持しつつヒープ領域の利用をやめることに成功しています。古いOSのサポートを終了して Mutex を使えるようになったら Mutex に乗り換えていくとよいでしょう。

ここまで AtomicMutex の紹介をしてきました。どちらも内部でLLVMの命令や os_unfair_lock などを呼んでいて、アトミック操作やロックがSwiftで実装されていないことを確認しました。

実際に両者を使ってみると、普段私たちが使っている型とは異なる特徴がたくさんあることに気がつきます。

  • AtomicMutex もコピー不可。
  • var で宣言できない。
  • オーダリングを定数で渡せない。

なので特徴と理由をしっかり把握して使いたいところです。

これらの制約はラップされている側のLLVM IRの命令や os_unfair_lock にはありません。ラップする側の AtomicMutex に仕掛けがあります。ここから先の章では、なぜ AtomicMutex に珍しい特徴を持たせてあるのかを考察し、そしてSwiftコンパイラでどのように実現しているのか紹介していきます。

Mutexos_unfair_lockOSAllocatedUnfairLock と異なりコピーできないことを学びました。これは構造体に ~Copyable をつけたことによるものです。実は Atomic~Copyable がついてコピーできないようになっており、Swift Atomics時代からの大きな変更点のひとつとなっています。

本章ではSynchronizationがなぜ ~Copyable をつけてコピー不可としたのかを考察します。

Copyable~Copyable

Swiftには Copyable というプロトコルがあります。このプロトコルに準拠していればコピーができるようになります。普段私たちは protocol Copyable への準拠を気にすることなくコピーをしています。これはほとんどの場合で自動的に準拠してくれるからです。

You don’t generally need to write an explicit conformance to Copyable. The following places implicitly include Copyable conformance:

出典:Copyable | Apple Developer Documentation

直訳すると
「一般的に、 Copyable への準拠を明示的に記す必要はない。以下に示す場所には暗黙的に Copyable への準拠が含まれる。」
となります。ドキュメントにある通り、基本的にはプロトコルもクラスも列挙型もアクターも Copyable に準拠しています。次のソースコードでは、明示的に Copyable へ準拠していないにも関わらず自作の MyValue をコピーできています。

struct MyValue {}

let value0 = MyValue()
let value1 = value0

print(value0 is Copyable) // ⚠️ 'is' test is always true

~Copyable をつけると Copyable の準拠が打ち消されコピー不可能になります。次のソースコードでは Copyable が失われコンパイルエラーが出るようになっています。

struct MyValue: ~Copyable {}

let value0 = MyValue()
let value1 = value0 // ❗️ Cannot consume noncopyable stored property 'value0' that is global

print(value0 is Copyable) // ⚠️ Cast from 'MyValue' to unrelated type 'any Copyable' always fails / ❗️ Noncopyable types cannot be conditionally cast

【募集】 ~ がわかる本や記事

総称型などの型パラメータも暗黙的に Copyable を要求します。そのままでは Copyable を失った型( ~Copyable をつけた型)を与えることはできません:

struct MyValue: ~Copyable {
}

enum MyOptional<Wrapped> {
    case some(Wrapped)
    case none
}

MyOptional.some(MyValue()) // ❗️ Generic enum 'MyOptional' requires that 'MyValue' conform to 'Copyable'

Copyable の準拠に関わらず(= Copyable でないものも含めて)受け取るためには : ~Copyable と書いておきます。この場合、 MyOptional はコピーできないものを持つ可能性があるため、 MyOptional 自身も ~Copyable をつけてコピー不可にしておかなければいけません。

enum MyOptional<Wrapped: ~Copyable>: ~Copyable {
    case some(Wrapped)
    case none
}

【募集】所有権がわかる本または記事

AtomicMutex~Copyable

あらためて AtomicMutex を見てみましょう。

public struct Atomic<Value: AtomicRepresentable>: ~Copyable

出典:swift/stdlib/public/Synchronization/Atomics/Atomic.swift at 1af1af7d4509b68a0a3acf2cdce27b3eef9a0789 · swiftlang/swift

public struct Mutex<Value: ~Copyable>: ~Copyable

出典:swift/stdlib/public/Synchronization/Mutex/Mutex.swift at 4e0bdad17273ae6c28f6cbc0d602bd0a284bb060 · swiftlang/swift

ここから Copyable / ~Copyable について読み取れることは次の2つです。

  • AtomicMutex もコピー不可。
  • AtomicCopyable なものだけを受け取るが MutexCopyable でないものも扱える。

それぞれの理由を考察してみます。

AtomicMutex もコピー不可

AtomicMutex の大きな特徴のひとつとして、共有資源の生データを AtomicMutex 自身が保有しているという点があります。もし Int 型オブジェクトを受け取ったら、その Int のデータは Atomic / Mutex 自身のデータとして扱います。これらは値型なのでデータもスタック領域に置かれます。 OSAllocatedUnfairLockManagedBuffer を使うことでヒープ領域にデータを置いているのとは異なります。

ここで仮に Atomic / MutexCopyable でコピー可能になっていると中に隠した共有資源のデータまでコピーされてしまいます。各々が自由に共有資源からコピーを作り出して使ってしまったらもはやそれは共有資源ではありません。 os_unfair_lock の悪い例のようにロック機能がうまく働かない事態になってしまうかもしれません。

以上のリスクをなくすためには AtomicMutex~Copyable をつけてコピー不可にするのが確実です。そうすれば実行速度と安全性を両立することができます。

AtomicCopyable なものだけを受け取るが MutexCopyable でないものも扱える

AtomicMutex も共有資源を守るための仕組みですが、 Copyable でないものの扱いに違いがあります。 Atomic では扱うことができず、 Mutex では扱うことができます。これは、

  • Atomic は値をコピーするような使い方を想定している(から ~Copyable をつけた型を受け取るわけにはいかない)
  • Mutex はコピーされたら困る値でも問題なく扱える(から ~Copyable をつけた型を受け取っても問題ない)

と言い換えることができます。

Atomic~Copyable をつけた型を受け取るわけにはいかない

Atomic の中身は load(_:ordering:) などのメソッドを使えばいつでも誰でも取得できますし、各々が順番に値を読んだあとその値を使って同時に処理を始めることもできます。みんなが共有資源の所有権を持ちうる、共有資源の所有権が複数存在しうる状態にあります。 Copyable でないものとは利用の仕方が合わなさそうです。

Mutex では所有権を共有しない

Mutex で守られた共有資源に読み書きできるのは1度に1人までです。 withLock(_:) のクロージャを抜けたら再度ロックを獲得するまで共有資源へアクセスできません。これは Copyable でないオブジェクトを都度借用するのと同じ使い方です。

よって MutexCopyable でない共有資源でも安全に扱えるので、 Value: ~Copyable としても問題なさそうです。

本章のまとめ

本章では AtomicMutex と (~)Copyable の関係性を紹介しました。所有権を活用することで共有資源を適切に扱うことができます。

次章では AtomicMutex の内部で共有資源のデータがどのように管理されているのかを見ていきます。

本章では AtomicMutex がどのように共有資源のデータを保持しているのか紹介します。

メモリレイアウトとは

【募集】メモリレイアウトがわかる本または記事

Swiftのメモリレイアウト

【募集】クラスとかのメモリレイアウトがわかる本や記事

参考:Swiftのメモリレイアウトを調べる #Swift - Qiita

AtomicMutex のメモリレイアウト

AtomicMutex は共有資源のデータを自分で直接保持しています。そしてその共有資源を読み書きするときはアトミック操作/ロックという特殊な操作で行います。そんな AtomicMutex のために、Swiftコンパイラでは型に対して @_rawLayout(like: T) という属性を指定できるようになっています。この属性がついていると次の効果があります。

  • @_rawLayout をつけた型はraw storageと呼ばれる特殊なストレージで構成されるようになる。raw storageのメモリレイアウトは型 T と同等。つまり T 型オブジェクトをちょうど1つ格納できるだけのメモリ領域を与えられる。
  • @_rawLayout をつけた型はストアドプロパティを持つことができない。つまり保持できるデータはraw storageに格納した Value 型1つだけ。
  • 普段他の型でされているような管理をほとんど受けない。 @_rawLayout をつけた型の自己責任で、アトミック操作/ロックといった標準的でない方法を用いてストレージにアクセスする。
  • @_rawLayoutCopyable な型にはつけられない。

つまり @_rawLayout をつけると、共有資源をちょうど格納できるだけの(自己責任にはなりますが)好きに読み書きしていいストレージに変身します。

【募集】普段他の型でされているような管理を受けたくない理由。余計な管理がない方が早そうではある。

それぞれどのように @_rawLayout を使って共有資源を管理しているのでしょうか?

Atomic の場合

Atomic の場合、保持しておくデータは共有資源ひとつだけです。 Atomic 自体に @_rawLayout がついて Atomic 自身がraw storageとなっています。次のソースコードは Atomic についているアノテーションの一覧です。 Value.AtomicRepresentation 、つまりInt 型などにエンコード済みの共有資源を1つだけ格納できるようになっています。

@available(SwiftStdlib 6.0, *)
@frozen
@_rawLayout(like: Value.AtomicRepresentation)
@_staticExclusiveOnly
public struct Atomic<Value: AtomicRepresentable>: ~Copyable

出典:swift/stdlib/public/Synchronization/Atomics/Atomic.swift at 1af1af7d4509b68a0a3acf2cdce27b3eef9a0789 · swiftlang/swift

開発者が Atomic<Value> のイニシャライザ init(_:) に型 Value の値(共有資源の初期値)を渡すと次の処理が行われます。

  1. 新しく Atomic オブジェクトが作られる。 @_rawLayout により、 Atomic は型パラメータ Value が指す型と同じサイズのraw storageになる。つまり共有資源のデータひとつぶんのメモリが Atomic オブジェクト用のメモリとして確保される。
  2. ステップ1で確保した領域を渡された値で初期化する。

こちらは Atomic のイニシャライザです:

public init(_ initialValue: consuming Value) {
    _address.initialize(to: Value.encodeAtomicRepresentation(initialValue))
}

出典:swift/stdlib/public/Synchronization/Atomics/Atomic.swift at f4bea5f7c0a7aa5656c4c7e937631b8bec893413 · swiftlang/swift

_addressAtomic<Value> 自身を指す UnsafeMutablePointer です。 initialize(to:) を使うことで自身(raw storage)を初期化できます。

参考:initialize(to:) | Apple Developer Documentation

Mutex の場合

Mutex には共有資源以外に保有しなければいけないデータがあります。 _MutexHandle というSwiftコンパイラのinternalな構造体です。Darwin/Linux/Windows/WebAssemblyという環境ごとにXXXImpl.swiftというファイルがあり、そこに _MutexHandle を用意してそれぞれの環境のロック関数を呼んでいます。環境に応じた処理をするために _MutexHandle という依存オブジェクトを持っているというわけです。

環境ごとにどのソースコードを使うのかはCMakeLists.txtで設定しています。こちらはDarwin用の設定です:

set(SWIFT_SYNCHRONIZATION_DARWIN_SOURCES
  Mutex/DarwinImpl.swift
  Mutex/Mutex.swift
)

出典:swift/stdlib/public/Synchronization/CMakeLists.txt at 7ea2b3dfecb88659dc7ba4566ec2c26211c11ca5 · swiftlang/swift

Mutex_MutexHandle も保持しておかなければいけないため、 Mutex 自体に @_rawLayout をつけてraw storageとすることはできません。代わりに _Cell<Value> というraw storageを用意してそちらに共有資源のデータを保存しています:

@available(SwiftStdlib 6.0, *)
@frozen
@usableFromInline
@_rawLayout(like: Value, movesAsLike)
internal struct _Cell<Value: ~Copyable>: ~Copyable

出典:swift/stdlib/public/Synchronization/Cell.swift at 1af1af7d4509b68a0a3acf2cdce27b3eef9a0789 · swiftlang/swift

【募集】 movesAsLike がわかる本または資料

本章のまとめ

本章では AtomicMutex がどのように共有資源のデータを保持しているのか確認しました。もし両者が Copyable だと Atomic / Mutex が内部に隠しているこれらの共有資源をコピーしてしまって危険というわけです。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment