今回は、このところ寄り道をして眺めていっている データレース
と レースコンディション
まわりについて、前回に読み進めていたブログの最後に記されている具体例のところを見ていきます。前回までの理屈的なところを具体例に照らして整理したらイメージを掴みやすくなるかもしれないです。よろしくお願いしますね。
———————————————————————————————————— 熊谷さんのやさしい Swift 勉強会 #224
00:00 開始 00:10 今回の展望 01:37 データが壊れる可能性は意識してみる 02:27 データ競合と競合状態 03:03 それぞれの違いを具体例で見てみる 03:26 不変条件を不変に保つ 05:15 データ競合の起こるコード例 06:36 これがデータ競合の場面? 09:23 std::atmic 10:10 データ競合は起こらなくても競合状態は起こる 15:04 不可分操作を想定した変数 15:46 Objective-C では不可分操作を指定できた 17:08 Swift Atomics というのがあるらしい 18:17 競合状態を防ぐこととは別の話 19:18 ミューテックスによるデータ競合と競合状態の回避例 21:11 その他の排他制御について 22:40 Swift で競合状態を回避してみる 25:13 Concurrency による競合状態の回避 26:47 排他制御によるクリティカルセクションの保護 30:44 クリティカルセクションは、普段どれくらいあるものなのだろう。 31:38 クロージングと次回の展望 ————————————————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #224
では、始めていきますね。今日も引き続き、データ競合と競合状態について確認していきます。前回の勉強会で、このブログをもう一度見直そうという話になって終わりましたが、特に取り上げるべき特別なことは見当たりませんでした。ただ、具体例が残っていましたね。
勉強会の前回の終わりの部分で、「こういうことが書いてある」と聞きましたが、実際にその通りでした。その部分については前回までで話せたかなという感じがありますが、この具体例をもう一度見ていくことで理解が整理されると思うので、今日はその点を確認しようと思います。これまでの内容を押さえるという意味でも、その感覚で改めて見てもらえれば、データ競合と競合状態については一通り理解できるかなという印象です。
データ競合と競合状態は、ブログのタイトルにもあるように、混同しないように注意が必要です。実際には、それほど言葉にこだわらなくても、「こういう状況ではデータが壊れる可能性がある」くらいに理解しておけば十分だと思います。さらに詳細に話す必要があるときに、二つの問題を意識すればいいでしょう。
では、おさらいをしましょう。データ競合とは、同時に同じメモリ領域を共有して読み書きを行うと、その書き込み中の不完全なデータが取得されることを指します。一方、競合状態とは、プログラムの処理中に割り込まれると処理が不完全になり、正しく動作しなくなることです。
データ競合の具体例として、C++での銀行口座間の送金処理を見てみます。この例では、システムの普遍条件として「残高が0以上」という前提が設定されています。借金は存在しないという条件です。普遍条件とは、ある処理の間でその条件が真であり続けることを指します。
具体的な送金処理の前提として、送金元口座に十分な残高がある場合にのみ送金が成功するという条件があります。そのためには、送金額が残高を超えないことが必要です。預金口座間でお金の要求が同時に行われる状況を考えてみると、データ競合が発生する可能性があります。
データ競合が起こるコードの例を見てみましょう。以下のようなコードを考えます:
int myAccountBalance = 200; // 自分の口座の残高
int yourAccountBalance = 100; // 他人の口座の残高
void transfer(int& from, int& to, int amount) {
if (from >= amount) {
from -= amount;
to += amount;
printf("Transfer successful.\\n");
} else {
printf("Transfer failed.\\n");
}
}
このコードでは、transfer
関数が送金元と送金先の残高を受け取り、送金額を送金元から引いて送金先に足します。もし送金元の残高が送金額を上回れば、送金は成功します。そうでなければ、送金は失敗します。
以上、今日はデータ競合と競合状態についての具体例を確認しました。これでこのテーマについては一通り理解できたと思います。 このときにレースコンディションが起こる可能性があるという話をしています。具体的には、ある操作を +=
や -=
などの演算子を使って行ったときに未定義動作が発生する可能性があります。この未定義動作が起こる原因は、複数のスレッドが同時に同じメモリにアクセスしようとするためです。
基本的に、比較操作でも同様のことが言えます。例えば、ある変数 M
が固定されていて、それを他の変数と比較する場合、比較の直前にその変数が他のスレッドによって変更される可能性があります。このような場合には、システム全体がどのような動作をするか保証できません。
未定義動作が発生するリスクがある場合には、対策が必要です。これは一般にプリミティブなレイヤーで発生する問題であり、同時アクセスによってデータが壊れることを意味します。プログラムの一部で未定義動作を引き起こす可能性があるため、アトミック操作を用いる必要があります。アトミック操作を利用することで、演算子による変更が一貫して行われることが保証されます。こうすることで、安全に計算を進めることができます。
例えば、std::atomic
のようなアトミック操作を活用することが推奨されます。これにより、-=
やその他の演算子がアトミックに扱われ、安全に保護されます。こうした工夫により、データレースが起こるリスクを回避できます。
具体例として、レースコンディションが起こりうるコードをアトミック操作で書き換える場合があります。これは、特定の演算がアトミックとして保護されることを保証し、同時アクセスの問題を防ぐためです。結果として、途中で割り込まれるリスクが減り、未定義動作やデータレースの心配がなくなります。
このような対策により、残高がマイナスになったり、計算途中で値が変わったりする問題を防ぐことができます。また、比較操作もアトミックにすることで、操作の前後で値が変わらないようにできます。
最終的に、各操作がアトミックに保護されていることで、未定義動作のリスクを回避できます。これは、システム全体の安全性を確保するために不可欠です。 このときにいろいろと問題が起こる可能性があります。複数のスレッドでこれとこれを別々に処理したときに、例えばアカウントAとBの間で、アカウントごとに50が続き、Bが失敗すると0と100になります。うまくやり取りが終わってアカウントがそれぞれ50になる場合もありますが、次に80がないために失敗するパターンも考えられます。
次に、先にBが動いた場合、20と80になって次の50が失敗するパターンもあります。ただし、割り込まれると判定したときには残高があることになり、お互いが通過した後に残高が130になってしまう可能性があります。こうなると、例えばソース側のアカウントから130引かれ、ターゲット側のアカウントに130足されるため、残高がマイナスになってしまいます。これは「残高がマイナスにならない」という条件に違反することになります。
ちなみに、SPDアトミック操作に相当するものがJavaやC++などにはありますが、Swiftには特にありません。ただし、Swiftでも同様の動作を実現することは可能です。アトミックな操作を保護するための専用のものはないかもしれませんが、NSAtomicStoreなどがあります。SwiftにはObjective-Cのように明示的にアトミックを指定できる機能があるわけではありませんが、バックグラウンドで処理を守るためのライブラリやパッケージが存在します。
SwiftにはManagedAtomic
というパッケージがあります。これは、先ほど説明したアトミック操作に相当するもので、データレースを防ぐ効果がありますが、レースコンディションのような競合状態を完全に防ぐのは難しいです。最近ではコンカレンシー(並行処理)が進化しており、アトミックな操作も並行処理の枠組みで解決できる場合があります。
具体的には、Mutex
の排他制御を使って競合状態を回避する方法があります。ポイントになるのは、イント自体は排他制御をするため、全体を保護することでデータ競合を避けることです。Mutexを使用してクリティカルセクションを保護し、同時に複数のスレッドが競合しないようにすることで、安全に操作を完了させることができます。
この方法を用いることで、データレースやレースコンディションの問題を減少させることができ、より安全なコードを実現することができます。 同時アクセスをやったとしても、ペッレのスペックでは誰かが操作している間、そのために待たされるので、安定して両方の判定を先に取ってから演算される仕組みになります。これにより、特定の順序で実行されることが守られます。C++のMutexとロックガードに相当するものがSwiftにあるかどうか、C#だとスレーティングモニターとして知られていますが、Swiftにモニターがあるかどうかは分かりません。
Swift モニター
で検索してもあまり情報が出ないので、モニターやMutexをキーワードに含めて調べる必要があります。ただ、こうした詳細は知らなくても何とかなりそうな気がします。モニターという概念が気になる方は、ゆっくり調べてみると良いでしょう。
次に、おまけと注釈の話ですが、Swiftでの実装がどのような感じになるかを考えてみます。非常に難しいことにはならないと思いますが、手を動かしてコードを書いてみましょう。
まず、基本的な変数とロックが必要な場面を考えます。
var source = 0
var yourAccount = 100
そして、データ競合が発生する可能性があるコードを書いてみます。例えば、資金を転送する場合の処理:
func transferAmount(from source: inout Int, to destination: inout Int, amount: Int) -> Bool {
if source >= amount {
source -= amount
destination += amount
return true
} else {
return false
}
}
このコードを同時実行する場合、データ競合が発生する可能性が高いです。そのため、この処理を保護する必要があります。最もシンプルな保護の方法は、おそらくasync
/await
でしょう。ただし、await
を使うためには、関数自体が非同期である必要があります。
func transferAmountAsync(from source: inout Int, to destination: inout Int, amount: Int) async -> Bool {
return await withCheckedContinuation { continuation in
if source >= amount {
source -= amount
destination += amount
continuation.resume(returning: true)
} else {
continuation.resume(returning: false)
}
}
}
この関数を呼び出すときにawait
を使って呼び出します。例えば:
let transferResult = await transferAmountAsync(from: &source, to: &yourAccount, amount: 50)
ただし、この中で他の非同期操作が行われる場合、条件が変わる可能性があります。そのため、async
だけではなく、actor
を使う必要があるかもしれません。
actor Account {
var balance: Int
init(balance: Int) {
self.balance = balance
}
func transferAmount(to destination: Account, amount: Int) async -> Bool {
if self.balance >= amount {
self.balance -= amount
await destination.deposit(amount)
return true
} else {
return false
}
}
func deposit(_ amount: Int) async {
self.balance += amount
}
}
これならば、データ競合を防ぐことができます。最初は少し難しいかもしれませんが、これに慣れるとデータの一貫性維持が容易になります。 そうですね、あれだけではダメなんです。何かしっかりと保護しないといけないんです。危なかったところですね。ここに await
があるかどうかは、また全体をアクターにしたときの話ですね。これをちゃんと守るには、やっぱりロックしないとダメなのでしょうか。
ロックするときには DispatchSemaphore
をよく使いますが、あまり Mutex
は使わないですね。NSLock
みたいなものは、確かあまり使わないと思います。そうすると、やっぱり Core Library の DispatchSemaphore
が妥当でしょうか。最近は Semaphore
に自信がなくなってきましたが、外に準備しておく必要がありますね。
まず、DispatchSemaphore
を使ってみましょう。バリューとして1を設定してみます。これで合っているかどうか自信がなくなってきたので、動かしてみて確認しましょう。
ここでの処理が終われば、defer
を使った方が良さそうですね。要はここを保護するかどうかですが、処理をガードする必要がありますので、defer
を使いましょう。ただし、これを動かしてみるのが一番確実ですね。
例えば、DispatchSemaphore
を使って、ローカルで async
で処理を行うとき、具体例としてマイアカウントに50と80を設定してみましょう。これで大丈夫でしょう。待たせたいところがあったら、スレッドスリープなどもうまく使います。ここではファンデーションの Thread.sleep
で待ち時間を設定します。
これでちゃんと動くかを確認します。セマフォで保護する方法はこういうふうに書くんですね。クリティカルセクションを保護するのは、マルチスレッド環境で重要です。その書き方は普段あまり意識しないかもしれませんが、確認は重要です。
クリティカルセクションはどれくらいの頻度で出てくるでしょうかね。改めて、自分のコードが安全に書かれているか、レースコンディションの観点で見直してみましょう。保護が必要だとなったときに、どのように保護を行うか、例えばセマフォでロックをかける方法や、他にもアクターを使う方法、ロックフリーの方法なども考えられます。
レースコンディション周りの確認が一通り終わったので、次回は循環参照周りを見ていきましょう。今回は以上です。お疲れ様でした。ありがとうございました。