前回の続きな感じで、寄り道しながら見ていっている データレース
まわりともうひとつ興味の沸いた レースコンディション
、そのあたりに自分は馴染みが薄くて理解が曖昧なので、それについて気になったブログを読んでみながらその様子を探ってみようと思います。どうぞよろしくお願いしますね。
————————————————————————— 熊谷さんのやさしい Swift 勉強会 #222
00:00 開始 01:20 命題と対偶 04:57 アトミック操作 05:56 単一命令で完結する操作も不可分操作 07:16 ファイル操作規模での不可分操作 08:34 何を以って不可分とするか 09:36 途中の状態に割り込ませないことが大切 11:17 ひとつの動作の途中経過を観測させない 13:26 RISC とCISC 15:22 割り込みによる競合にも注意 16:27 各言語におけるデータ競合の定義 17:49 データ競合を気にしてコードを書いていく 18:59 データ競合と競合状態は異なる概念 22:47 次回にまた読み進めたいブログ 23:44 ロックフリー、CAS 26:04 CAS の働き 31:37 クロージング —————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #222
はい、では始めていきますね。今日は前回と前々回からの続きとして、データ競合について話します。今日はレースコンディションやデータ競合について議論します。まず、自分があまり詳しくない部分について、意識して説明する機会が今までなかったので、ちょっと過剰に言ってしまったかもしれません。でもまあ、その程度だと思います。実際にこの辺りを理解しているかと言うと、理解した気になっているだけで、実はちゃんとわかっていない可能性もあります。なんとなく難しい分野ですよね。いろんな考え方や言葉が出てきて、難しいところではあります。
前回この部分を読んで、実際にデータ競合を起こしてみたりもしました。データ競合を防ぐためには、ロックによる排他制御やアトミック操作が必要です。データ競合が起こる時、ロックやアトミック操作をしていないことが原因とは限らないということですね。逆に、データ競合が起こる時にロックやアトミック操作をしていなかったとは限らないということです。
論理学では「AならばB」に対する命題「BならばA」が成り立つとは限らないと言います。同様に「AでないならばBでない」という対偶の命題が真である場合、それは元の命題「AならばB」が正しいことを示します。具体的に言うと、データ競合が起きない場合でも、排他制御やアトミック操作をしていない可能性はある、ということです。
アトミック操作についてですが、例えば、ある操作が何段階かの手順を踏まないと実現できない場合、その操作が中断されずに一つの操作として完了するようにすることを指します。これは前回も話題にしましたが、具体的にはプロパティの「アトミック」のように、割り込まれないことが前提となっています。
コンピューターの世界では、CPUがマルチコアであったり、シングルコアであったりしても、マルチスレッドでCPUを利用する際に、複数のステップを踏まないといけない部分があります。これに対して、割り込みが入ると困るので、適切に保護する必要があります。シングルインストラクションで実行できる部分がアトミック操作とされ、これによりCPUレベルでの一連の処理が安全に行えるようになります。
ファイル保存の例でも、データ破損を防ぐために、アトミック操作が利用されます。例えば、一度コピーを別のファイルに保存して、それが成功したら元ファイルと差し替えるという方法があげられます。これはロックを使って安全に処理するイメージです。
ロック処理やアトミック処理は、CPUやカーネルレベルでサポートされないと絶対に無理なので、そこから段々と範囲を広げていくことになります。ファイル保存のような操作でも、これらの技術を使って、安全でデータ破損が起きないようにするということです。 概念的なところですが、何を分割不可能な単位とするかでファイルアクセスが決まります。例えば、1つのファイルハンドルに複数からアクセスがあると状況が複雑になり、理解しづらくなります。1つのテキストファイルを編集中に別のエディターで開かれると不整合が起こるため、ファイルの書き込みが終わるまで1つの操作単位とし、途中の状態を避けるために何らかの方法で保護します。
読み込みは、書き込みという1つの操作が完了してから行うため、完全な状態で読めるという利点があります。このように、ファイルアクセスを1つの操作単位とすることで、編集中に別のアクセスを許さないのです。これは、Immutableクラスの概念にも似ているかもしれません。
さらに、CPUにはこういった不可分な操作をサポートする命令があります。この不可分という考え方は非常に重要です。たとえば、アトミックオペレーション(不可分操作)はシステムの他の部分から見て1つの操作に見えます。複数ステップを一つにまとめて見せることが大事です。
ファイルの書き込みに関しても、APIに見つけられないことがありますが、FileManager
にはアトミック操作をサポートするメソッドがあります。例えば、テンポラリーファイルに書き出してそれが完了したら本来のファイルに置き換えることで、書き込みの途中状態を見せないようにします。
アトミック操作が保証されることで、操作が他の処理に割り込まれないため、不正なデータ状態が生じません。これはデータベースのトランザクションにも似ています。操作が一つのステップで完了し、観測不可能な状態になることが重要です。失敗すると全体が失敗し、以前の状態に戻るというのもアトミック操作の一環です。
アトミック操作を保証することによって、データの一貫性が保たれ、データの不整合が防げます。CPUアーキテクチャなどによる違いもありますが、基本的な概念としてアトミック操作は非常に大事です。データの整合性を保つためには、これを理解することが重要です。また、リスクとシスクの違いも理解しておくと良いでしょう。
以上の点を踏まえて、データ共有に関してはアトミック操作と排他制御をしっかりと理解することが必要です。この理解が曖昧だと、データ共有に対する意識が弱くなるかもしれません。 割り込みについてですが、シングルセットのプログラムでも割り込みによっていろいろなことが起こりえます。割り込みというのは、処理の流れが急に変わるような機構です。これによって、途中で不可分操作を行わなければならない部分で、流れが変わってしまうことがあります。そうしたことが起こり得るため、割り込みには注意が必要です。このあたりは応用レベルのプログラミングで特に意識する必要があります。
プログラミング言語によっては、割り込みやデータの競合に関する厳密な定義があります。例えば、C++では「競合動作」や「アトミック操作」について細かく説明されています。また、JavaやJavaScriptでも同様にデータの競合に関する注意点が記載されています。これはどのプログラミング言語でも同じで、データの競合を意識してプログラムを書く必要があります。
特に、イミュータブルなクラスを意識することで、データ競合を避けることができます。ただし、そうした場面でも「アトミック操作」を気にしなければなりません。Swiftを含む多くのプログラミング言語には、スレッドをまたぐ操作を考慮した言語仕様が備わっていますが、それだけでは十分でないこともあります。
データ競合を避ける意識を持つことで、マルチスレッドプログラミングの世界でより高品質なコードを書くことができます。ここで興味深いのは、「データ競合」と「競合状態」は別の概念であるという点です。データ競合がないプログラムであっても、競合状態が存在することがあります。
例えば、共有変数に対する書き込みがシリアル制御やアトミック操作で保護されている場合、データ競合は発生しません。ただし、変更される前の値や後の値を異なるスレッドが読むと、それによる動作のタイミングの違いが発生します。具体的には、あるスレッドが値を書き換えて読み取り、その間に別のスレッドが同じ共有変数を読み取るといった状況です。
このように考えると、データ競合がないということはアトミックな処理で保護されていることを意味しますが、値が変わっていく過程を異なるタイミングで見るスレッドが存在するため、それが競合状態を生むということです。 結局のところ、読み方が変わることで処理に誤りが発生する可能性があります。前後のタイミングで意味が異なってしまうと正しく処理が行えない場合があるという話ですね。
まず、現在の状態を読み取って表示するまでの間に状態が変わってしまうことについて説明します。例えば、ある値を読み取った後、その値が表示される前に別のコードがその値を書き換えてしまった場合です。このような状況では、データ競合やレースコンディションが発生する可能性がありますね。
次に、アトミック操作(原子操作)についてです。アトミック操作はすべての処理が一つの単位として実行され、中断されることがありません。これによって競合状態が防がれます。しかし、この競合状態に関しては、より厳密な排他性を持たせる必要があるため、プログラマーは注意を払う必要があります。
データ競合とレースコンディションは混同してはいけないという点も重要です。データ競合は、同じデータに対して複数の操作が同時に行われ、その結果が予測不可能になる状態を指します。一方、レースコンディションは、複数のスレッドが特定の順序で実行されることに依存する問題です。この違いを理解することが非常に重要です。
ロックフリーの考え方についても触れます。ディスパッチを使ってスレッドを揃えるといった方法も含まれますが、もっと広い意味でのロックフリーの手法としてCAS(Compare and Swap)があります。CASは、現在の値が期待通りであれば新しい値に置き換えるという操作を実行する方法です。これは一連の操作を一つの原子操作として実行するため、途中で他のスレッドに割り込まれることがありません。
例えば、ある値を読み込んでその値がゼロだった場合に一つ追加するという操作を考えます。この操作をアトミックに行うために、CASを使用します。具体的には、以下のようなコードを利用します:
if value == expectedValue {
value = newValue
}
このコードでは、元の値(expectedValue)が期待した値であれば、新しい値(newValue)に置き換えます。このようにCASを使えば、競合状態を防ぐことができます。
以上が、データ競合や競合状態についての説明と、それを防ぐための方法に関する内容です。今回はここまでとし、次回にまた続きを話しましょう。 インクリメントの話を進めていきましょう。インクリメントを行う際には、元の値を取得し、その値に +1
した新しい値を書き込む、という流れになります。もし説明がうまく伝わってなければ申し訳ないのですが、要は最初に取得した値 (source
) から newValue
を source + 1
にして、最後に新しい値を書き込むときに、その間に他のスレッドが値を変更していないかを確認する必要があります。
例えば、次のようなコードです:
let source = value
let newValue = source + 1
最終的に値を更新する際、この newValue
を元の value
で上書きする必要があります。そのとき、本当に元の source
を取得した時点の状態のまま書き込めるかどうかをチェックします。これが適切に行われないと、インクリメント処理が正しく動作しないという問題が発生します。特にマルチスレッド環境では、このような問題が起こりやすいです。
ここで出てくるのが「Compare and Swap(CAS)」という技術です。CASを使うと、次のようなコードで、元の値 (source
) が変更されていないことを確認してから新しい値を書き込むことができます:
if value == source {
value = newValue
}
この処理によって、他のスレッドによる干渉がなく、確実に正しい値を設定することができます。要するに、新しい値を書き込むときに、元の値が自分が取得した値と一致していれば、そのまま newValue
に書き換えるというわけです。
こうした工夫によって、++
演算子のような操作をアトミックに実装できるわけです。逆に言えば、こうした工夫をしないと、マルチスレッド環境での競合問題が発生してしまいます。
これがロックフリーの一つの考え方で、適切な排他制御を行わずとも、正しい結果を得る手法です。
今日の話はざっくりした説明となりましたが、以上のポイントを気にしておくと良いかと思います。時間となりましたので、今日はここまでにします。