反復中にConcurrentDictionaryを変更する

最近、スレッドセーフコレクションの内部デバイスを扱うことにしました。Habréの出版物は、ConcurrentDictionaryデバイスの研究の出発点でした。 その動作の原理は簡単かつ明確に説明されていますが、著者のおかげです。



出版のある瞬間が完全にカバーされていないように思えたので、このギャップを埋めることにしました。



スレッドセーフコレクションは、マルチスレッド環境で使用するために設計されており、いつでも変更できる必要があります。 したがって、それらは列挙時にも変更できます。 ここから、反復中にコレクションが変更された場合、イテレータにこれらの変更が表示されますかという質問がありましたか?



上記の記事を見てみましょう。

GetEnumerator-メソッド呼び出しの後に別のスレッドによって変更が行われた場合、およびイテレータがこの要素をどのように渡したかによって古い値を返すことができます。


それは、イテレータが既に渡した要素への変更がコレクションを並べ替える際に考慮されないことは非常に論理的です。 そして、イテレータがまだ「到達していない」要素を変更した場合、またはコレクションに新しい要素を挿入した場合はどうなりますか?

MSDNに目を向けます(このメモのロシア語の翻訳はあまりよくできていないので、元の言語でメモを挿入します)。

辞書から返される列挙子は、辞書の読み取りおよび書き込み中に使用しても安全ですが、辞書のスナップショットは提供しません。 列挙子を介してアクセス可能なコンテンツには、GetEnumeratorを呼び出した後に辞書に加えられた変更が含まれる場合があります。



ディクショナリから返される列挙子は、ディクショナリの読み取りと書き込みと同時に使用しても安全ですが、ディクショナリの瞬間的なスナップショットを表すものではありません。 列挙子を介して公開されるコンテンツには、GetEnumeratorが呼び出された後に辞書に加えられた変更が含まれる場合があります。


私は、技術教育を受けた人として、「含むかもしれない」という言葉に混乱しています。 つまり 含まれるかどうかは? 確認しましょう:



ConcurrentDictionary<int, string> dictionary = new ConcurrentDictionary<int, string>(); dictionary.TryAdd(0, "item0"); int x = 1; foreach (var element in dictionary) { var tmp = x++; if (!dictionary.TryAdd(tmp, "item" + tmp.ToString())) { throw new Exception("   "); } Console.WriteLine(element); }
      
      





コンソールには何が表示されますか? 1つの要素またはプログラムが無限ループに入りますか? どちらもありません。 以下が表示されます。



[0、アイテム0]

[1、項目1]

[2、項目2]

[3、item3]

[4、item4]

[5、item5]

[6、item6]

[7、item7]

[8、item8]

[9、item9]

[10、item10]

[11、item11]

[12、item12]

[13、item13]

[14、item14]

[15、item15]

[16、item16]



例外はありませんでした。したがって、コレクションの18番目の要素は正常に挿入されましたが、反復子はそれを認識しませんでした。なぜですか?



このコレクションのソース 、つまりGetEnumeratorメソッドの実装を見てみましょう。



 public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator() { Node[] buckets = m_tables.m_buckets; for (int i = 0; i < buckets.Length; i++) { // The Volatile.Read ensures that the load of the fields of 'current' doesn't move before the load from buckets[i]. Node current = Volatile.Read<Node>(ref buckets[i]); while (current != null) { yield return new KeyValuePair<TKey, TValue>(current.m_key, current.m_value); current = current.m_next; } } }
      
      





m_tablesフィールドはvolatileキーワードでマークされているため、それに含まれるNode [] m_buckets配列の変更はすべてのスレッドに表示されます。 この配列の各要素は、単一リンクリストの最初の要素を表し、リストの次の要素へのリンクが含まれています。 さらに、要素の追加/変更が単純にリンクされたリスト自体の変更につながる限り、イテレータはこれらの変更を「認識」しますが、配列自体の変更はイテレータには見えません。



m_buckets配列は、2つの場合に変更されます。 最初は要素を挿入するときのサイズの増加、2番目はClear()メソッドの呼び出しです(配列のサイズをデフォルト値にリセットします)。

更新:

m_buckets配列のサイズが大きくなったときの質問に答えるために、ConcurrentDictionaryの内部構造について少し説明します。

コレクションからの追加/変更/削除操作でロックを提供するには、オブジェクト[] m_locks配列があり、そのデフォルトサイズは4 *プロセッサ数です(m_tables.m_bucketsが増加するたびに、ロック用のオブジェクトを含む配列のサイズは2倍になります)。

ロックごとの要素の最大数を定義するint m_budgetフィールドもあります。 次のように計算されます:m_buckets.Length / m_locks.Length。

各ロックの要素数はint [] m_countPerLockフィールドに含まれています。このフィールドは、リンクされたリストに新しい要素が追加されると増加し、リストから要素が削除されると減少します。

次に、m_buckets配列を増やす条件に戻ります。 条件テーブルの後に増加しますm_countPerLock [lockNo]> m_budgetが満たされる、つまり ロックごとの要素数が最大許容数を超えたとき。 このチェックは要素挿入メソッドの最後に行われ、現在の要素が挿入された後に内部m_bucketsコレクションのサイズ変更が行われることに注意してください。

私の例では、4つのプロセッサがあり、それぞれ配列のサイズはm_locks = 16、m_budget = 31/16 = 1です。17個の要素を挿入すると、2つの要素が1つのロックになり、コレクションが拡張されます。

/アップデート

更新および削除操作は配列のサイズを変更しないため、これらの変更はイテレーターに常に表示されます(もちろん、イテレーターがまだ到達していない要素の変更について話している場合)。



おわりに


コレクションの列挙中に行われた変更がいつ表示されるかはわかっていますが、表示されていない場合は、ConcurrentDictionaryを使用してプログラミングするときにこの知識を考慮すべきではありません。 行われた変更が表示される場合と表示されない場合があるというMSDNで説明されているルールに従うことをお勧めします。



All Articles