Swiftでプログラミング-Memory Safety
デフォルトでは、Swiftは危険な動作が発生を防ぐようにコードを打てるようになっています。 たとえば、Swiftは、変数が使用される前に初期化し、割り当てが解除された後にメモリにアクセスしないように、配列であるば範囲外のエラーがないかチェックしています。
Swiftではメモリ内の場所を変更するとき、そのメモリへの排他的アクセスを要求することにより、メモリの同じ領域への複数のアクセスが競合しないようにします。 Swiftはメモリを自動的に管理するため、ほとんどの場合、メモリへのアクセスについて考える必要はありません。 ただし、潜在的な競合が発生する可能性がある場所を理解することが重要です。これにより、メモリへのアクセスが競合するコードを記述しないようにすることができます。 コードに競合が含まれている場合は、コンパイル時または実行時のエラーが発生します。
Understanding Conflicting Access to Memory
変数の値を設定したり、関数に引数を渡したりすると、メモリへのアクセスが発生します。 たとえば、次のコードには、読み取りアクセスと書き込みアクセスの両方が含まれています。
// A write access to the memory where one is stored.
var one = 1
// A read access from the memory where one is stored.
print("We're number \(one)!")
コードの異なる部分がメモリ内の同じ場所に同時にアクセスしようとすると、メモリへのアクセスの競合が発生する可能性があります。 メモリ内の場所に同時に複数回アクセスすると、予測できない、または一貫性のない動作が発生する可能性があります。 Swiftには、コードの数行にまたがる値を変更する方法があり、それ自体の変更の途中で値にアクセスを試みることができます。
一枚の紙に書かれた入れ物をどのように更新するかを考えると、同様の問題を見ることができます。 budgetの更新は2段階のプロセスです。最初にアイテムの名前と価格を追加し、次に現在リストにあるアイテムを反映するように合計金額を変更します。 次の図に示すように、更新の前後に、budgetから任意の情報を読み取り、正しい答えを得ることができます。
budgetにアイテムを追加している間、新しく追加されたアイテムを反映するように合計金額が更新されていないため、一時的に無効な状態になっています。アイテムを追加するプロセス中に合計金額を読み取ると、誤った情報が得られます。
この例は、メモリへの競合するアクセスを修正するときに発生する可能性のある課題も示しています。競合を修正する方法が複数ある場合があり、異なる回答が生成されます。どの回答が正しいかが常に明確であるとは限りません。この例では、元の合計金額と更新された合計金額のどちらが必要かによって、5ドルまたは320ドルのどちらかが正解である可能性があります。競合するアクセスを修正する前に、それが何を意図していたかを判断する必要があります。
並行コードまたはマルチスレッドコードを作成した場合、メモリへのアクセスの競合はおなじみの問題である可能性があります。ただし、ここで説明する競合するアクセスはシングルスレッドで発生する可能性があり、同時コードまたはマルチスレッドコードは含まれません。
単一のスレッド内からメモリへのアクセスが競合している場合、Swiftはコンパイル時または実行時にエラーが発生することを保証します。マルチスレッドコードの場合は、Thread Sanitizerを使用して、スレッド間で競合するアクセスを検出します。
Characteristics of Memory Access
競合するアクセスのコンテキストで考慮すべきメモリアクセスには、3つの特性があります。アクセスが読み取りか書き込みか、アクセスの期間、およびアクセスされているメモリ内の場所です。具体的には、次のすべての条件を満たす2つのアクセスがある場合に競合が発生します。
・少なくとも1つは、書き込みアクセスまたは非アトミックアクセスです。
・それらはメモリ内の同じ場所にアクセスします。
・それらの期間は重複しています。 注:アトミックとは、1つのスレッドのみが変数にアクセスすることを意味
通常、読み取りアクセスと書き込みアクセスの違いは明らかです。書き込みアクセスはメモリ内の場所を変更しますが、読み取りアクセスは変更しません。メモリ内の場所は、アクセスされているもの(変数、定数、プロパティなど)を指します。メモリアクセスの期間は、瞬間的または長期的です。
Cアトミック操作のみを使用する場合、操作はアトミックです。それ以外の場合は非アトミックです。
アクセスの開始後、終了前に他のコードを実行できない場合、アクセスは瞬時に行われます。その性質上、2つの瞬間的なアクセスを同時に行うことはできません。ほとんどのメモリアクセスは瞬時に行われます。たとえば、以下のコードリストのすべての読み取りおよび書き込みアクセスは瞬時に行われます。
func oneMore(than number: Int) -> Int {
return number + 1
}
var myNumber = 1
myNumber = oneMore(than: myNumber)
print(myNumber)
// Prints "2"
ただし、他のコードの実行にまたがる、長期アクセスと呼ばれるメモリにアクセスする方法はいくつかあります。 即時アクセスと長期アクセスの違いは、長期アクセスの開始後、終了前に他のコードを実行できることです。これはオーバーラップと呼ばれます。 長期アクセスは、他の長期アクセスや瞬間アクセスと重複する可能性があります。
重複するアクセスは、主に、関数やメソッドでin-outパラメーターを使用するコード、または構造体のメソッドを変更するコードに現れます。
Conflicting Access to In-Out Parameters
関数には、そのすべての入出力パラメーターへの長期書き込みアクセス権があります。 入出力パラメーターの書き込みアクセスは、すべての非入出力パラメーターが評価された後に開始され、その関数呼び出しの全期間にわたって継続します。 複数の入出力パラメーターがある場合、書き込みアクセスはパラメーターが表示されるのと同じ順序で開始されます。
この長期の書き込みアクセスの結果の1つは、スコープルールとアクセス制御で許可されている場合でも、inoutとして渡された元の変数にアクセスできないことです。元の変数にアクセスすると、競合が発生します。 例えば:
var stepSize = 1
func increment(_ number: inout Int) {
number += stepSize
}
increment(&stepSize)
// Error: conflicting accesses to stepSize
上記のコードでは、stepSizeはグローバル変数であり、通常はincrement(_ :)内からアクセスできます。 ただし、stepSizeへの読み取りアクセスは、numberへの書き込みアクセスと重複します。 次の図に示すように、numberとstepSizeの両方がメモリ内の同じ場所を参照しています。 読み取りアクセスと書き込みアクセスは同じメモリを参照し、それらが重複して競合を引き起こします。
この競合を解決する1つの方法は、stepSizeの明示的なコピーを作成することです。
// Make an explicit copy.
var copyOfStepSize = stepSize
increment(©OfStepSize)
// Update the original.
stepSize = copyOfStepSize
// stepSize is now 2
インクリメント(_ :)を呼び出す前にstepSizeのコピーを作成すると、copyOfStepSizeの値が現在のステップサイズだけインクリメントされることは明らかです。 読み取りアクセスは書き込みアクセスが開始する前に終了するため、競合は発生しません。
inoutパラメータへの長期書き込みアクセスの別の結果は、同じ関数の複数のinoutパラメータの引数として単一の変数を渡すと競合が発生することです。 例えば:
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)
// Error: conflicting accesses to playerOneScore
上記のbalance(_:_ :)関数は、2つのパラメーターを変更して、合計値をそれらの間で均等に分割します。 引数としてplayerOneScoreとplayerTwoScoreを使用して呼び出すと、競合は発生しません。時間的に重複する2つの書き込みアクセスがありますが、それらはメモリ内の異なる場所にアクセスします。 対照的に、playerOneScoreを両方のパラメーターの値として渡すと、メモリ内の同じ場所に同時に2回の書き込みアクセスを実行しようとするため、競合が発生します。
演算子は関数であるため、in-outパラメーターに長期間アクセスすることもできます。 たとえば、balance(_:_ :)が<^>という名前の演算子関数である場合、playerOneScore <^> playerOneScoreを書き込むと、balance(&playerOneScore、&playerOneScore)と同じ競合が発生します。
Conflicting Access to self in Methods
構造体の変更メソッドは、メソッド呼び出しの間、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
上記の例では、oscarのPlayerがmariaのPlayerとhealthを共有するためにshareHealth(with :)メソッドを呼び出しても、競合は発生しません。 oscarはmutatingメソッドのselfの値であるため、メソッド呼び出し中にoscarへの書き込みアクセスがあります。また、mariaがin-outパラメーターとして渡されたため、同じ期間、mariaへの書き込みアクセスがあります。 次の図に示すように、メモリ内のさまざまな場所にアクセスします。 2つの書き込みアクセスは時間的に重複していますが、競合することはありません。
ただし、shareHealth(with :)の引数としてオスカーを渡すと、競合が発生します。
oscar.shareHealth(with: &oscar)
// Error: conflicting accesses to oscar
変更メソッドは、メソッドの期間中、selfへの書き込みアクセスを必要とし、in-outパラメーターは、同じ期間、チームメイトへの書き込みアクセスを必要とします。 次の図に示すように、メソッド内では、自己とチームメイトの両方がメモリ内の同じ場所を参照します。 2つの書き込みアクセスは同じメモリを参照し、それらが重複して競合を引き起こします。
Conflicting Access to Properties
構造、タプル、列挙などのタイプは、構造のプロパティやタプルの要素など、個々の構成値で構成されます。 これらは値型であるため、値の任意の部分を変更すると、値全体が変更されます。つまり、プロパティの1つへの読み取りまたは書き込みアクセスには、値全体への読み取りまたは書き込みアクセスが必要です。 たとえば、タプルの要素への書き込みアクセスが重複すると、競合が発生します。
var playerInformation = (health: 10, energy: 20)
balance(&playerInformation.health, &playerInformation.energy)
// Error: conflicting access to properties of playerInformation
上記の例では、playerInformationへの書き込みアクセスが重複しているため、タプルの要素でbalance(_:_ :)を呼び出すと競合が発生します。 playerInformation.healthとplayerInformation.energyの両方が入出力パラメーターとして渡されます。つまり、関数呼び出しの間、balance(_:_ :)はそれらへの書き込みアクセスが必要です。 どちらの場合も、タプル要素への書き込みアクセスには、タプル全体への書き込みアクセスが必要です。 これは、期間が重複するplayerInformationへの2つの書き込みアクセスがあり、競合が発生することを意味します。
以下のコードは、グローバル変数に格納されている構造のプロパティへの重複する書き込みアクセスに対して同じエラーが表示されることを示しています。
var holly = Player(name: "Holly", health: 10, energy: 10)
balance(&holly.health, &holly.energy) // Error
実際には、構造のプロパティへのほとんどのアクセスは安全に重なり合う可能性があります。 たとえば、上記の例の変数hollyがグローバル変数ではなくローカル変数に変更された場合、コンパイラは、構造体の格納されたプロパティへの重複アクセスが安全であることを証明できます。
func someFunction() {
var oscar = Player(name: "Oscar", health: 10, energy: 10)
balance(&oscar.health, &oscar.energy) // OK
上記の例では、 oscarのhealthとenergyは、関数balance(_:_ :)に2つの入出力パラメーターとして渡されます。コンパイラは、格納されている2つのプロパティがまったく相互作用しないため、メモリの安全性が維持されていることを証明できます。
構造体のプロパティへの重複アクセスに対する制限は、メモリの安全性を維持するために必ずしも必要ではありません。メモリの安全性は望ましい保証ですが、排他的アクセスはメモリの安全性よりも厳しい要件です。つまり、一部のコードは、メモリへの排他的アクセスに違反していても、メモリの安全性を維持します。 Swiftは、コンパイラがメモリへの非排他的アクセスが依然として安全であることを証明できる場合、このメモリセーフコードを許可します。具体的には、次の条件が当てはまる場合、構造のプロパティへの重複アクセスが安全であることを証明できます。
インスタンスの保存プロパティにのみアクセスし、計算プロパティやクラスのプロパティにはアクセスしません。
構造はローカル変数の値であり、グローバル変数ではありません。
構造はクロージャによってキャプチャされないか、エスケープしないクロージャによってのみキャプチャされます。
コンパイラがアクセスが安全であることを証明できない場合、コンパイラはアクセスを許可しません。