C# で キューというデータ構造を扱う Queue<T> クラスと、スレッド排他制御機能付きの ConcurrentQueue<T> クラスの使い方の紹介をしたいと思います。
2つのQueue
まず「Queue」ですが一言で言うと、入れたデータが入れた順番に取り出せる入れ物の事を指します。以下のような1本のパイプのイメージです。
こういったデータ構造を先入れ先出し(First In First Out)を略して FIFO と呼んだりします。C# にはこの FIFO をサポートする Queue<T> というクラスがありその使い方の紹介になります。
また「ConcurrentQueue<T>」はこの Queue が複数のスレッドから同時にアクセスしても安全なスレッドセーフという特徴を持っています。まとめるとこんな感じです。
クラス |
説明 |
Queue<T> |
通常の Queue |
ConcurrentQueue |
スレッドセーフな Queue |
ちなみに操作方法は「ほぼ」同じです。
尚この記事では System.Linq で定義されている Linq の拡張メソッドについては範囲が広大になるため言及しません。
確認環境
この記事は以下環境で動作を確認しています。
- VisualSturio2019
- .NET 5
- C# 9.0
Queue<T>の使い方
宣言
Queue<T> は完全名が「System.Collections.Generic.Queue<T>」のため最初に以下のように最初に using を宣言します。
using System.Collections.Generic;
基本的な操作
生成と値の出し入れは以下の通りです。List<T> と違って FIFO と用途が限定されているため利用できるメソッドが少なめです。
Queue<int> queue = new();
Queue<int> queue2 = new(256);
queue.Enqueue(0);
queue.Enqueue(10);
queue.Enqueue(100);
queue.Enqueue(1000);
queue.Enqueue(10000);
int a = queue.Dequeue();
int b = queue.Dequeue();
int c = queue.Dequeue();
int d = queue.Dequeue();
int e = queue.Dequeue();
int f = queue.Dequeue();
if (queue.TryDequeue(out int f2))
{
}
else if (queue.Count != 0)
{
int f3 = queue.Dequeue();
}
bool contains = queue.Contains(100);
queue.Clear();
queue.TrimExcess();
特殊な操作
値の出し入れ以外に出来る操作は以下の通りです。
Queue<int> queue = new();
queue.Enqueue(0);
queue.Enqueue(10);
queue.Enqueue(100);
queue.Enqueue(1000);
queue.Enqueue(10000);
int a = queue.Peek();
int b = queue.Peek();
int c = queue.Peek();
if (queue.TryPeek(out int f1))
{
}
else if (queue.Count != 0)
{
int f3 = queue.Peek();
}
int[] queueArray = queue.ToArray();
foreach (int item in queue)
{
Console.WriteLine(item);
}
ConcurrentQueue<T>の使い方
ConcurrentQueue ですが Queue と「ほぼ」同じです。ただ取り出すときの「Dequeue」と「Peek」が存在せず取り出すときは「TryDequeue」と「TryPeek」のみが存在します。これはこの Queue を使用するときは常に自分以外のスレッドから値が取り出されて、直前までは値があったのに自分が取り出すときに存在しないことがあるため「安全に中身を取り出す」ために Try~ 系で取り出すことになります。
宣言
ConcurrentQueue<T> は完全名が「System.Collections.Generic.ConcurrentQueue<T>」のため最初に以下のように最初に using を宣言します。
using System.Collections.Generic;
なんか色々インターフェースを継承していますが、スレッドセーフですよーの目印の「IProducerConsumerCollection」を継承しています。まぁでもこれに大した意味はないです。
各種操作
Queue とほぼ同じなのでざっくり以下の通りになります。
ConcurrentQueue<int> queue = new();
queue.Enqueue(0);
queue.Enqueue(1);
if (queue.TryDequeue(out int a))
{
Console.WriteLine($"a={a}");
}
if (queue.TryPeek(out int b))
{
Console.WriteLine($"b={b}");
}
スレッドセーフとは?
最後に Queue と ConcurrentQueue をマルチスレッドで使用したときの挙動の違いを確認します。まずは以下のコードとコメントを確認してください。
private static void Main(string[] args)
{
QueueMultiThreadTest();
ConcurrentQueueMultiThreadTest();
}
private static void QueueMultiThreadTest()
{
Queue<int> queue = new();
Parallel.For(0, 6, i =>
{
queue.Enqueue(i);
});
foreach (var item in queue)
{
Console.WriteLine(item);
}
Console.WriteLine();
Queue<int> queue2 = new();
queue2.Enqueue(0);
queue2.Enqueue(1);
queue2.Enqueue(2);
queue2.Enqueue(3);
queue2.Enqueue(4);
queue2.Enqueue(5);
Parallel.For(0, queue2.Count, i =>
{
int cnt = queue2.Dequeue();
Console.WriteLine(cnt);
});
}
private static void ConcurrentQueueMultiThreadTest()
{
ConcurrentQueue<int> queue = new();
Parallel.For(0, 6, i =>
{
queue.Enqueue(i);
});
foreach (var item in queue)
{
Console.WriteLine(item);
}
Console.WriteLine();
ConcurrentQueue<int> queue2 = new();
queue2.Enqueue(0);
queue2.Enqueue(1);
queue2.Enqueue(2);
queue2.Enqueue(3);
queue2.Enqueue(4);
queue2.Enqueue(5);
Parallel.For(0, queue2.Count, i =>
{
if (queue2.TryDequeue(out int cnt))
{
Console.WriteLine(cnt);
}
});
}
コード中のコメントに書きましたが Queue はマルチスレッドで使用すると内容が滅茶苦茶になります。途中で例外が出ることもあります。逆に ConcurrentQueue は内容に一貫性がある状態を保っています。複数のスレッドから同時に操作しようとしたときに安全かそうでないかが確認できました。
最後に
Queueでできない事
余談ですが Queue でできない事を以下に紹介します。
FIFO で最初に入れたものが最初に取れるという順序が保証できなくなる操作は提供されていません。
Queue<int> queue = new Queue<int>()
{
0, 10, 100
};
int a = queue[1];
queue.Sort();
List<T>との使い分け
Queue の機能ですが基本的に List<T> でも同じことができます。List の部分的な機能が Queue と言ってもいいかもしれません。
List<int> list = new()
{
0, 10, 100
};
list.Add(1000);
int i = list[0];
list.RemoveAt(0);
さて、List でも同じようなことができるのに Queue を使用する意義ですが、「ここは FIFO で順序を保証します」という設計意図が保証できます。この制限を他人に強制できてクラス自体余計な操作ができなので List のような柔軟な操作を禁止できます。
この制限により間に値を挿入したり先頭に値を追加することができません。List クラスは柔軟で動的な操作が可能かつ Linq も組み合わせると多様な機能が提供されている反面、自由すぎて実装意図を読み取るのは結構難しいケースがあるため実装の意図の明確化として有用なのではないかと思います。
ただ、最初は Queue で操作を制限していても途中で割り込み挿入が入ったり、ソートが必要等で仕様が変わると結局 List になってしまう事も多いので純粋な Queue が最後まで維持される事があまりないのも印象的です。
以上です。