Структуры данных

Очередь Queue<T> в C#: методы и примеры

Обновлено: · 6 мин чтения

Очередь Queue<T> в C# работает по принципу FIFO: элемент, добавленный первым, первым же и извлекается. Такая структура нужна для буферов задач, обхода графов в ширину, обработки сообщений и всюду, где важен порядок поступления.

Ниже разобраны основные методы Enqueue, Dequeue и Peek, безопасные варианты TryDequeue и TryPeek, приоритетная очередь PriorityQueue и два практических примера.

Очередь Queue<T> в C#: методы и примеры
Содержание
  1. Принцип FIFO и устройство очереди
  2. Создание очереди
  3. Основные методы Queue
    1. Enqueue и Dequeue
    2. Peek без извлечения
  4. Безопасное извлечение: TryDequeue и TryPeek
  5. Перебор очереди
  6. Пример: обход графа в ширину
  7. Пример: буфер задач
  8. Очередь с приоритетом PriorityQueue
  9. Сравнение с другими коллекциями
  10. Типичные ошибки
  11. Частые вопросы

Принцип FIFO и устройство очереди

Аббревиатура FIFO расшифровывается как "first in, first out". Представьте очередь в кассу: новый человек встаёт в конец, а обслуживают того, кто стоит в начале. Добавление происходит с одного конца структуры, извлечение с другого, и никакого доступа к элементам в середине нет.

Класс Queue<T> из System.Collections.Generic реализован на кольцевом буфере: внутри массив, в котором хранятся индексы начала и конца. Благодаря этому и Enqueue, и Dequeue работают за константное время, а при нехватке места массив увеличивается вдвое. Для сравнения, у списка List удаление первого элемента требует сдвига всех остальных, поэтому использовать список как очередь неэффективно.

Создание очереди

Очередь можно создать пустой, с заданной начальной ёмкостью или на основе готовой коллекции. При копировании из коллекции порядок извлечения совпадает с порядком перебора исходной последовательности.

var empty = new Queue<string>();
var withCapacity = new Queue<int>(100);
var fromArray = new Queue<int>(new[] { 10, 20, 30 });

Console.WriteLine(fromArray.Count);  // 3
Console.WriteLine(fromArray.Peek()); // 10 - первым будет извлечён он

Основные методы Queue

У очереди совсем небольшой набор операций, и именно эта простота делает структуру надёжной. Главные методы собраны в таблице.

МетодЧто делаетЕсли очередь пуста
Enqueue(x)Добавляет элемент в конецРаботает как обычно
Dequeue()Извлекает и возвращает первый элементInvalidOperationException
Peek()Возвращает первый элемент, не удаляяInvalidOperationException
TryDequeue(out x)Извлекает без исключенияВозвращает false
TryPeek(out x)Смотрит первый без исключенияВозвращает false
Contains(x)Проверяет наличие значенияВозвращает false
Clear()Удаляет все элементыНичего не делает
ToArray()Копирует элементы в массив в порядке очередиПустой массив

Enqueue и Dequeue

Enqueue кладёт значение в хвост, Dequeue забирает из головы. Свойство Count показывает текущее число элементов. После каждого Dequeue размер уменьшается на единицу.

var queue = new Queue<string>();
queue.Enqueue("первый");
queue.Enqueue("второй");
queue.Enqueue("третий");

Console.WriteLine(queue.Dequeue()); // первый
Console.WriteLine(queue.Count);     // 2

while (queue.Count > 0)
    Console.WriteLine(queue.Dequeue()); // второй, третий

Peek без извлечения

Peek возвращает элемент из головы и оставляет его в очереди. Это полезно, когда нужно решить, обрабатывать ли задачу прямо сейчас, например сравнить время её запуска с текущим.

var q = new Queue<int>(new[] { 5, 6, 7 });
int head = q.Peek();
Console.WriteLine($"{head}, осталось {q.Count}"); // 5, осталось 3

Безопасное извлечение: TryDequeue и TryPeek

Вызов Dequeue на пустой очереди выбрасывает InvalidOperationException с сообщением "Queue empty". В многопоточном коде проверка Count перед Dequeue ненадёжна: между проверкой и извлечением очередь может опустеть. Поэтому в современном коде применяют TryDequeue и TryPeek, которые возвращают bool и отдают значение через out-параметр.

var queue = new Queue<int>();
queue.Enqueue(42);

if (queue.TryDequeue(out int value))
    Console.WriteLine($"Получили {value}");

if (!queue.TryDequeue(out _))
    Console.WriteLine("Очередь пуста");

if (queue.TryPeek(out int next))
    Console.WriteLine(next);

Эти методы появились в .NET Core 2.0 и доступны во всех актуальных версиях. Общий разбор таких исключений и способов их избежать есть в статье про NullReferenceException: логика похожая, нужно проверять состояние до обращения.

Перебор очереди

Цикл foreach обходит элементы от головы к хвосту, не извлекая их. Это удобно для вывода содержимого или подсчёта. Если вам нужно именно обработать и убрать элементы, используйте цикл while с TryDequeue. Изменять очередь внутри foreach нельзя: будет выброшено InvalidOperationException, как и в случае со списком.

var queue = new Queue<string>(new[] { "A", "B", "C" });

foreach (var item in queue)
    Console.Write(item + " ");   // A B C
Console.WriteLine(queue.Count);  // 3 - ничего не удалено

while (queue.TryDequeue(out var s))
    Console.Write(s.ToLower());  // abc

Пример: обход графа в ширину

Алгоритм поиска в ширину (BFS) невозможно реализовать без очереди. Он посещает вершины слоями: сначала все соседи стартовой, затем соседи соседей. Очередь гарантирует, что вершины ближнего слоя обработаются раньше дальних. Так находят кратчайший путь по числу рёбер в невзвешенном графе, проходят лабиринты и ищут связные компоненты.

var graph = new Dictionary<int, List<int>>
{
    [1] = new() { 2, 3 },
    [2] = new() { 4 },
    [3] = new() { 4, 5 },
    [4] = new() { 6 },
    [5] = new() { 6 },
    [6] = new()
};

static Dictionary<int, int> Bfs(Dictionary<int, List<int>> g, int start)
{
    var dist = new Dictionary<int, int> { [start] = 0 };
    var queue = new Queue<int>();
    queue.Enqueue(start);

    while (queue.TryDequeue(out int v))
    {
        foreach (int next in g[v])
        {
            if (dist.ContainsKey(next)) continue;
            dist[next] = dist[v] + 1;
            queue.Enqueue(next);
        }
    }
    return dist;
}

foreach (var (vertex, d) in Bfs(graph, 1))
    Console.WriteLine($"Вершина {vertex}: расстояние {d}");

Здесь словарь dist выполняет двойную функцию: хранит расстояния и служит множеством посещённых вершин. Каждая вершина попадает в очередь не более одного раза, поэтому общее время работы пропорционально числу вершин и рёбер.

Пример: буфер задач

Второй типичный сценарий - накопление работы, которую обрабатывает отдельный поток или фоновая служба. Производитель кладёт задачи в очередь, потребитель забирает их в порядке поступления. Для одного потока подойдёт обычный Queue<T>, а для нескольких используют потокобезопасный аналог.

var tasks = new Queue<Action>();
tasks.Enqueue(() => Console.WriteLine("Отправить письмо"));
tasks.Enqueue(() => Console.WriteLine("Сохранить отчёт"));
tasks.Enqueue(() => Console.WriteLine("Очистить кэш"));

while (tasks.TryDequeue(out var job))
{
    try { job(); }
    catch (Exception ex) { Console.WriteLine($"Ошибка задачи: {ex.Message}"); }
}

Если очередью пользуются сразу несколько потоков, берите ConcurrentQueue<T> из System.Collections.Concurrent. Его методы Enqueue и TryDequeue безопасны без внешней блокировки. А для сценариев с ожиданием новых элементов и ограничением размера лучше подходит Channel<T> из System.Threading.Channels, он поддерживает асинхронное чтение через await.

Очередь с приоритетом PriorityQueue

Иногда порядок FIFO не подходит: срочные задачи должны обслуживаться раньше обычных. Для этого в .NET 6 добавили класс PriorityQueue<TElement, TPriority>. Он хранит пары "элемент и приоритет", а Dequeue возвращает элемент с наименьшим значением приоритета. Внутри лежит двоичная куча, поэтому добавление и извлечение выполняются за логарифмическое время.

var pq = new PriorityQueue<string, int>();
pq.Enqueue("обычная задача", 5);
pq.Enqueue("срочная задача", 1);
pq.Enqueue("фоновая задача", 10);
pq.Enqueue("важная задача", 2);

while (pq.TryDequeue(out string? task, out int priority))
    Console.WriteLine($"{priority}: {task}");
// 1: срочная задача
// 2: важная задача
// 5: обычная задача
// 10: фоновая задача

Чтобы получить максимум первым, передайте свой компаратор или инвертируйте приоритет. Обратите внимание на два нюанса. Элементы с одинаковым приоритетом извлекаются в произвольном порядке, то есть стабильность не гарантируется. Кроме того, PriorityQueue не поддерживает перебор в порядке приоритета: UnorderedItems отдаёт элементы без сортировки.

// максимум первым
var maxFirst = new PriorityQueue<string, int>(
    Comparer<int>.Create((a, b) => b.CompareTo(a)));
maxFirst.Enqueue("A", 1);
maxFirst.Enqueue("B", 9);
Console.WriteLine(maxFirst.Dequeue()); // B

Приоритетная очередь лежит в основе алгоритма Дейкстры для кратчайших путей во взвешенном графе, планировщиков задач и слияния отсортированных потоков.

Сравнение с другими коллекциями

Выбор структуры зависит от порядка доступа. Очередь, стек и список хранят элементы последовательно, но отличаются правилами добавления и извлечения.

СтруктураПорядок извлеченияТипичное применение
Queue<T>Первым пришёл, первым ушёлЗадачи, BFS, буферы сообщений
Stack<T>Последним пришёл, первым ушёлОтмена действий, обход в глубину
PriorityQueue<T,P>По наименьшему приоритетуПланировщики, алгоритм Дейкстры
List<T>По любому индексуУниверсальное хранение
ConcurrentQueue<T>FIFO для нескольких потоковКонвейеры обработки данных

Типичные ошибки

Самая частая ошибка новичков - вызов Dequeue или Peek на пустой очереди без проверки. Решается через TryDequeue или условие Count > 0. Вторая - попытка получить элемент по индексу: у Queue<T> нет индексатора, а ToArray() для каждого обращения создаёт копию и убивает производительность. Если нужен произвольный доступ, структура выбрана неверно.

Третья ошибка касается потоков: обычный Queue<T> не потокобезопасен, и одновременные Enqueue из разных потоков приводят к потере данных или исключениям. Четвёртая - бесконтрольный рост: если производитель быстрее потребителя, очередь занимает всё больше памяти. В серьёзных системах размер ограничивают, как в BoundedChannel. Наконец, помните, что очередь ссылочных объектов удерживает их, пока они не извлечены, поэтому забытые элементы мешают сборщику мусора.

Частые вопросы

Чем очередь отличается от стека?

Очередь выдаёт самый старый элемент, стек самый новый. Очередь моделирует порядок поступления, стек подходит для отката действий и рекурсивных обходов.

Как очистить очередь?

Вызовите метод Clear(). Он удаляет все элементы и сбрасывает Count в ноль. Ёмкость внутреннего массива при этом сохраняется, а для её уменьшения служит TrimExcess().

Можно ли добавить в очередь null?

Для ссылочных типов можно, Queue<string> примет null как обычное значение. Но тогда результат Dequeue неотличим от пустого значения, поэтому лучше использовать TryDequeue и не класть null без нужды.

Как перевернуть очередь?

Превратите её в массив методом ToArray(), разверните через Array.Reverse и создайте новую очередь из результата. Для частой смены направления подойдёт двусторонний LinkedList<T>.

Как реализовать очередь с несколькими потоками?

Используйте ConcurrentQueue<T> или BlockingCollection<T>. Если нужен асинхронный код с await, выбирайте Channel<T>. Обычный Queue<T> допустим только при ручной синхронизации через lock.

Что вернёт PriorityQueue при равных приоритетах?

Порядок не определён. Если нужна стабильность, добавьте второй компонент приоритета, например порядковый номер добавления, и сравнивайте кортеж (приоритет, номер).