前回に寄り道した データレース
とは何かみたいなところについて、前回はその本のさわりの部分までしか見られなかったので、今回も引き続きそのあたりを見ていく回にしますね。自分にとってあまり馴染みのないところなので、いくつかの Web ページを眺めたりしながら、その言葉に対するイメージを広げて行けたらいいなと思っています。それを見終えて時間があれば、再び 循環参照
の解消のしかたについての話に戻って見ていきますね。どうぞよろしくお願いします。
——————————————————————————————— 熊谷さんのやさしい Swift 勉強会 #221
00:00 開始 00:08 今回は札幌からの配信 01:17 データ競合とは 03:18 データ競合が発生する場面 05:33 シングルスレッドプログラミングでは発生しない 06:08 今どきの多くはマルチスレッド? 08:13 単一スレッドに閉じるやり方 10:45 DispatchGroup で待たせる方法 12:19 データ競合で壊したい 13:37 同時アクセスによる予期しないエラー 19:46 グローバルキューは単一スレッドではない 21:40 実際の実行環境で試してみる 22:04 直列キューと並列キュー 26:21 同時アクセスの回避や排他制御で未定義動作を防止する 29:01 並行計算におけるマルチスレッドの活用意義 32:32 データ競合を回避する手法 34:21 クロージングと次回の展望 ———————————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #221
では、始めていきましょう。今日は前回の雑談の続きで、主にデータレースに関する話です。前回は同時アクセスをシミュレーションしてデータを壊してみようという内容でしたね。同時アクセスでデータが壊れる具体例を示すためのコードが消えてしまったので、今回は新たにコードを書くのは時間がかかるため控えることにします。
とりあえず簡単におさらいしますと、片方が短いスパンで値を変更しつつ、もう片方がその値を読み取ることにより同時アクセスが起こり、データが壊れる可能性があるというお話でした。
今回は、データ競合についてもう少し深く見ていきます。データ競合は、データベースにおいて同時に大きな読み書きが行われることで発生します。Wikipediaの説明によると、データ競合は同じ時点において操作されるべきデータが矛盾を起こす状況を指します。具体的には、マルチスレッドプログラミングにおいて、一つのプロセス内の複数のスレッドが同じメモリ位置に対して同時アクセスし、少なくとも一方のスレッドが書き込みを行っている状態を指します。これが、適切なロックなどの制御がないと、書き込み中のデータを読み取ることが起こります。
このような場合、タイミングによってはデータが不整合な状態になることがあります。結果として、一貫性のないデータを読み取ることで、プログラムが予期しない動作をする可能性があります。また、この問題の再現性が低い場合、デバッグが非常に困難になります。手元では問題が発生せず、利用者の環境でのみ問題が発生するといったことが起こり得ます。
例えば、2つのスレッドAとBがあって、共有変数X
の初期値が1だったとします。スレッドAは変数X
を読み取り、スレッドBは変数X
に書き込みを行おうとします。ここで、スレッドAとBが同時に操作を行った場合、結果はどうなるでしょうか?可能性の一つは、スレッドAがX
の値1を読み取った後にスレッドBがX
に2を書き込むことです。逆の可能性としては、スレッドBが2を書き込み、スレッドAがその値2を読み取ることです。
このように読み取りと書き込みが同時に行われる場合、予測不能な振る舞いをする可能性があります。これが前回示した不定な値が取れる例です。この矛盾が起こり、一貫性を失わせる現象や状態がいわゆるデータ競合です。
データ競合はマルチスレッドプログラミングでのみ発生するため、シングルスレッドプログラムでは発生しません。今時のiOSアプリや他のOSアプリではマルチスレッドが当たり前のため、発生し得る問題です。例えば、JavaScriptは基本シングルスレッドなので、この問題は少ないはずです。JavaScriptではsetTimeout
などを使って非同期処理を行いますが、それでも同じスレッド内で動きます。
しかし、ほとんどの現代的な環境ではマルチスレッドが標準となっており、データ競合については常に気にしておく必要があります。例えば、CoreDataや通信関連ではバックグラウンドで処理が行われることがあり、その際のデータ競合の可能性も考慮しなければなりません。AppleのAPIでも、メインスレッドでないといけない場面が多く、その際にデータ競合を避けるための配慮が必要です。
いろいろ話しましたが、シングルスレッド環境ではこのような問題は基本的に発生しません。ただ、ライブラリを経由してバックグラウンド処理が行われる場合などは別です。シングルスレッドに注意してプログラミングすることで、データ競合の発生を防ぐ方法もあります。 シングルスレッドを使う場合、FHQ(Fair, High Queue)に統一する方法がありますね。例えば、グローバルキューを作って、それを使用することを考えます。マルチスレッド環境で安全にアクセスさせるには、マルチスレッドでキューを扱う際には注意が必要です。
例えば、バリューを一度に求める場合に、バリューを繰り返して取得することで、異なる値同士が競合するとおかしなことが起きる可能性があります。しかし、適切にバリューを取り出すことができれば問題は解決します。
ここで、「バックグラウンドで起こる処理」として考えると、例えば、スレッドグループを使用して、キューに対して処理を行い、その後グループに戻します。一連の処理が終わったら、再度グループにアクセスする形にすると、並行処理がうまく行われます。これにより、アクセスが安全に行われるでしょう。
具体的なコード例でいうと、DispatchGroup
を使って次のようにすることができます。
let dispatchGroup = DispatchGroup()
dispatchGroup.enter()
DispatchQueue.global().async {
// 非同期タスクの処理
dispatchGroup.leave()
}
dispatchGroup.notify(queue: .main) {
// すべてのタスクが完了した後の処理
}
スマホ上でのグループ処理でも、同様に正しく処理が行われるのを確認できます。しかし、同時アクセスが多発する場合には、適切な排他制御が必要です。
特に、インサートとセレクト操作が同時に行われた場合、不整合が発生する可能性があります。そこで、インサート処理中に別のインサートやセレクトが入らないようにする必要があります。
適切な対策を行えば、たとえ順番が狂うことがあっても、一定の整合性は保たれるでしょう。そのためには、適切なキューイングやロック機構を導入する必要があるのです。
シンプルな例として、キューを1つ作ってそのキューに対して投げる方法があります。この場合、同時に動かないように制御できます。
let queue = DispatchQueue(label: "com.example.queue", attributes: .concurrent)
queue.async {
// シンプルな非同期処理
}
これにより、並行処理の競合を避けつつ、安全に実行することが可能です。 では、さっそく進めていきます。
用意しておいたタスクの待ち行列にどんどん入れてくれて、それが終わり次第次に進んでくれるようにする、シンプルな方法がいいかもしれません。どちらでも良いのですが、このようにストレートに1つにすることにより、ストレート内からのみアクセスすることで問題が解決します。
インタラプトが発生しているのか確認します。もしかしてタイムアウトしたかもしれません。本当のタイムアウトではなく、一発リーブの数が多すぎる可能性があります。では一旦進行を停止して、もう一度試してみます。
最初はシンクを使ってみましょう。ただ、シンクだと27で28が終わらないと29に進めないですよね。シンクは単に待ち行列を処理するだけですので、一気に並列処理を行う場合には向いていないかもしれません。テストを実行してみて、プロセスがどのように動作するのか確認してみます。
途中でエラーが発生している可能性もありますが、グローバルに影響するようなことが原因かもしれません。シンクを使用した後にタスクを解放するか、リリースするか決めておく必要があります。全体の進行順序も確認しながら、適切な箇所でリリース処理を行うようにしましょう。
今のところ、約10回から20回ほど実行されているようですが、順序が決定的ではないため、再度確認する必要があります。同時に複数のタスクが実行された際に問題がないか注意深く観察します。新たなタスクが割り込むことによって、順序が変更されるリスクも考慮する必要があります。
スマイルマークのタスクが順番通りに処理されるか、最終的に確認してみます。
今回の内容はここまでです。ご視聴ありがとうございました。