System.Threading.ReaderWriterLockSlim クラス
この記事では、この API のリファレンス ドキュメントへの補足的な解説を提供します。
複数のスレッドによって読み取られ、一度に 1 つのスレッドによって書き込まれるリソースを保護するために使用 ReaderWriterLockSlim します。 ReaderWriterLockSlim では、複数のスレッドが読み取りモードになり、1 つのスレッドがロックの排他的所有権を持つ書き込みモードになり、読み取りアクセス権を持つ 1 つのスレッドがアップグレード可能な読み取りモードになり、そこからスレッドはリソースへの読み取りアクセスを放棄せずに書き込みモードにアップグレードできます。
Note
- ReaderWriterLockSlim は ReaderWriterLock と似ていますが、再帰の規則や、ロック状態のアップグレードおよびダウングレードの規則が簡素化されています。 ReaderWriterLockSlim は、デッドロックの可能性を大幅に回避します。 さらに、ReaderWriterLockSlim のパフォーマンスは ReaderWriterLock と比較して格段に優れています。 すべての新規開発で、ReaderWriterLockSlim を使用することをお勧めします。
- ReaderWriterLockSlim はスレッド中止セーフではありません。 .NET Framework など、アクセスするスレッドを中止できる環境では使用しないでください。 .NET Core または .NET 5 以降を使用している場合は、問題ありません。 Abort は .NET Core ではサポートされておらず、 .NET 5 以降のバージョンでは廃止 されています。
既定では、フラグを ReaderWriterLockSlim 使用して新しいインスタンスが LockRecursionPolicy.NoRecursion 作成され、再帰は許可されません。 再帰によって不要な複雑さが発生し、コードがデッドロックを起こしやすいため、この既定のポリシーはすべての新しい開発に推奨されます。 使用またはReaderWriterLock使用Monitorする既存のプロジェクトからの移行を簡略化するために、フラグをLockRecursionPolicy.SupportsRecursion使用して再帰を許可するインスタンスReaderWriterLockSlimを作成できます。
スレッドは、読み取りモード、書き込みモード、アップグレード可能な読み取りモードの 3 つのモードでロックを開始できます。 (このトピックの残りの部分では、"アップグレード可能な読み取りモード" は "アップグレード可能モード" と呼ばれ、"モードに入る" という長い語句に優先して "モードx
に入x
る" という語句が使用されます)。
再帰ポリシーに関係なく、書き込みモードにできるスレッドは 1 つだけです。 スレッドが書き込みモードの場合、他のスレッドはどのモードでもロックを開始できません。 アップグレード可能モードにできるスレッドは、いつでも 1 つだけです。 任意の数のスレッドを読み取りモードにすることができ、他のスレッドが読み取りモードである間は、1 つのスレッドをアップグレード可能モードにすることができます。
重要
この型は IDisposable インターフェイスを実装します。 型の使用が完了したら、直接的または間接的に型を破棄する必要があります。 直接的に型を破棄するには、try
/catch
ブロック内で Dispose メソッドを呼び出します。 間接的に型を破棄するには、using
(C# の場合) または Using
(Visual Basic 言語) などの言語構成要素を使用します。 詳細については、IDisposable インターフェイスに関するトピック内の「IDisposable を実装するオブジェクトの使用」セクションを参照してください。
ReaderWriterLockSlim にはマネージド スレッド アフィニティがあります。つまり、ロック モードを開始および終了するには、各 Thread オブジェクトが独自のメソッド呼び出しを行う必要があります。 スレッドが別のスレッドのモードを変更することはできません。
a が再帰を ReaderWriterLockSlim 許可しない場合、ロックに入ろうとするスレッドは、いくつかの理由でブロックする可能性があります。
書き込みモードへの入りを待機しているスレッドがある場合、または書き込みモードでスレッドが 1 つある場合に、読み取りモードブロックに入ろうとするスレッド。
Note
ライターがキューに登録されたときに新しいリーダーをブロックすることは、ライターを優先するロックの公平性ポリシーです。 現在の公平性ポリシーは、最も一般的なシナリオでスループットを向上させるために、読者とライターに公平性のバランスを取ります。 .NET の将来のバージョンでは、新しい公平性ポリシーが導入される可能性があります。
既にアップグレード可能モードのスレッドがある場合、書き込みモードに入るのを待機しているスレッドがある場合、または書き込みモードでスレッドが 1 つある場合に、アップグレード可能モードに入ろうとするスレッドがブロックされます。
3 つのモードのいずれかにスレッドがある場合に、書き込みモードブロックに入ろうとするスレッド。
ロックのアップグレードとダウングレード
アップグレード可能モードは、通常、スレッドが保護されたリソースから読み取るが、何らかの条件が満たされた場合に書き込みが必要になる場合を対象としています。 アップグレード可能モードに入ったReaderWriterLockSlimスレッドは、保護されたリソースへの読み取りアクセス権を持ち、またはTryEnterWriteLockメソッドを呼び出EnterWriteLockすことによって書き込みモードにアップグレードできます。 アップグレード可能モードのスレッドは一度に 1 つしか存在できないため、再帰が許可されていない場合、書き込みモードにアップグレードしてもデッドロックは発生しません。これは既定のポリシーです。
重要
再帰ポリシーに関係なく、最初に読み取りモードに入ったスレッドは、アップグレード可能モードまたは書き込みモードにアップグレードできません。これは、そのパターンによってデッドロックが発生する可能性が高いためです。 たとえば、読み取りモードの 2 つのスレッドが両方とも書き込みモードに入ろうとすると、デッドロックが発生します。 アップグレード可能モードは、このようなデッドロックを回避するように設計されています。
読み取りモードで他のスレッドがある場合は、ブロックをアップグレードしているスレッド。 スレッドがブロックされている間、読み取りモードに入ろうとする他のスレッドはブロックされます。 すべてのスレッドが読み取りモードから終了すると、ブロックされたアップグレード可能なスレッドは書き込みモードになります。 他のスレッドが書き込みモードに入るのを待機している場合はメインブロックされます。これは、アップグレード可能モードの 1 つのスレッドがリソースへの排他的アクセスを取得できないようにするためです。
アップグレード可能モードのスレッドが書き込みモードを終了すると、読み取りモードに入るのを待機している他のスレッドは、書き込みモードに入るのを待機しているスレッドがない限り、これを行うことができます。 アップグレード可能モードのスレッドは、保護されたリソースに書き込む唯一のスレッドである限り、無期限にアップグレードおよびダウングレードできます。
重要
複数のスレッドが書き込みモードまたはアップグレード可能モードに入ることを許可する場合、1 つのスレッドがアップグレード可能モードを独占できないようにする必要があります。 そうしないと、書き込みモードに直接入ろうとするスレッドは無期限にブロックされ、ブロックされている間は他のスレッドは読み取りモードに入ることができません。
アップグレード可能モードのスレッドは、最初にメソッドを呼び出してからメソッドを EnterReadLock 呼び出すことによって、読み取りモードに ExitUpgradeableReadLock ダウングレードできます。 このダウングレード パターンは、すべてのロック再帰ポリシーでも NoRecursion許可されます。
読み取りモードにダウングレードした後、スレッドは読み取りモードから終了するまでアップグレード可能モードに再入できません。
ロックを再帰的に入力する
ロック ポリシーを ReaderWriterLockSlim 指定するコンストラクターを使用して、再帰ロック エントリを ReaderWriterLockSlim(LockRecursionPolicy) サポートするオブジェクトを LockRecursionPolicy.SupportsRecursion作成し、
Note
再帰の使用は、不要な複雑さが発生し、コードがデッドロックを起こしやすいため、新しい開発にはお勧めしません。
再帰を許可する場合 ReaderWriterLockSlim は、スレッドが入力できるモードについて次のように言えます。
読み取りモードのスレッドは再帰的に読み取りモードに入ることができますが、書き込みモードまたはアップグレード可能モードに入ることはできません。 これを行おうとすると、a LockRecursionException がスローされます。 読み取りモードに入ってから書き込みモードまたはアップグレード可能モードに入るパターンは、デッドロックの可能性が高いパターンであるため、許可されません。 前述のように、ロックをアップグレードする必要がある場合にアップグレード可能モードが提供されます。
アップグレード可能モードのスレッドは、書き込みモードまたは読み取りモードに入ることができ、3 つのモードのいずれかを再帰的に入力できます。 ただし、読み取りモードで他のスレッドがある場合は、書き込みモードブロックに入ろうとします。
書き込みモードのスレッドは、読み取りモードまたはアップグレード可能モードに入ることができ、3 つのモードのいずれかを再帰的に入力できます。
ロックに入っていないスレッドは、任意のモードに入ることができます。 この試行は、非再帰ロックを入力する場合と同じ理由でブロックできます。
スレッドは、各モードをそのモードに入ったのとまったく同じ回数終了する限り、任意の順序で入力したモードを終了できます。 スレッドがモードを何度も終了しようとしたり、入力されていないモードを終了しようとすると、a SynchronizationLockException がスローされます。
ロック状態
ロックの状態の観点から考えると便利な場合があります。 A ReaderWriterLockSlim は、入力されていない、読み取り、アップグレード、書き込みの 4 つの状態のいずれかになります。
未入力: この状態では、スレッドがロックに入っていません (または、すべてのスレッドがロックを終了しました)。
読み取り: この状態では、1 つ以上のスレッドが、保護されたリソースへの読み取りアクセスのロックに入りました。
Note
スレッドは、読み取りモードでロックを開始するには、メソッドをEnterReadLockTryEnterReadLock使用するか、アップグレード可能モードからダウングレードします。
アップグレード: この状態では、1 つのスレッドが読み取りアクセスのロックに入り、書き込みアクセスにアップグレードするオプション (つまり、アップグレード可能モード) が設定され、0 個以上のスレッドが読み取りアクセスのロックに入りました。 アップグレードするオプションを使用してロックを開始できるスレッドは、一度に 1 つ以上ありません。アップグレード可能モードに入ろうとする追加のスレッドはブロックされます。
書き込み: この状態では、1 つのスレッドが保護されたリソースへの書き込みアクセスのロックに入りました。 そのスレッドはロックを排他的に所有しています。 何らかの理由でロックに入ろうとする他のスレッドはブロックされます。
次の表では、スレッド t
が左端の列で説明されているアクションを実行するときに、再帰を許可しないロックのロック状態間の遷移について説明します。 アクションを実行する時点では、 t
モードがありません。 (アップグレード可能モードの特殊なケース t
については、表の脚注で説明します)。一番上の行には、ロックの開始状態が記述されています。 セルはスレッドの動作を記述し、ロック状態の変更をかっこで囲んで表示します。
切り替え効果 | 未入力 (N) | 読み取り (R) | アップグレード (U) | 書き込み (W) |
---|---|---|---|---|
t 読み取りモードに切り替わります |
t enters (R)。 |
t スレッドが書き込みモードを待機している場合はブロックされます。それ以外の場合は、 t 入力します。 |
t スレッドが書き込みモードを待機している場合はブロックされます。それ以外の場合は、 t 入力します。1 |
t ブロック。 |
t アップグレード可能モードに切り替わります |
t enters (U)。 |
t スレッドが書き込みモードまたはアップグレード モードを待機している場合はブロックされます。それ以外の場合は、 t (U) を入力します。 |
t ブロック。 |
t ブロック。 |
t 書き込みモードに切り替わります |
t (W) を入力します。 |
t ブロック。 |
t ブロック。2 |
t ブロック。 |
1t
アップグレード可能モードで起動すると、読み取りモードになります。 このアクションはブロックされません。 ロック状態は変更されません。 (スレッドはアップグレード可能モードを終了することで、読み取りモードへのダウングレードを完了できます)。
2t
アップグレード可能モードで起動すると、読み取りモードのスレッドがある場合はブロックされます。 それ以外の場合は、書き込みモードにアップグレードされます。 ロック状態が書き込み (W) に変わります。 読み取りモードのスレッドがあるためにブロックする場合 t
、最後のスレッドが読み取りモードを終了するとすぐに、書き込みモードに入ります(書き込みモードに入るスレッドが存在する場合でも)。
スレッドがロックを終了したために状態の変更が発生した場合、次に覚醒するスレッドが次のように選択されます。
- 最初に、書き込みモードを待機していて、既にアップグレード可能モードになっているスレッド (そのようなスレッドは最大で 1 つ存在する可能性があります)。
- 失敗した場合、書き込みモードを待機しているスレッド。
- 失敗した場合、アップグレード可能モードを待機しているスレッド。
- 失敗すると、読み取りモードを待機しているすべてのスレッド。
ロックの後続の状態は、最初の 2 つのケースでは常に書き込み (W) になり、3 番目のケースではアップグレード (U) は、終了するスレッドが状態の変更をトリガーしたときのロックの状態に関係なく行われます。 最後のケースでは、状態変更後にアップグレード可能モードのスレッドがある場合、ロックの状態は Upgrade (U) になり、それ以外の場合は以前の状態に関係なく読み取り (R) になります。
例
次の例は、整数キーを持つ文字列を保持する単純な同期キャッシュを示しています。 インスタンス ReaderWriterLockSlim は、内部キャッシュとして機能するインスタンスへの Dictionary<TKey,TValue> アクセスを同期するために使用されます。
この例には、キャッシュに追加し、キャッシュから削除し、キャッシュから読み取る簡単なメソッドが含まれています。 タイムアウトを示すために、この例には、指定されたタイムアウト内でキャッシュに追加できる場合にのみ追加するメソッドが含まれています。
アップグレード可能モードを示すために、この例には、キーに関連付けられている値を取得し、それを新しい値と比較するメソッドが含まれています。 値が変更されていない場合、メソッドは変更がないことを示す状態を返します。 キーの値が見つからない場合は、キーと値のペアが挿入されます。 値が変更された場合は更新されます。 アップグレード可能モードを使用すると、スレッドは読み取りアクセスから必要に応じて書き込みアクセスにアップグレードでき、デッドロックのリスクはありません。
この例には、アップグレード可能モードを示すメソッドの戻り値を指定する入れ子になった列挙体が含まれています。
この例では、パラメーターなしのコンストラクターを使用してロックを作成するため、再帰は許可されません。 ロックで ReaderWriterLockSlim 再帰が許可されていない場合、プログラミングが簡単になり、エラーが発生しにくくなります。
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
Imports System.Collections.Generic
Imports System.Threading
Imports System.Threading.Tasks
public class SynchronizedCache
{
private ReaderWriterLockSlim cacheLock = new ReaderWriterLockSlim();
private Dictionary<int, string> innerCache = new Dictionary<int, string>();
public int Count
{ get { return innerCache.Count; } }
public string Read(int key)
{
cacheLock.EnterReadLock();
try
{
return innerCache[key];
}
finally
{
cacheLock.ExitReadLock();
}
}
public void Add(int key, string value)
{
cacheLock.EnterWriteLock();
try
{
innerCache.Add(key, value);
}
finally
{
cacheLock.ExitWriteLock();
}
}
public bool AddWithTimeout(int key, string value, int timeout)
{
if (cacheLock.TryEnterWriteLock(timeout))
{
try
{
innerCache.Add(key, value);
}
finally
{
cacheLock.ExitWriteLock();
}
return true;
}
else
{
return false;
}
}
public AddOrUpdateStatus AddOrUpdate(int key, string value)
{
cacheLock.EnterUpgradeableReadLock();
try
{
string result = null;
if (innerCache.TryGetValue(key, out result))
{
if (result == value)
{
return AddOrUpdateStatus.Unchanged;
}
else
{
cacheLock.EnterWriteLock();
try
{
innerCache[key] = value;
}
finally
{
cacheLock.ExitWriteLock();
}
return AddOrUpdateStatus.Updated;
}
}
else
{
cacheLock.EnterWriteLock();
try
{
innerCache.Add(key, value);
}
finally
{
cacheLock.ExitWriteLock();
}
return AddOrUpdateStatus.Added;
}
}
finally
{
cacheLock.ExitUpgradeableReadLock();
}
}
public void Delete(int key)
{
cacheLock.EnterWriteLock();
try
{
innerCache.Remove(key);
}
finally
{
cacheLock.ExitWriteLock();
}
}
public enum AddOrUpdateStatus
{
Added,
Updated,
Unchanged
};
~SynchronizedCache()
{
if (cacheLock != null) cacheLock.Dispose();
}
}
Public Class SynchronizedCache
Private cacheLock As New ReaderWriterLockSlim()
Private innerCache As New Dictionary(Of Integer, String)
Public ReadOnly Property Count As Integer
Get
Return innerCache.Count
End Get
End Property
Public Function Read(ByVal key As Integer) As String
cacheLock.EnterReadLock()
Try
Return innerCache(key)
Finally
cacheLock.ExitReadLock()
End Try
End Function
Public Sub Add(ByVal key As Integer, ByVal value As String)
cacheLock.EnterWriteLock()
Try
innerCache.Add(key, value)
Finally
cacheLock.ExitWriteLock()
End Try
End Sub
Public Function AddWithTimeout(ByVal key As Integer, ByVal value As String, _
ByVal timeout As Integer) As Boolean
If cacheLock.TryEnterWriteLock(timeout) Then
Try
innerCache.Add(key, value)
Finally
cacheLock.ExitWriteLock()
End Try
Return True
Else
Return False
End If
End Function
Public Function AddOrUpdate(ByVal key As Integer, _
ByVal value As String) As AddOrUpdateStatus
cacheLock.EnterUpgradeableReadLock()
Try
Dim result As String = Nothing
If innerCache.TryGetValue(key, result) Then
If result = value Then
Return AddOrUpdateStatus.Unchanged
Else
cacheLock.EnterWriteLock()
Try
innerCache.Item(key) = value
Finally
cacheLock.ExitWriteLock()
End Try
Return AddOrUpdateStatus.Updated
End If
Else
cacheLock.EnterWriteLock()
Try
innerCache.Add(key, value)
Finally
cacheLock.ExitWriteLock()
End Try
Return AddOrUpdateStatus.Added
End If
Finally
cacheLock.ExitUpgradeableReadLock()
End Try
End Function
Public Sub Delete(ByVal key As Integer)
cacheLock.EnterWriteLock()
Try
innerCache.Remove(key)
Finally
cacheLock.ExitWriteLock()
End Try
End Sub
Public Enum AddOrUpdateStatus
Added
Updated
Unchanged
End Enum
Protected Overrides Sub Finalize()
If cacheLock IsNot Nothing Then cacheLock.Dispose()
End Sub
End Class
次のコードでは、オブジェクトを SynchronizedCache
使用して野菜名のディクショナリを格納します。 3 つのタスクが作成されます。 1 つ目は、配列に格納されている野菜の名前をインスタンスに SynchronizedCache
書き込みます。 2 番目と 3 番目のタスクでは、野菜の名前が表示されます。1 つ目は昇順 (低いインデックスから高いインデックスまで) で、2 番目のタスクは降順で表示されます。 最後のタスクは文字列 "きゅうり" を検索し、見つかると、メソッドを EnterUpgradeableReadLock 呼び出して文字列 "green bean" に置き換えます。
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
Imports System.Collections.Generic
Imports System.Threading
Imports System.Threading.Tasks
public class Example
{
public static void Main()
{
var sc = new SynchronizedCache();
var tasks = new List<Task>();
int itemsWritten = 0;
// Execute a writer.
tasks.Add(Task.Run( () => { String[] vegetables = { "broccoli", "cauliflower",
"carrot", "sorrel", "baby turnip",
"beet", "brussel sprout",
"cabbage", "plantain",
"spinach", "grape leaves",
"lime leaves", "corn",
"radish", "cucumber",
"raddichio", "lima beans" };
for (int ctr = 1; ctr <= vegetables.Length; ctr++)
sc.Add(ctr, vegetables[ctr - 1]);
itemsWritten = vegetables.Length;
Console.WriteLine("Task {0} wrote {1} items\n",
Task.CurrentId, itemsWritten);
} ));
// Execute two readers, one to read from first to last and the second from last to first.
for (int ctr = 0; ctr <= 1; ctr++) {
bool desc = ctr == 1;
tasks.Add(Task.Run( () => { int start, last, step;
int items;
do {
String output = String.Empty;
items = sc.Count;
if (! desc) {
start = 1;
step = 1;
last = items;
}
else {
start = items;
step = -1;
last = 1;
}
for (int index = start; desc ? index >= last : index <= last; index += step)
output += String.Format("[{0}] ", sc.Read(index));
Console.WriteLine("Task {0} read {1} items: {2}\n",
Task.CurrentId, items, output);
} while (items < itemsWritten | itemsWritten == 0);
} ));
}
// Execute a red/update task.
tasks.Add(Task.Run( () => { Thread.Sleep(100);
for (int ctr = 1; ctr <= sc.Count; ctr++) {
String value = sc.Read(ctr);
if (value == "cucumber")
if (sc.AddOrUpdate(ctr, "green bean") != SynchronizedCache.AddOrUpdateStatus.Unchanged)
Console.WriteLine("Changed 'cucumber' to 'green bean'");
}
} ));
// Wait for all three tasks to complete.
Task.WaitAll(tasks.ToArray());
// Display the final contents of the cache.
Console.WriteLine();
Console.WriteLine("Values in synchronized cache: ");
for (int ctr = 1; ctr <= sc.Count; ctr++)
Console.WriteLine(" {0}: {1}", ctr, sc.Read(ctr));
}
}
// The example displays the following output:
// Task 1 read 0 items:
//
// Task 3 wrote 17 items
//
//
// Task 1 read 17 items: [broccoli] [cauliflower] [carrot] [sorrel] [baby turnip] [
// beet] [brussel sprout] [cabbage] [plantain] [spinach] [grape leaves] [lime leave
// s] [corn] [radish] [cucumber] [raddichio] [lima beans]
//
// Task 2 read 0 items:
//
// Task 2 read 17 items: [lima beans] [raddichio] [cucumber] [radish] [corn] [lime
// leaves] [grape leaves] [spinach] [plantain] [cabbage] [brussel sprout] [beet] [b
// aby turnip] [sorrel] [carrot] [cauliflower] [broccoli]
//
// Changed 'cucumber' to 'green bean'
//
// Values in synchronized cache:
// 1: broccoli
// 2: cauliflower
// 3: carrot
// 4: sorrel
// 5: baby turnip
// 6: beet
// 7: brussel sprout
// 8: cabbage
// 9: plantain
// 10: spinach
// 11: grape leaves
// 12: lime leaves
// 13: corn
// 14: radish
// 15: green bean
// 16: raddichio
// 17: lima beans
Public Module Example
Public Sub Main()
Dim sc As New SynchronizedCache()
Dim tasks As New List(Of Task)
Dim itemsWritten As Integer
' Execute a writer.
tasks.Add(Task.Run( Sub()
Dim vegetables() As String = { "broccoli", "cauliflower",
"carrot", "sorrel", "baby turnip",
"beet", "brussel sprout",
"cabbage", "plantain",
"spinach", "grape leaves",
"lime leaves", "corn",
"radish", "cucumber",
"raddichio", "lima beans" }
For ctr As Integer = 1 to vegetables.Length
sc.Add(ctr, vegetables(ctr - 1))
Next
itemsWritten = vegetables.Length
Console.WriteLine("Task {0} wrote {1} items{2}",
Task.CurrentId, itemsWritten, vbCrLf)
End Sub))
' Execute two readers, one to read from first to last and the second from last to first.
For ctr As Integer = 0 To 1
Dim flag As Integer = ctr
tasks.Add(Task.Run( Sub()
Dim start, last, stp As Integer
Dim items As Integer
Do
Dim output As String = String.Empty
items = sc.Count
If flag = 0 Then
start = 1 : stp = 1 : last = items
Else
start = items : stp = -1 : last = 1
End If
For index As Integer = start To last Step stp
output += String.Format("[{0}] ", sc.Read(index))
Next
Console.WriteLine("Task {0} read {1} items: {2}{3}",
Task.CurrentId, items, output,
vbCrLf)
Loop While items < itemsWritten Or itemsWritten = 0
End Sub))
Next
' Execute a red/update task.
tasks.Add(Task.Run( Sub()
For ctr As Integer = 1 To sc.Count
Dim value As String = sc.Read(ctr)
If value = "cucumber" Then
If sc.AddOrUpdate(ctr, "green bean") <> SynchronizedCache.AddOrUpdateStatus.Unchanged Then
Console.WriteLine("Changed 'cucumber' to 'green bean'")
End If
End If
Next
End Sub ))
' Wait for all three tasks to complete.
Task.WaitAll(tasks.ToArray())
' Display the final contents of the cache.
Console.WriteLine()
Console.WriteLine("Values in synchronized cache: ")
For ctr As Integer = 1 To sc.Count
Console.WriteLine(" {0}: {1}", ctr, sc.Read(ctr))
Next
End Sub
End Module
' The example displays output like the following:
' Task 1 read 0 items:
'
' Task 3 wrote 17 items
'
' Task 1 read 17 items: [broccoli] [cauliflower] [carrot] [sorrel] [baby turnip] [
' beet] [brussel sprout] [cabbage] [plantain] [spinach] [grape leaves] [lime leave
' s] [corn] [radish] [cucumber] [raddichio] [lima beans]
'
' Task 2 read 0 items:
'
' Task 2 read 17 items: [lima beans] [raddichio] [cucumber] [radish] [corn] [lime
' leaves] [grape leaves] [spinach] [plantain] [cabbage] [brussel sprout] [beet] [b
' aby turnip] [sorrel] [carrot] [cauliflower] [broccoli]
'
' Changed 'cucumber' to 'green bean'
'
' Values in synchronized cache:
' 1: broccoli
' 2: cauliflower
' 3: carrot
' 4: sorrel
' 5: baby turnip
' 6: beet
' 7: brussel sprout
' 8: cabbage
' 9: plantain
' 10: spinach
' 11: grape leaves
' 12: lime leaves
' 13: corn
' 14: radish
' 15: green bean
' 16: raddichio
' 17: lima beans
.NET