これは最近、私がスクリーニングで尋ねられた質問であり、それが私に考えさせられました。私は会話がどのように終わったかに完全に落ち着いたことはありませんでした。私が見た答えはどれも満足のいくものではなかった。
問題は本質的に「どのようにしてスレッドセーフなハッシュマップを実装するのか」です。明らかにミューテックスで保護しますが、単一のミューテックスで待機している複数のスレッドの競合に対処する必要があります。これは、アクセスが完全に同時ではないことを意味します。最終的に、ハッシュテーブルのn個のバケットの単純な設計に到達しました。1つのmutexが1つのバケットに対応しています。非常に簡単で、他のサイトでこの質問に対する多くの答えが止まるのがここです。ハッシュテーブルのサイズが変更された場合や、何らかの理由でテーブルが再ハッシュされた場合の処理は行いません。
1つのスレッドが再ハッシュを管理することが想定されていたため、単純な設計はn + 1ミューテックスになり、管理スレッドはすべてのn + 1ミューテックスをロックして再ハッシュする必要があります。ただし、nバケットのいずれかで待機しているスレッドは、(再ハッシュ後)別のバケットと一致するように一致したmutexの所有権を取得できます。基本的には非並行ハッシュマップに戻って、私は思いつくことができる答えだけです。
Javaには、いくつかの種類の同時ハッシュマップが標準で備わっていると思いますが、C++の世界に住んでいます。画面に合格しましたが、これについてはまだ知りませんでした。 「a-ha」の瞬間、それは実用的なアプリケーションのように聞こえます。
読み書きロックはいくぶん適切な答えかもしれないと思いますが、繰り返しますが、再ハッシュする際にはまだ注意が必要です。
私はこれに取り組んできました。設計を何度か繰り返しました...
最初の解決策:グローバルロックを持つハッシュテーブルを用意するだけです。
2番目の解決策:固定サイズのハッシュテーブルを待ちます。固定サイズのスレッドセーフな待機フリーハッシュセットを実装できることを理解する必要があります。これを行うために、インデックスがハッシュである配列を使用し、線形プローブを使用し、すべての操作がインターロックされます。
だから、はい、ロックします。ただし、スレッドがスピン待機したり、待機ハンドルで待機したりすることはありません。それらがすべてアトミックであることを保証する方法を理解するには、いくつかの作業が必要です。
3番目の解決策:成長を抑制します。素朴なアプローチは、構造をロックし、新しい配列を作成し、すべてのデータをコピーしてからロックを解除することです。それは機能し、スレッドセーフです。サイズが変わらない限り、以前のソリューションと同じように作業できるので、成長のみをロックして問題を回避できます。
ダウンタイムが気に入らない以外は。コピーが完了するのを待っているスレッドがあります。もっと上手にできる...
4番目のソリューション:協力的な成長。最初にスレッドセーフステートマシンを実装して、構造が通常の使用から成長に戻って変化するようにします。次に、待機するのではなく、操作を実行するすべてのスレッドが、新しい配列へのデータのコピーに協力します。どうやって?インターロックされた操作で、インクリメントするインデックスを作成できます。各スレッドはインデックスをインクリメントし、要素を取得し、新しい配列のどこに配置するかを計算してそこに書き込み、操作が完了するまでループします。
問題があります:縮小しません。
5番目の解:スパース配列。配列を使用する代わりに、配列のツリーを使用して、スパース配列のように使用できます。そのサイズは、ハッシュ関数が出力できるすべての値を割り当てるのに十分です。これで、スレッドは必要に応じてノードにできます。各ノードの使用状況、つまり、占有されている子の数と現在使用しているスレッドの数を追跡します。使用量がゼロになったら、削除できます...待って...ロックしますか?楽観的な解決策を実行できることがわかりました。ノードデータへの参照を維持すると、すべてのスレッドが操作後に1つ書き込みます。 0を見つけたスレッドは消去してから読み取り(別のスレッドが書き込んだ可能性があります)、2番目の参照にコピーします。すべての連動操作。
ちなみに、これらのアトミック操作ですか?うん、それは私たちが抽象化できるいくつかの一般的なパターンであることがわかりました。最後に、私は、アイテムを追加する場合と追加しない場合があるDoMayIncrement
、削除できるかどうかがわからないものについてはDoMayDecrement
、その他すべてについてはDo
のみを持っています。コールバックコードは値がどこに保存されているかを正確に知る必要がない値への参照を使用してコールバックを取得します。その特定の操作の上に構築し、途中で衝突を処理するためのプローブを追加します。
ああ、忘れました、割り当てを最小限に抑えるために、ツリーのノードを構成する配列をプールしました。
6番目のソリューション:Phil Bagwellの Ideal Hash Trees 。それは私の5番目のソリューションに似ています。主な違いは、ツリーがすべての値が同じ深さに挿入されるスパース配列のように動作しないことです。代わりに、木の枝の深さは動的です。衝突が発生すると、古いノードはブランチに置き換えられ、新しいノードに加えて古いノードが再挿入されます。これにより、平均的なケースでBagwellのソリューションのメモリ効率が向上します。 Bagwellはアレイもプールします。 Bagwellは縮小について詳しく述べていません。
5番目の解決策は、バックポート/ポリフィルに現在使用しているものですConcurrentDictionary<TKey, TValue>
から.NET 2.0。より正確には、 私のバックポートConcurrentDictionary<TKey, TValue>
カスタムタイプをラップします ThreadSafeDictionary<TKey, TValue>
Bucket<KeyValuePair<TKey, TValue>>
BucketCore
に対してアトミック操作を実装します。これは、スレッドセーフのスパース配列のことで、縮小と拡大のロジックがあります。
「真の並行性を備えたハッシュテーブル」はとにかく問題があります。スレッドAがキーを検索し、スレッドBがまったく同じキーを削除または追加する場合、Aがキーを見つけるかどうかがわからないことが、最良の結果です。
アトミックにするためには、通常の操作以上のものが必要になります。たとえば、キーを検索して削除し、呼び出し元にすべてアトミックに返す操作が必要です。そうしないと、アルゴリズムで問題が発生します。ディクショナリにキーを追加してすぐに検索しても、キーがまだそこにあるとは限りません。楽しい。
したがって、最初のステップは、スレッドセーフな方法でサポートする操作を設計することです。次はスレッドセーフにすることです。最も簡単な方法は、1つのmutexを使用することです。ハッシュテーブルがスレッドセーフである必要があるからといって、それが頻繁に使用されるわけではありません。 Javaは、競合しないロックを非常に高速にするためにいくつかのトリックを備えています。次に、スピンロックを使用できるかどうかを確認します。ハッシュテーブルの検索は高速であるため、効率的です。 。おそらく、ロック外のキーの比較を試みるべきです。
2つの新しい(キー、値)ペアを hashmap に追加したいとします。
最初のコメントとして、 lock free likes list を作成するのは簡単です。これは最後の問題を解決します
この機能を利用して、ハッシュマップが同時に使用される前に、構築時にハッシュマップの各バケットを(ロックフリー)(空の)リンクリストにすることができます。これは最初の問題を解決します:バケットへのアクセスは常にロックフリーのリンクリストを通過します。
このような構造では、バケットを再配置する必要がない限り、Nice同時ロックフリーハッシュマップが得られます(たとえば、バケット数を動的に増減したり、ハッシュ関数を変更したりする場合)。
編集
テーブル全体をロックすると、グローバルロックを取得する必要があるため、ロックフリー構造の利点はすべて失われます。
ロックフリーを維持するのは非常に難しいので、次のアイデアをクロスチェックする必要があります。アイデアは、サイズ変更を開始すると作成され、新しいバケットを含むシャドウテーブルを操作することです。
詳細な分析は行っていませんが、起こりうる最悪の事態は、ライターがシャドウに新しい値を書き込んでいる間に、リーダーが見逃して古い値を読んでいることです。更新前。もう1つの悪いケースは、スレッドが現在のテーブルを調べ始め、保留状態になり、古いテーブルが削除されると再開する場合です。これを回避する方法は完全に明確ではありません。おそらく、古いテーブルでジャンプしたスレッドの使用カウントですか?
TLDR:この種のものに対処するにはさまざまな方法があり、必要な動作と柔軟な動作を正確に定義して少なくとも絞り込めるようになるまで、それらの方法を選択することすらできません。該当する同時実行モデルのリスト。
最初に、必要なものをより正確に定義することから始めるのが最善です。
あなたはハッシュテーブルを要求しますが、あなたのデータ構造は本当にハッシュテーブルでなければならず、他には何もないのですか? (もしそうなら、なぜですか?)それとも、キー値のルックアップを高速化し、何らかの方法で変更可能なデータ構造にすることができますか?
「明らかにmutexで保護する」コメントは、スレッドが変更を開始すると、他のスレッドが変更前のデータを決して見てはならないなど、シリアル化された同時実行モデルを意味すると解釈できます。しかし、これも本当に必要なのでしょうか?または、スレッドが特定のバージョンのデータ構造を取得し、すべてのルックアップが新しいバージョンを要求するまでそのバージョンから行われる、「マルチバージョン」同時実行モデルで問題ない(またはおそらくさらに良い)でしょうか。 (一貫性がなければならない複数のルックアップをスレッドが実行したい場合、シリアル化されたモデルは、最初の読み取りの前にロックし、そのトランザクションのすべての読み取りが終了したらロックユーティリティを維持する必要があることを意味します。)
たとえば、Key-Valueディクショナリを提供する任意のデータ構造で問題がなく、マルチバージョンの同時実行に対応できる(または必要な場合さえある)場合は、代わりに ハッシュツリー を使用できます。グローバル変数に格納されているそれへの参照。
読み取りは、グローバル変数を読み取ってツリーのルートを取得し、必要な限りそのコピーを使用して、そのバージョンのツリーから値を検索し、一貫性を保ちます。
書き込みの場合、実行するパフォーマンスのトレードオフの種類に応じて、少なくとも2つの選択肢があります。辞書に変更を加える前にグローバル変数をロックし、変更が完了するまでロックを保持することができます。これはリーダーをブロックしませんが、他のライターをブロックします。または、ツリーの既存のバージョンに変更を加え、現在のバージョンが変更を開始したバージョンと同じ場合にのみ新しいツリーを保存し、それ以外の場合は中止することもできます。これにより、ロックのコストは節約されますが、書き込みスレッド間の競合からの回復により多くのコストがかかります(また、ツリーの新しいバージョンに対して行いたい変更を簡単に「再生」できない場合は、回復がさらに複雑になります)。 。
スレッドセーフ(別名「並行」)データ構造(ハッシュマップなど)は、2つ以上のスレッドがいつでもそれらの一部に完全に並行アクセスできるという意味で、完全に「並行」になることはありません。データ構造に適用される「同時」とは、それらがスレッドセーフであることを意味します。つまり、2つ以上のスレッドがいつでも同時アクセスを試行でき、構造がすべてのリーダー/ライターの一貫性を維持します。実際には、これはある状況下ではスレッドがブロックされることを意味します。適切に作成された並行構造は、適切な戦略を採用して、ブロッキングの発生と、発生した場合のブロッキングによる遅延を最小限に抑えます。