1. 序章

マルチスレッドアプリケーションで最も一般的な問題の1つは、競合状態の問題です。

このチュートリアルでは、競合状態とは何か、それらを検出する方法、およびそれらを処理するためのアプローチについて学習します。

2. 競合状態

定義上、競合状態は、その動作が複数のスレッドまたはプロセスの相対的なタイミングまたはインターリーブに依存するプログラムの状態です。 1つ以上の考えられる結果が望ましくなく、バグが発生する可能性があります。 この種の動作を非決定論的と呼びます。

スレッドセーフは、複数のスレッドからアクセスされたときに競合状態のないプログラム、コード、またはデータ構造を表すために使用する用語です。

2つの銀行口座間で送金を実行するための簡単な関数を考えてみましょう。

 

この実装では、ソースアカウントで利用できない資金を引き出すことを試みる可能性を考えました。 したがって、利用可能な金額を確認し、アカウントの残高がゼロを下回ることはないと予想します。 アカウントAとBがあり、それぞれの残高が500であり、300をAからBに転送する2つの試行を実行するとします。

ただし、これら2つの試行が異なるプロセスまたはスレッドで同時に開始されると、望ましくない動作が発生する可能性があります。

予測できないスレッドスケジューリングを考えると、特定のステップの順序は任意です。 実行フローのインターリーブが原因で競合状態が発生しました。

競合状態を回避するには、共有リソース、つまりスレッド間で共有できるリソースに対する操作をアトミックに実行する必要があります。 原子性を実現する1つの方法は、クリティカルセクション —プログラムの相互に排他的な部分を使用することです。 もう1つのアプローチは、不可分操作を使用して、ハードウェアの不可分性を確保する機能を利用することです。

3. Check-Then-Act

銀行送金の例では、check-then-act。のパターンを観察しました。

これは最も一般的なタイプの競合状態です。プログラムフローによって定義され、古い可能性のある観測値を使用して次に何をするかを決定します。この状態によって生成されるバグを時間と呼びます。 -使用時間またはTOCTOUのバグをチェックします。

TOCTOUの競合は、さまざまなプラットフォーム、特にファイルシステムアクセスに関するセキュリティの脆弱性の原因であることがよくあります。 攻撃者はこれらの脆弱性を悪用して、特権の昇格を取得したり、サービス拒否攻撃を実行したりします。

遅延初期化は、チェックしてから実行するパターンのさらに別の例です。

4. リードモディファイライト

競合状態のチェックアンドアクトタイプは、マルチスレッドアプリケーションで遭遇する可能性のある最も一般的なタイプですが、別の、把握しやすいタイプがあります。 通常の増分操作を使用する次の擬似コードについて考えてみます。

 

ほとんどの言語では、通常のインクリメント演算子は、読み取り、変更、書き込みの3つの連続した操作を表します。

この実行のアトミック保証を示していないため、複数の実行が開始された場合、以前に見たのとまったく同じ操作がインターリーブされる可能性があります。

このタイプの状態は、データレースと密接に関連しています。 この微妙な違いについては以下で説明しますが、最初に実際的な側面について説明しましょう。

5. 検出

競合状態は通常、再現、デバッグ、および排除するのが困難です。 競合状態によって発生するバグをheisenbugsと表現します。

競合状態はアプリケーションのセマンティクスに関連付けられているため、それらを検出する一般的な方法はありません。 テスト結果の安定性に焦点を当てたマルチスレッドの単体テストは役立ちますが、100% gの保証を提供する可能性は低いです。

幸い、競合状態を回避または排除するためのいくつかの手法があります。 これらの手法を知っていると、コードレビューでの使用を確実にしたいと思うかもしれません。

6. 排除

競合状態と戦うためのアプローチには2種類あります。

  • 共有状態の回避
  • 同期とアトミック操作の使用

6.1. 共有状態の回避

競合状態を表示するには共有状態が必要なので、共有状態を削除することが問題を解決するための最良の方法です。

構築後に状態を変更できない不変オブジェクトは、本質的にスレッドセーフです。 可能な限り不変オブジェクトを使用することを常にお勧めします。

各スレッドがプライベートコピーを持つようにローカライズされたスレッドローカル変数も、各スレッドに対してローカルであるため、スレッドセーフです。

チェックしてから実行する競合状態の場合、一般的な手法の1つは、チェックの代わりに例外処理を使用することです。 このアプローチでは、仮定の失敗が使用時に検出され、例外が発生します。 おなじみのことわざにあるように、許可よりも許しを求める方が簡単です。

より根本的な扱いは、アクターベースの並行性など、共有状態を完全に禁止する並行性モデルを使用することです。

6.2. 同期と不可分操作の使用

クリティカルセクションなどの同期プリミティブは、プログラムの特定の部分を複数のスレッドで同時に実行できないようにするために使用されます。 ロックは、スレッドレベルでクリティカルセクションの動作を強制するための同期メカニズムです。 mutex は、複数のシステムプロセスに存在する同じ抽象化です。

同期は競合状態を取り除くための最も強力な方法ですが、コストがかかります。ロックは、オーバーヘッドのためにパフォーマンスに打撃を与えます。 また、扱いが難しい場合もあります。 ロックは構成されません。つまり、ロックベースのモジュールをより大きなロックベースのプログラムに組み合わせる場合は、正確さを維持するために余分な労力が必要になります。 最後に、ロックによってデッドロックが発生する可能性があります。

不可分操作は、単一の作業単位としての実行を意味します。 具体的な保証は使用する言語によって異なりますが、主な考え方は、実装が実行が終了するまで割り込みを防ぐハードウェアの機能に依存しているということです。

アトミック操作を使用して、より高レベルのロックフリー抽象化を実装できます。 ソフトウェアトランザクショナルメモリ(さらに別の同時実行モデル)は、この概念を利用して、メモリ内の操作でデータベースのようなトランザクションを可能にします。

アプリケーションレベルでは、同時データ構造と言語提供のアトミックパッケージを使用することが役立つ場合があります。

7. データレース

上記のグローバルカウンターインクリメントの例は、競合状態の古典的なデモンストレーションですが、別の概念も表しています。 前述の例の競合状態は、アトミック性の契約なしに、並列命令によって同じメモリ位置にアクセス(書き込みを含む)することによって発生します。

この発生をデータレースと呼びます。 2つのスレッドが同じ変数に同時にアクセスし、アクセスの少なくとも1つが書き込みである場合にデータ競合が発生します。データ競合の概念は、特定の同時実行モデルのメモリアクセスに固有であるため、異なります。プラットフォーム間。

ほとんどの場合、データレースは、カウンターインクリメントの例とまったく同じように競合状態を作成します。 それでも、データレースなしで競合状態になる可能性があり、特定のプラットフォーム定義によっては、望ましくない結果をもたらさないデータ競合が発生する可能性があります。 一般に、データ競合は競合状態のサブセットではありません。

銀行送金の例では、チェックしてから実行するシーケンスに焦点を当て、インクリメント/デクリメント操作の原子性のバランスに注意を払いませんでした。 確かに、銀行の資金移動の素朴な実装には、データの競合もあります。

これらは、カウンターインクリメントの例と同様に、インクリメント/デクリメント操作で原子性を確保することで(たとえば、ロックを使用して)解決できます。 すべての操作を保護することで、データレースを排除できますが、実装全体をクリティカルセクションに配置しない限り、競合状態は依然として存在します。

競合状態とは異なり、特定のプラットフォームでのデータ競合には、プログラムのセマンティクスに依存しない厳密な定義があります。 これにより、データの競合を自動的に検出する機能が提供されます。

この目的のために、RV-Predict、ThreadSanitizer、IntelInspectorなどの多くのツールが存在します。

8. 結論

この記事では、マルチスレッドアプリケーションで発生する競合状態について説明しました。

チェック・アンド・アクトのパターンとデータの競合について学びました。

最後に、プログラムの正確性を確保するために、競合状態を回避および排除するためのいくつかの方法を検討しました。