メモリの安全性
メモリにアクセスする際の競合を避けるようにコードを構成します。
デフォルトでは、Swiftはコード内での安全でない動作を防ぎます。例えば、Swiftは変数が使用される前に初期化されていること、メモリが解放された後にアクセスされないこと、配列のインデックスが範囲外エラーをチェックすることを保証します。
Swiftはまた、メモリの同じ領域への複数のアクセスが競合しないように、メモリの場所を変更するコードがそのメモリへの排他的アクセスを持つことを要求します。Swiftはメモリを自動的に管理するため、ほとんどの場合、メモリへのアクセスについて考える必要はありません。しかし、潜在的な競合が発生する場所を理解することは重要であり、メモリへの競合するアクセスを含むコードを書かないようにする必要があります。コードに競合が含まれている場合、コンパイル時または実行時にエラーが発生します。
メモリへの競合するアクセスの理解
メモリへのアクセスは、変数の値を設定したり、関数に引数を渡したりする際にコード内で発生します。例えば、次のコードには読み取りアクセスと書き込みアクセスの両方が含まれています:
// oneが格納されているメモリへの書き込みアクセス。
var one = 1
// oneが格納されているメモリからの読み取りアクセス。
print("We're number \(one)!")
メモリへの競合するアクセスは、コードの異なる部分が同時に同じメモリ領域にアクセスしようとする場合に発生する可能性があります。メモリの同じ場所への複数のアクセスが同時に発生すると、予測不可能または一貫性のない動作が生じる可能性があります。Swiftでは、複数行のコードにわたって値を変更する方法があり、その途中で値にアクセスしようとすることが可能です。
紙に書かれた予算を更新する方法を考えることで、同様の問題を見ることができます。予算の更新は2段階のプロセスです。まず、項目の名前と価格を追加し、次にリストに現在ある項目を反映するように合計金額を変更します。更新の前後では、予算から任意の情報を読み取ることができ、正しい答えが得られます。
項目を予算に追加している間、合計金額が新しく追加された項目を反映するように更新されていないため、一時的に無効な状態になります。項目を追加する過程で合計金額を読み取ると、誤った情報が得られます。
この例は、メモリへの競合するアクセスを修正する際に直面する可能性のある課題も示しています。競合を修正する方法が複数あり、それぞれが異なる答えを生み出すことがあり、どの答えが正しいかが明確でない場合があります。この例では、元の合計金額が欲しいのか、更新された合計金額が欲しいのかによって、$5または$320のどちらも正しい答えとなる可能性があります。競合するアクセスを修正する前に、それが何を意図していたのかを判断する必要があります。
注記
並行またはマルチスレッドコードを書いたことがある場合、メモリへの競合するアクセスは馴染みのある問題かもしれません。しかし、ここで議論されている競合するアクセスは、単一のスレッド内で発生する可能性があり、並行またはマルチスレッドコードを含みません。
単一のスレッド内でメモリへの競合するアクセスがある場合、Swiftはコンパイル時または実行時にエラーを保証します。マルチスレッドコードの場合、Thread Sanitizerを使用してスレッド間の競合するアクセスを検出するのに役立ててください。
メモリアクセスの特性
競合するアクセスの文脈で考慮すべきメモリアクセスの特性は、アクセスが読み取りか書き込みか、アクセスの期間、アクセスされているメモリの場所の3つです。具体的には、次の条件をすべて満たす2つのアクセスがある場合、競合が発生します:
- アクセスが両方とも読み取りではなく、両方ともアトミックではない。
- 同じメモリの場所にアクセスしている。
- アクセスの期間が重なっている。
読み取りアクセスと書き込みアクセスの違いは通常明白です:書き込みアクセスはメモリの場所を変更しますが、読み取りアクセスは変更しません。メモリの場所は、アクセスされているもの(例えば、変数、定数、プロパティ)を指します。メモリアクセスの期間は瞬間的か長期的かのいずれかです。
アクセスがアトミックである場合、それはAtomic
またはAtomicLazyReference
のアトミック操作への呼び出しであるか、Cのアトミック操作のみを使用します。そうでない場合は非アトミックです。Cのアトミック関数のリストについては、stdatomic(3)
のマニュアルページを参照してください。
アクセスが瞬間的である場合、そのアクセスが開始された後に他のコードが実行されることはありません。性質上、2つの瞬間的なアクセスが同時に発生することはありません。ほとんどのメモリアクセスは瞬間的です。例えば、以下のコードリストのすべての読み取りおよび書き込みアクセスは瞬間的です:
func oneMore(than number: Int) -> Int {
return number + 1
}
var myNumber = 1
myNumber = oneMore(than: myNumber)
print(myNumber)
// "2"と表示されます
しかし、他のコードの実行をまたぐ、長期的なアクセスと呼ばれるメモリアクセスの方法がいくつかあります。瞬間的なアクセスと長期的なアクセスの違いは、長期的なアクセスが開始された後に他のコードが実行される可能性があることです。これをオーバーラップと呼びます。長期的なアクセスは、他の長期的なアクセスや瞬間的なアクセスとオーバーラップすることがあります。
オーバーラップするアクセスは、主に関数やメソッドのin-outパラメータや構造体のミューテイティングメソッドを使用するコードに現れます。長期的なアクセスを使用する特定の種類のSwiftコードについては、以下のセクションで説明します。
in-outパラメータへの競合するアクセス
関数はすべてのin-outパラメータに対して長期的な書き込みアクセスを持ちます。in-outパラメータの書き込みアクセスは、すべての非in-outパラメータが評価された後に開始され、その関数呼び出しの全期間にわたって続きます。複数のin-outパラメータがある場合、書き込みアクセスはパラメータが現れる順序で開始されます。
この長期的な書き込みアクセスの結果の一つは、スコープルールやアクセス制御が許可する場合でも、in-outとして渡された元の変数にアクセスできないことです。元の変数へのアクセスは競合を引き起こします。例えば:
var stepSize = 1
func increment(_ number: inout Int) {
number += stepSize
}
increment(&stepSize)
// エラー: stepSizeへの競合するアクセス
上記のコードでは、stepSize
はグローバル変数であり、通常はincrement(_)
内からアクセス可能です。しかし、stepSize
への読み取りアクセスはnumber
への書き込みアクセスと重なります。以下の図に示すように、number
とstepSize
は同じメモリ位置を参照しています。読み取りアクセスと書き込みアクセスが同じメモリを参照し、重なるため競合が発生します。
この競合を解決する一つの方法は、stepSize
の明示的なコピーを作成することです:
// 明示的なコピーを作成
var copyOfStepSize = stepSize
increment(©OfStepSize)
// 元の値を更新
stepSize = copyOfStepSize
// stepSizeは現在2です
increment(_)
を呼び出す前にstepSize
のコピーを作成すると、copyOfStepSize
の値が現在のステップサイズによって増加することが明確になります。読み取りアクセスは書き込みアクセスが開始される前に終了するため、競合は発生しません。
in-outパラメータへの長期的な書き込みアクセスのもう一つの結果は、同じ関数の複数のin-outパラメータに対して単一の変数を引数として渡すと競合が発生することです。例えば:
func balance(_ x: inout Int, _ y: inout Int) {
let sum = x + y
x = sum / 2
y = sum - x
}
var playerOneScore = 42
var playerTwoScore = 30
balance(&playerOneScore, &playerTwoScore) // OK
balance(&playerOneScore, &playerOneScore)
// エラー: playerOneScoreへの競合するアクセス
上記のbalance(_:_:)
関数は、2つのパラメータを修正して合計値を均等に分割します。playerOneScore
とplayerTwoScore
を引数として渡すと競合は発生しません。2つの書き込みアクセスが時間的に重なりますが、異なるメモリ位置にアクセスします。対照的に、playerOneScore
を両方のパラメータの値として渡すと、同じメモリ位置に対して同時に2つの書き込みアクセスを行おうとするため競合が発生します。
注記
演算子も関数であるため、in-outパラメータに対して長期的なアクセスを持つことがあります。例えば、balance(_:_:)
が<^>
という名前の演算子関数であった場合、playerOneScore <^> playerOneScore
と書くとbalance(&playerOneScore, &playerOneScore)
と同じ競合が発生します。
メソッド内のselfへの競合するアクセス
構造体のミューテイティングメソッドは、メソッド呼び出しの期間中self
への書き込みアクセスを持ちます。例えば、各プレイヤーがダメージを受けると減少するヘルス量と、特殊能力を使用すると減少するエネルギー量を持つゲームを考えてみましょう。
struct Player {
var name: String
var health: Int
var energy: Int
static let maxHealth = 10
mutating func restoreHealth() {
health = Player.maxHealth
}
}
上記のrestoreHealth()
メソッドでは、メソッドの開始時にself
への書き込みアクセスが開始され、メソッドが戻るまで続きます。この場合、restoreHealth()
内にPlayer
インスタンスのプロパティに重複するアクセスを持つ他のコードはありません。以下のshareHealth(with:)
メソッドは、別のPlayer
インスタンスをin-outパラメータとして受け取り、重複するアクセスの可能性を作り出します。
extension Player {
mutating func shareHealth(with teammate: inout Player) {
balance(&teammate.health, &health)
}
}
var oscar = Player(name: "Oscar", health: 10, energy: 10)
var maria = Player(name: "Maria", health: 5, energy: 10)
oscar.shareHealth(with: &maria) // OK
上記の例では、オスカーのプレイヤーがマリアのプレイヤーとヘルスを共有するためにshareHealth(with:)
メソッドを呼び出しても競合は発生しません。ミューテイティングメソッド内でoscar
はself
の値であり、メソッド呼び出しの期間中oscar
への書き込みアクセスがあり、同じ期間中maria
への書き込みアクセスもあります。以下の図に示すように、異なるメモリ位置にアクセスします。2つの書き込みアクセスは時間的に重なりますが、競合は発生しません。
しかし、oscar
をshareHealth(with:)
の引数として渡すと競合が発生します:
oscar.shareHealth(with: &oscar)
// エラー: oscarへの競合するアクセス
ミューテイティングメソッドはメソッドの期間中self
への書き込みアクセスを必要とし、in-outパラメータは同じ期間中teammate
への書き込みアクセスを必要とします。メソッド内では、self
とteammate
は同じメモリ位置を参照します。以下の図に示すように、2つの書き込みアクセスは同じメモリを参照し、重なるため競合が発生します。
プロパティへの競合アクセス
構造体、タプル、列挙型のような型は、構造体のプロパティやタプルの要素など、個々の構成値で構成されています。これらは値型であるため、値の一部を変更すると全体の値が変更されます。つまり、プロパティの読み取りまたは書き込みアクセスには、全体の値への読み取りまたは書き込みアクセスが必要です。例えば、タプルの要素への重複する書き込みアクセスは競合を引き起こします。
var playerInformation = (health: 10, energy: 20)
balance(&playerInformation.health, &playerInformation.energy)
// エラー: playerInformation のプロパティへの競合アクセス
上記の例では、タプルの要素に対して balance(_:_:)
を呼び出すと競合が発生します。これは playerInformation
への重複する書き込みアクセスがあるためです。playerInformation.health
と playerInformation.energy
の両方が in-out パラメータとして渡されるため、balance(_:_:)
は関数呼び出しの間、それらへの書き込みアクセスが必要です。どちらの場合も、タプル要素への書き込みアクセスにはタプル全体への書き込みアクセスが必要です。これにより、重複する期間に playerInformation
への2つの書き込みアクセスが発生し、競合が生じます。
以下のコードは、グローバル変数に格納された構造体のプロパティへの重複する書き込みアクセスでも同じエラーが発生することを示しています。
var holly = Player(name: "Holly", health: 10, energy: 10)
balance(&holly.health, &holly.energy) // エラー
実際には、構造体のプロパティへのほとんどのアクセスは安全に重複することができます。例えば、上記の例で変数 holly
をグローバル変数ではなくローカル変数に変更すると、コンパイラは構造体の格納プロパティへの重複アクセスが安全であることを証明できます。
func someFunction() {
var oscar = Player(name: "Oscar", health: 10, energy: 10)
balance(&oscar.health, &oscar.energy) // OK
}
上記の例では、オスカーの健康とエネルギーが balance(_:_:)
の2つの in-out パラメータとして渡されます。コンパイラは、2つの格納プロパティが相互に干渉しないことを証明できるため、メモリの安全性が保たれることを証明できます。
構造体のプロパティへの重複アクセスに対する制限は、メモリの安全性を保つために常に必要というわけではありません。メモリの安全性は望ましい保証ですが、排他的アクセスはメモリの安全性よりも厳しい要件です。つまり、排他的アクセスに違反していても、メモリの安全性を保つコードもあります。コンパイラが非排他的アクセスが安全であることを証明できる場合、Swiftはこのメモリ安全なコードを許可します。具体的には、以下の条件が適用される場合、構造体のプロパティへの重複アクセスが安全であることを証明できます。
- インスタンスの格納プロパティのみをアクセスしている場合(計算プロパティやクラスプロパティではない)。
- 構造体がローカル変数の値である場合(グローバル変数ではない)。
- 構造体がクロージャによってキャプチャされていないか、非エスケープクロージャによってのみキャプチャされている場合。
コンパイラがアクセスが安全であることを証明できない場合、そのアクセスは許可されません。