Swift Digest
SE-0176 | Swift Evolution

Enforce Exclusive Access to Memory

Proposal
SE-0176
Authors
John McCall
Review Manager
Ben Cohen
Status
Implemented (Swift 4.0)

01 何が問題だったのか

Swift 3 までは、ひとつの変数に対する「読み書き」と「別の読み書き」が時間的に重なってしまう(= 同じ変数への overlapping access が発生する)コードが書けてしまい、結果として挙動が直感と食い違ったり、コンパイラが最適化をあきらめたりする原因になっていました。

「一瞬では終わらないアクセス」がある

Swift における多くのメモリアクセスは「瞬間的」です。たとえば stored property に値を代入する、あるいは読み出すといった操作は、そのあいだに他のコードが割り込む余地がないため、複数のアクセスが重なることはありません。

しかし、すべてのアクセスが瞬間的なわけではありません。代表例が ミュータブルメソッド(mutating メソッド)inout 引数 です。これらは「メソッド本体の実行中ずっと、その変数を使っている」という長いアクセスとして扱われ、その最中にまた同じ変数へ触れると、アクセスが重なってしまいます。

var global: Int = 0
var total: Int = 0

extension Int {
    mutating func increaseByGlobal() {
        // mutating の本体のあいだ、呼び出し元の変数はずっと self として
        // アクセスされ続けている。
        total += self   // self が total だったら? total を2つの経路から触る
        self += global  // self が global だったら? global を2つの経路から触る
    }
}

self がたまたま totalglobal と同じ変数だった場合、表面上は片方しか触っていないように見える行が、実際には同じ変数を二重に読み書きしてしまいます。読み手は「この行は global を変えない」と思い込みがちですが、global.increaseByGlobal() のように呼ばれると突然 global が倍になる、といった事態が起こり得ます。

クロージャや inout も巻き込まれる

同じ問題は、クロージャを受け取るメソッドや inout 引数でも起きます。

extension Array {
    mutating func modifyElements(_ closure: (inout Element) -> ()) {
        var i = startIndex
        while i != endIndex {
            closure(&self[i])           // 本体の実行中に self を触られると
            i = index(after: i)         // i が無効なインデックスになりうる
        }
    }
}

クロージャが中で self(= 配列)を書き換えてしまうと、手元の i が不正なインデックスに化ける可能性があります。実際に起こらなかったとしても、「起こりうる」というだけでコンパイラは重要な最適化(例: copy-on-write の一意性チェックをループ外に巻き上げる)を適用できなくなります。

open class Person {
    open var title: String
}

func collectTitles(people: [Person], into set: inout Set<String>) {
    for person in people {
        set.insert(person.title)  // open な title ゲッタが set を書き換えないと保証できない
    }
}

open なプロパティのゲッタがどんな実装をしているかは呼び出し側からは分からないため、コンパイラは「呼び出しの合間に set が外から書き換えられているかもしれない」という最悪ケースを想定せざるを得ず、保守的なコピーやロードを大量に挿入することになります。

「一瞬では終わらないアクセス」自体は消せない

では、そもそもミュータブルメソッドや inout のような長いアクセスをやめればよいのでしょうか。実はそれも現実的ではありません。

長いアクセスを避けるためには、呼び出しのたびに変数の値を一時コピーに取り、メソッドから戻ったあとに書き戻すしかありません。しかしこれは 2 つの深刻な問題を抱えています。

  • 性能が大きく落ちる。特に Array のような copy-on-write 型では、一時コピーを作った時点でバッファの参照カウントが増え、本来 in-place でできた変更のためにバッファ全体を複製することになります。Ownership 機能の狙いである「不要なコピーを減らす」方向と真っ向から衝突します。
  • 意味の曖昧さはむしろ悪化する。一時コピー経由で操作している最中に、元の変数(たとえばグローバル変数やクラスのプロパティ)が別経路で読み書きされても、その変更はコピーには反映されず、メソッドの結果を書き戻すときに黙って上書きされて消えてしまう、という不思議な挙動が生まれてしまいます。

つまり、「長いアクセス」は Swift が性能と表現力を両立するうえで捨てられない仕組みであり、それを許容したまま overlap だけを禁止する仕組みが必要だったのです。これが Law of Exclusivity、すなわち本提案が導入する排他アクセス規則のモチベーションです。

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

Swift に、同じ変数への2つのアクセスは、両方が読み取りでない限り重なってはいけない、という言語ルール(Law of Exclusivity)を追加します。ここでいう「変数」とは、グローバル変数・ローカル変数・inout 引数・struct のプロパティ・クラスのプロパティなど、ミュータブルなメモリ全般を指します。

この規則は、変数の種類に応じてできるだけ静的に、必要な場合は動的に検査されます。イミュータブル(let の束縛やプロパティなど、読み取りしか起きない領域)については検査そのものが不要です。

何が「アクセスの重なり」になるのか

基本は次の2点です。

  • inout 引数として変数を渡す、あるいはミュータブルメソッドを呼ぶことは、その呼び出しのあいだずっと続く書き込みアクセスとみなされる。
  • 読み取りアクセスは互いに重なっても構わない。重なると違反になるのは「書き込み vs 何か」の組のみ。

具体例で見ます。

var x = 0, y = 0

// OK: 2回の読み取りは瞬間的で重ならない。仮に重なっても両方読み取りなので衝突なし。
let z = x + x

// OK: 右辺は瞬間的な読み取り、左辺は瞬間的な書き込み。重ならない。
x = x

// OK: 右辺の読み取りが終わってから、 += の inout 書き込みが始まる。
x += x

// NG: swap(&x, &x) は同じ変数に対する2つの書き込みアクセスを同時に持つ。
swap(&x, &x)

extension Int {
    mutating func assignResultOf(_ function: () -> Int) {
        self = function()
    }
}

// NG: mutating メソッド呼び出しは self への書き込みアクセスが続く間、
// クロージャの中で同じ変数を読むと重なる。
x.assignResultOf { x + 1 }

静的検査と動的検査

検査方法は変数の種類で変わります。

  • ローカル変数・inout 引数・struct のプロパティ: コンパイラがフロー解析によって静的に検査し、違反はコンパイルエラーになる。
  • グローバル変数・クラスのプロパティ: ランタイムが「現在進行中のアクセス」を記録し、新しいアクセスが始まるたびに衝突を検査する。違反は実行時エラーになる。ローカル変数でも、クロージャにキャプチャされて escape する場合は動的検査に切り替わる。
  • UnsafePointer など unsafe なAPI: 検査は行わず、規則を守るのはプログラマの責任となる(違反すれば未定義動作)。

並行処理との関係

同じ変数に対する読み/書きや書き/書きの競合は、もともと Swift では未定義動作であり、適切な同期機構を使って避けるのはプログラマの責任とされてきました。この提案でもその立場は変わりません。

動的検査はアクセス情報をスレッドローカルに保持して同期コストを避けることを優先し、既定では並行実行中の衝突を検知することを目的にしません。これにより動的検査を常時有効にできるだけの軽量さを確保します(実装が検知することは妨げられず、ビルド構成によってオプトインでスレッドセーフな検査を有効化することも可能)。並行由来のデータ競合は、後続の並行性機能で別途扱うことを見据えた設計です。

値型では「値全体」へのアクセスになる

値型(structenum、タプル)上でメソッドやsubscript、computed propertyを使うことは、原則として値全体へのアクセスとして扱われます。mutating なら書き込み、そうでなければ読み取りです。「このメソッドはこのプロパティしか触らない」といった細かなルールは、言語仕様を大きく複雑化するため採用されません。

ただし、struct の異なる stored property(あるいはタプルの異なる要素)へのアクセスは互いに重ねて構いません。

struct Pair {
    var x: Int
    var y: Int
}

var pair = Pair(x: 0, y: 0)
swap(&pair.x, &pair.y)  // OK: 異なる stored property に対する2つの書き込み

一方、クラスのプロパティなど動的検査に回るプロパティの場合は、たとえ内部が別の stored property でも、プロパティ単位での排他チェックが先に働きます。

class Paired {
    var pair = Pair(x: 0, y: 0)
}

let object = Paired()
swap(&object.pair.x, &object.pair.y)
// 実行時エラー: object.pair への2つの書き込みアクセスが重なる

このようなケースは、object.pair をいったん inout 引数として束ねてしまえば回避できます。

func modifying<T>(_ value: inout T, _ function: (inout T) -> ()) {
    function(&value)
}

modifying(&object.pair) { pair in swap(&pair.x, &pair.y) }

inout 引数の中では静的検査に切り替わり、pair.xpair.y は異なる stored property として安全に同時アクセスできます。なお、異なるプロパティを独立に扱えるのは両方が stored であると分かっているときだけで、resilient な値型(別モジュールの値型など、stored かどうかが外から見えない型)では同時アクセスは許可されません。

配列では要素アクセスも「配列全体」への書き込み

Array の添字アクセスは通常の computed subscript として扱われるため、要素を変更することは配列全体への書き込みアクセスになります。これにより、次のようなコードは衝突になります。

var array = [[1, 2], [3, 4, 5]]

// OK: 瞬間的な読み取りどうし
print(array[0] + array[1])

// OK: 右辺の読み取りが完了してから左辺の書き込みが始まる
array[0] += array[1]

// NG: 同じ配列変数に対する2つの書き込みアクセスが重なる
swap(&array[0], &array[1])

// NG: array[0] への読み取り中に array[1] への書き込み mutating が走る
array[0].forEach { array[1].append($0) }

swap(&array[i], &array[j]) の代替として、同時期の SE-0173 で MutableCollection.swapAt(_:_:) が追加され、2つのインデックスを受け取る形で要素を安全に入れ替えられます。Swift 3 互換モードや 3-to-4 マイグレータはこのパターンを自動的に swapAt に書き換えます。より込み入ったケースは、当面 withUnsafeMutableBufferPointer などで回避することが想定されています。

クラスは「プロパティごと」に検査

クラスでは、値型と違ってインスタンス全体へのアクセスという概念を導入せず、排他性はあくまで個々の stored property 単位で検査されます。したがって、同じインスタンスに対する別プロパティへのアクセスは自由に重ねられ、メソッド呼び出しもインスタンス全体をロックしません。

これは、クラス(参照型)では複数スレッドから並行にメソッドが呼ばれたり、ロックやキューで同期しながら複数プロパティを更新したりといった使い方が当たり前である、という事情を踏まえた設計です。逆に値型は「その値を保持する変数」に対してユニークなアクセスを前提にできるため、より強い保証を与えることに価値があります。

動的検査を意図的に無効化する

性能が本当にクリティカルな箇所や、既に上位のレイヤーで排他性が保証されている箇所のために、プロパティ単位で動的検査をオプトアウトする属性 @exclusivity(unchecked) が用意されます。指定すると UnsafePointer と同じ扱いになり、規則違反は未定義動作になります。

class TreeNode {
    @exclusivity(unchecked) var left: TreeNode?
    @exclusivity(unchecked) var right: TreeNode?
}

クロージャとescapingの関係

クロージャが escaping か non-escaping かで、キャプチャされた変数の扱いが変わります。

  • non-escaping クロージャにキャプチャされた変数は、静的検査で排他性を保証できる(= ヒープに逃がす必要がなく、C 並みの性能を保てる)。
  • escaping クロージャにキャプチャされた変数は、いつ呼ばれるか静的には追えないため、動的検査に切り替わる。

非 escaping 変数について静的検査を保証するために、「non-escaping クロージャの中から、同じ変数をキャプチャする別の non-escaping クロージャを再帰的に呼び出さない」という制約が追加されます(Non-Escaping Recursion Restriction, NRR)。実用上この規則に抵触するのは非常に稀ですが、利用者から見える形では次のような形で現れます。

  • NPCR(Non-Escaping Parameter Call Restriction): ある関数が、受け取った non-escaping 関数パラメータを、別の non-escaping 関数パラメータに引数として渡すことができなくなる。キャプチャ経由でも同様に扱われる。
// これは NPCR に違反する
func recurse(fn: (() -> ()) -> ()) {
    fn { fn { } }  // non-escaping な fn を、別の non-escaping な fn の引数内で呼んでいる
}

func apply<T>(argProvider: () -> T, fn: (() -> T) -> T) {
    fn(argProvider)  // non-escaping 同士の受け渡しになるため NPCR 違反
}

どうしても必要な場合は、引数を @escaping にするか、withoutActuallyEscaping を使って明示的に一時 escape させ、再帰呼び出しが NRR に違反しないことをプログラマが保証します。

静的に衝突を指摘できる場合

動的検査が前提の変数でも、コンパイラが「このコードは必ず衝突を起こす/起こし得ない」と証明できれば、その場でエラーとして報告されます。たとえば次のコードはグローバル変数への明白な二重書き込みとして静的に弾かれます。

var global: Int = 0
swap(&global, &global)  // コンパイル時エラー

mutating メソッドに渡したクロージャから同じグローバル変数を参照するケースも、実際にクロージャが呼ばれるかどうかを問わず、静的に衝突と判定できます。