今日は最初に、前回の話の中で僅かに出てきた データレース
とは何かみたいなところを確認して見る回にしてみますね。自分もなんとなくでしか理解できていないと思われるので、探り探りで雰囲気だけでも掴めたらいいなと思いつつ。その辺りを見てから時間があれば、再び 循環参照
の解消のしかたについての話に戻って見ていきますね。どうぞよろしくお願いします。
———————————————————————————————————————— 熊谷さんのやさしい Swift 勉強会 #220
00:00 開始 00:39 今回の展望 03:39 データ競合とは? 04:14 データの一貫性が損なわれる状況 11:34 SystemRandomNumberGenerator を使ってみる 19:05 トップレベルの変数を @MainActor で保護しない方法は? 19:38 ローカルスコープにしても Concurrency に守られる 20:12 結局は GCD でデータ競合を起こしてみるも微妙に 27:50 今回の所感とクロージング ————————————————————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #220
はい、じゃあ始めていきますね。今日は画面に移した内容とはちょっと別のお話をしていこうかなと思います。順当な流れでいけば、自動リファレンスカウンティング(ARC)の循環参照を回避するための手段についての話になる予定だったのですが、前回の勉強会で循環参照や型、ポインタなどの話をする中で、「データレース」という言葉がコメントに出てきました。
データレースという言葉は聞き覚えがあったのですが、それが具体的に何なのか思い出せなかったので調べてみました。そして、結構重要な概念だと分かりました。これは少し難しい話かもしれませんが、今日はデータレースについて見ていこうかなと思います。
データレースとは、基本的には同じデータに対する同時書き込みが引き起こす問題やその状態のことを指します。ただ、自分自身もそこまで詳しいわけではないので、細かい解説は難しいですが、概念を深めていこうという感じで進めてみようと思います。
まずデータレースというのは、簡単に言うと予測不能なデータが手に入ることがあるというものです。これはプログラムが並行して動作しているときに発生します。データレースについてはWikipediaなども参考になりますので見てみましょう。Wikipediaには「データレースとは、単一のデータに対する同時書き込みが一貫性を引き起こす現象、あるいはその状態である」と記されています。一貫性が損なわれるということですね。
では、具体的にどういった状況でデータレースが発生するのか見ていきたいと思います。例えば、以下のようなコードを考えてみます。
import Dispatch
struct MyStruct {
var value: Int
mutating func setValue(_ newValue: Int) {
self.value = newValue
}
}
extension MyStruct: CustomStringConvertible {
var description: String {
return "Value: \\(value)"
}
}
var myStruct = MyStruct(value: 0)
DispatchQueue.global().async {
for _ in 0..<10 {
myStruct.setValue(Int.random(in: 0...100))
print(myStruct)
}
}
DispatchQueue.global().async {
for _ in 0..<10 {
print(myStruct)
}
}
このコードでは、myStruct
という変数が二つの非同期タスクで並行して読み書きされるため、何が起こるか分からない状態になっています。具体的には、一つのタスクが値を更新している間に、もう一つのタスクがその値を読み取ろうとすると、途中のデータが読み取られたりします。このような状況がデータレースです。
このように並行処理を行う際には、データレースが発生しないように注意が必要です。特にSwiftでは、並行処理を安全に行うための仕組みがいくつかあります。例えば、DispatchQueue
を使って必要な部分だけシリアルキューにすることができます。
let queue = DispatchQueue(label: "com.example.myQueue")
queue.async {
for _ in 0..<10 {
myStruct.setValue(Int.random(in: 0...100))
print(myStruct)
}
}
queue.async {
for _ in 0..<10 {
print(myStruct)
}
}
このようにすると、書き込みと読み込みの順序が保証されるので、データレースの問題を回避できます。
いかがでしょうか。データレースの概念について、一旦理解が深まったかと思います。次回の勉強会では、また別のテーマについて掘り下げていきたいと思います。 そうすると、これは Sendable
じゃないとか言われるのかなと思います。async
関数で await
して、それを print
するような形ですね。特に print
自体は何の変哲もないですし、await
するとエラーになる場合もあるので注意が必要です。
それから、タスクを使ってくるくる回している部分ですが、for
ループを使う形にします。例えば、let
に 1万回
くらいのループにするとどうなるか検証します。Range
を使って、for i in 0..<10000
のように記述します。これで書き込みの処理を作ろうと思いますが、まずはその部分が正しく動作するか試してみます。
次に進みます。タスクの部分で、変数に対してランダムな値を設定します。例えば、Int.random(in: 0..<100)
のようなランダムジェネレータを用います。SystemRandomNumberGenerator
というのが使えると思います。これを使うと、前に宣言したランダムジェネレータを使用し、ランダムな値を生成することができます。
コード例として、以下のような形になります:
var generator = SystemRandomNumberGenerator()
for _ in 0..<100 {
let randomValue = Int.random(in: 0..<100, using: &generator)
print(randomValue)
}
このジェネレータは、next
メソッドを使って次のランダムな値を取得しますが、プロトコルを確認して目的に合ったメソッドがあるかをチェックします。例えば、next()
メソッドは UInt64
を返しますが、必要な型にキャストできる場合もあります。
また、Int
型にキャストする際には以下のように記述します:
let randomInt = Int(generator.next() % 100) // 0から99までのランダムな整数
これで、ランダムな値を生成する準備が整いました。ループ内で生成したランダムな値を使って処理を行うことができます。最後に、動作確認をして期待通りに動作するかを確認します。
エラーが出たり、型が異なる場合には、適切なキャストや変数の宣言を行う必要があります。また、async
や await
が必要な部分は actor
で囲み、非同期処理に対応させます。こうして、メインアクターのアイソレーションコンテキストにうまく入るようにします。
以上の流れで、Swiftでランダムな値を用いたタスクの処理を行う事ができます。次に進める準備が整いましたので、引き続きコーディングを進めていきましょう。 ノンアイソレイティブなコンテクストについてですが、どのようになっているのか説明します。ノンアイソレイティブだとコンパレンチの方法になります。ノンアイソレイティブの場合、アクセスは問題になりません。現在、値がメインアクターによってアイソレイティブな環境にあります。ノンアクターアイソレイティブの場合、そもそも値を受け取ることができません。
つまり、この問題を解消するとデータレースがなくなるということです。なるほど、よくできていますね。ディスパッチを使わないとダメですか?そうですね。これを強引にどうにかしようとすると、アイソレイティブになってしまいます。アイソレイティブの概念が少し曖昧ですが、ノンアイソレイティブはどこで使うのでしょう?ネットワークアクションなどですかね。アクセスしようとして壊したいのですね。メインアクターを使って動かせば壊れますが、メインアクターで動かすと守られてしまいますね。
ディスパッチを使うかどうかも検討中ですが、ディスパッチにはそのような機能がないので多分できるはずです。ローカルスポットにすると、勝手にメインアクターにならないようです。ちょっと試してみます。
どうなりましたか?結局、メインアクターに引っかからないといけないようですが、それが防げている感じがします。ディスパッチに変えてみますね。最新の方法すぎたかもしれませんが、安全性を確保してくれる傾向があります。
ディスパッチを使用して、ローカルスポットをグローバルに変更します。タスクグループではなく、ディスパッチでディスパッチキューのグローバルエーシンクを使います。これでまず一段落させて、次にプレイグラウンドサポートをインポートします。それで、不要な部分を削除してからスレッドスリープを使います。たとえ忘れても、インポートファンデーションを使い、ThreadSleep
メソッドでタイムインターバルを設定します。
やってみます。無限にループさせる場合、プレイグラウンドページでの設定を行います。ThreadSleep
のタイムインターバルが完了するまで待機してください。
動かしてみると、EXC_BAD_ACCESS
エラーが出ますが、これはUInt64
だからです。UInt64
として大きすぎる値を使ってしまったので、これを適切な数値に変更します。実行してみましたが、1万回はやりすぎましたね。一回実行するたびに確認する必要があります。
次に、これを100回に設定し、何か問題があればメッセージを表示するようにします。具体的には、変な処理を試して、print
で結果を出力します。
もし一致しなければエラーメッセージを表示するようにします。このように処理すると、100回のうちどこかでエラーが出ないかを見ることができます。並列型にするほうが分かりやすかったかもしれませんが、これは今のところ無理やり過ぎて微妙ですね。 ですが、壊れていましたね。まあ、いいのかな。こんな例で、今一括で書ける64ビットや128ビットの変数とかあれば、もうちょっと面白くなったんですが、Int128
とかはないのかな。なさそうですね。よくわからない方が出てきましたけど、まあいいや。
とにかく、一つのデータ型に対して読み込みと書き込みを同時に行うと、こうやって異なる値になることがありますね。通常は、タイミングによって異なるんですよね。例えば、要素の問題かな。8の0.02
とかにすると分かりやすくなります。まあまあ、ここじゃないか。こっちですね。まあいいや。動かしてみましょう。ああ、こんなもんですね。うん、そうそう。
とりあえず、こんな感じでデータレースが理解できればいいですね。これについて話していたら、今日の勉強会が終わってしまいますね。マルチスレッドプログラムにおいてデータ競合とは、同じ時点で同じメモリアドレスに対して異なるスレッドが同時に読み書きすることで発生します。これを避けるために、プライベート変数を使って同期を取ることが重要です。
もう一度実行してみたいですが、今回は壊れないみたいですね。まあ、これを1万回実行して確認してみましょう。1万回実行することによって、競合状態が発生しやすくなります。もう少し試してみましょう。
まとめると、データ競合は非常に厄介で、環境によって再現しにくい問題です。実際に発生すると、大変なトラブルになることが多いです。次回は、この続きを見ていく予定です。
今日はここまでにして、次回この続きから学んでいきましょう。お疲れ様でした。ありがとうございました。