この記事の要点
- Swift は、メモリ安全性のために「変数を変更している間は、その変数に別の名前経由でアクセスできない」という 排他的アクセス(Exclusive Access to Memory) のルールを持っています。これは SE-0176 で Swift 4.0 に導入され、コンパイル時(静的)と実行時(動的)の両方でチェックされます。
- 実行時チェックは Swift 4 では Debug ビルドのみ で有効でしたが、Swift 5 からは Release ビルドでも既定で有効 になりました。これにより、Debug でテストされていなかった違反コードが Release 実行時にトラップ(クラッシュ)する可能性があります。
- 多くの違反はコンパイル時に検出され、
letでローカルコピーを取るなど単純な修正で解決できます。実行時にしか検出できないケース(グローバル変数、クラスのプロパティ、staticプロパティ、エスケープするクロージャ経由など)では、Simultaneous accesses to ..., but modification requires exclusive accessというメッセージとともにトラップします。 - 実行時チェックはビルド設定で無効化できますが、Release で無効化すると、違反があった場合にクラッシュやメモリ破壊、将来の Swift での予測不能な挙動を招くため強く非推奨です。
背景: 排他的アクセスとは何か
Swift はメモリ安全性を実現するために、ある変数を変更するにはその変数への 排他的アクセス が必要だと定めています。言い換えると、ある変数が inout 引数として、あるいは mutating メソッド内の self として変更されている間は、同じ変数を別の名前経由でアクセスしてはいけません。
次の例では、count を inout 引数として渡すことで変更しています。同時に modifier クロージャがキャプチャした count を読み取っているため、排他的アクセス違反になります。modifyTwice の内部では count は value という inout 引数経由でのみ、クロージャ内では $0 経由でのみ安全にアクセスできます。
func modifyTwice(_ value: inout Int, by modifier: (inout Int) -> ()) {
modifier(&value)
modifier(&value)
}
func testCount() {
var count = 1
modifyTwice(&count) { $0 += count }
print(count)
}
このような違反では、プログラマの意図が曖昧になりがちです。count は 3 と表示されるべきでしょうか、4 でしょうか。どちらにせよコンパイラはその挙動を保証せず、最適化によって微妙に予測しづらい結果が生じることさえあります。こうした違反を防ぎ、安全性の保証に依存する言語機能を導入できるようにするために、排他的アクセスの強制は Swift 4.0 の SE-0176: Enforce Exclusive Access to Memory で初めて導入されました。
コンパイル時(静的)診断は多くのよくある違反を捕捉しますが、エスケープするクロージャ、クラス型のプロパティ、static プロパティ、グローバル変数が絡む違反は、実行時(動的)診断でしか捕捉できません。Swift 4.0 は両方の強制を備えていましたが、実行時の強制は Debug ビルドでのみ有効でした。Swift 4.1 / 4.2 ではコンパイル時診断が段階的に強化され、Swift 5 で言語モデルの残りの穴がふさがれ、Release ビルドでも実行時強制が既定で有効になりました。
Swift プロジェクトへの影響
Swift 5 での排他的アクセス強制は、既存プロジェクトに次の 2 つの形で影響し得ます。
-
実行時トラップ。ソースが排他的アクセスのルール(SE-0176)に違反していて、その違反コードが Debug のテストで実行されていなかった場合、Release バイナリの実行時にトラップする可能性があります。クラッシュ時には次の文字列を含む診断メッセージが出ます。
Simultaneous accesses to ..., but modification requires exclusive accessソースレベルの修正は通常単純です(次節に例があります)。
-
性能への影響。メモリアクセスチェックのオーバーヘッドが Release バイナリの性能に影響することがあります。多くの場合その影響は小さいですが、もし計測可能な性能低下が見られたらバグ報告が推奨されています。一般的な指針として、性能が重要なループの中ではクラスのプロパティアクセス(とくにループごとに別オブジェクトへのアクセス)を避けるとよく、避けられない場合はクラスプロパティを
private/internalにすると、コンパイラが「ループ内で同じプロパティに他のコードがアクセスしない」と証明しやすくなります。
これらの実行時チェックは Xcode の “Exclusive Access to Memory” ビルド設定(”Run-time Checks in Debug Builds Only” / “Compile-time Enforcement Only” を選べます)や、対応する swiftc フラグ -enforce-exclusivity=unchecked / -enforce-exclusivity=none で無効化できます。
ただし実行時チェックを無効化しても排他的アクセス違反が安全になるわけではありません。強制を切ると、ルールを守る責任はプログラマが負うことになります。Release ビルドでの無効化は強く非推奨です。違反があれば、クラッシュやメモリ破壊を含む予測不能な挙動を示し得ますし、今は正しく動いて見えても将来の Swift で問題が表面化したり、セキュリティ上の脆弱性が露呈したりする恐れがあります。
よくある違反と修正例
inout 引数とキャプチャの衝突
冒頭の testCount は、ローカル変数を inout 引数として渡しつつ、同じ変数をクロージャでキャプチャしているため違反します。これはコンパイル時に検出され、let でローカルコピーを取れば簡単に直せます。
let incrementBy = count
modifyTwice(&count) { $0 += incrementBy }
同じ値を二重に変更する
次の append(removingFrom:) は、別の配列から全要素を取り除きながら自分自身に追加するメソッドです。
extension Array {
mutating func append(removingFrom other: inout Array<Element>) {
while !other.isEmpty {
self.append(other.removeLast())
}
}
}
これを使って配列を自分自身に追加しようとすると、無限ループという予期しない挙動になります。ここでも「inout 引数同士はエイリアスになってはならない」という理由でコンパイル時にエラーになります。別の var にコピーしてから渡せば、2 つの変更は別々の変数に対するものになり衝突しません。
var toAppend = elements
elements.append(removingFrom: &toAppend)
実行時にしか検出されない違反
最初の例をローカル変数ではなくグローバル変数に変えると、コンパイラはエラーを出せなくなり、代わりに実行時に “Simultaneous access” 診断でトラップします。実行時診断は、衝突するアクセスがどこで始まったか(先行する変更アクセスと現在のアクセス)をバックトレース付きで示します。
Simultaneous accesses to ..., but modification requires exclusive access.
Previous access (a modification) started at Example`main + ....
Current access (a read) started at:
0 swift_beginAccess
1 closure #1
2 closure #2
3 Point.modifyX(_:)
Fatal access conflict detected.
衝突するアクセスは、次の例のように別々の文に現れることもあります。
struct Point {
var x: Int = 0
var y: Int = 0
mutating func modifyX(_ body:(inout Int) -> ()) {
body(&x)
}
}
var point = Point()
let getY = { return point.y }
// y の値を x にコピーする
point.modifyX {
$0 = getY()
}
クロージャ内で必要な値をあらかじめコピーしておけば、違反を避けられます。
let y = point.y
point.modifyX {
$0 = y
}
なお、point.x = point.y のように単純な代入(inout 引数のスコープを伴わない)で書けば、変更は瞬間的に完了するため、そもそも違反になりません。
値型のプロパティは「全体」として扱われる
上の Point の例で、point.x と point.y という別々のプロパティを読み書きしているのに違反になるのは、Point が struct(値型)だからです。値型ではすべてのプロパティが 1 つの値の一部であり、あるプロパティへのアクセスは値全体へのアクセスとみなされます。
ただしコンパイラは、単純な静的解析で安全性を証明できる場合は例外を認めます。とくに 同じ文 が互いに重ならない 2 つの stored property へのアクセスを開始するなら、違反として報告しません。次の例では modifyX を呼ぶ文が point.x を inout で渡しつつ point をキャプチャしますが、キャプチャ側は point.y にしかアクセスしないとコンパイラが見て取れるため、エラーになりません。
func modifyX(x: inout Int, updater: (Int)->Int) {
x = updater(x)
}
func testDisjointStructProperties(point: inout Point) {
modifyX(x: &point.x) { // 1 度目の point アクセス
let oldy = point.y // 2 度目の point アクセス
point.y = $0; // ルールの例外として許容される
return oldy
}
}
プロパティは次の 3 種類に分けられ、排他的アクセスの扱いが異なります。
- 値型のインスタンスプロパティ
- 参照型のインスタンスプロパティ
- あらゆる型の
static/ class プロパティ
このうち「全体へのアクセス」を要求するのは 1 番目(値型のインスタンスプロパティ)だけです。2 番目・3 番目は独立したストレージとして個別に強制されます。そのため上の Point を class に変えると、もとの違反は消えます。
class SharedPoint {
var x: Int = 0
var y: Int = 0
func modifyX(_ body:(inout Int) -> ()) {
body(&x)
}
}
var point = SharedPoint()
let getY = { return point.y } // modifyX 内で呼ばれても違反にならない
// y の値を x にコピーする
point.modifyX {
$0 = getY()
}
なぜ完全な強制が必要なのか
排他的アクセスを「プログラマの責任」に委ねるのではなく言語として完全に強制することには、次のような意義があります。
-
離れた場所での予期しない相互作用を防ぐ。プログラムが大きくなるほど、ルーチン同士が予想外に干渉しやすくなります。とくに参照型では、2 つの変数が同じオブジェクトを参照することで、同じインスタンスを移動元と移動先の両方に渡してしまい(下の例)、無限ループのような事故が起きやすくなります。排他的アクセス強制はこれを未然に防ぎます。
func moveElements(from src: inout Set<String>, to dest: inout Set<String>) { while let e = src.popFirst() { dest.insert(e) } } class Names { var nameSet: Set<String> = [] } func moveNames(from src: Names, to dest: Names) { moveElements(from: &src.nameSet, to: &dest.nameSet) } var oldNames = Names() var newNames = oldNames // 参照型では自然にエイリアスが起きる moveNames(from: oldNames, to: newNames) - 未規定の挙動ルールを言語から取り除く。Swift 4 以前は、排他的アクセスは正しい挙動のために必要でありながら強制されていませんでした。違反は微妙な形で容易に起き、とくにコンパイラのバージョン間で予測不能な挙動を招きました。
- ABI 安定性に必要。完全に強制しないと ABI 安定性への影響が予測不能になります。強制なしでビルドされた既存バイナリは、あるリリースでは正しく動いても、将来のコンパイラ・標準ライブラリ・ランタイムで誤動作し得ます。
- メモリ安全性を保ったまま最適化を可能にする。
inout引数とmutatingメソッドにおける排他性の保証は、メモリアクセスや参照カウント操作の最適化に使える重要な情報をコンパイラに与えます。完全な強制があるからこそ、メモリ安全性を犠牲にせずにこの最適化が行えます。 - 所有権と move-only 型の基盤になる。排他性のルールは、プログラマに所有権と move-only 型の制御を与えるための土台となります。
まとめ
Swift 5 は Release ビルドで排他的アクセスの完全な強制を有効にして出荷することで、バグやセキュリティ上の問題を取り除き、バイナリ互換性を確保し、将来の最適化や言語機能を可能にしています。Debug でしかテストされていなかったコードが Release でトラップし得る点には注意が必要ですが、違反の多くはコンパイル時に検出され、ローカルコピーを取るといった単純な修正で解決できます。
関連リンク
- SE-0176: Enforce Exclusive Access to Memory — 排他的アクセスのルールと強制の詳細を定めた元の Proposal
- Swift のメモリ安全性(The Swift Programming Language)
- Swift 4.2 リリース — コンパイル時診断が強化された Swift 4.2 の公式リリース告知