C# 引入的 PriorityQueue 集合类型提供了一种基于堆数据结构的优先级排序能力,它允许元素按照优先级高低依次出队,而不是遵循先进的先出规则。这种结构在任务调度、事件处理、寻路算法等场景中非常常见。理解其核心 API 和调度算法,是掌握高级并发与任务编排的关键一步。

本文将围绕 PriorityQueue 的基础用法、自定义比较器、线程安全的调度器设计以及性能优化展开,帮助你从简单的入队出队操作进阶到可以应对真实业务场景的调度逻辑。
PriorityQueue 核心 API 与堆排序基础
PriorityQueue<TElement,TPriority> 位于 System.Collections.Generic 命名空间下,它不要求元素类型实现任何特殊接口,而是把优先级单独作为一个泛型参数。默认情况下,它使用最小堆实现,也就是说优先级数值越小的元素会越早出队。如果你希望数值越大越优先,可以通过自定义比较器来反转顺序。
最基础的用法只需要三个方法:Enqueue 用于入队,TryDequeue 用于安全出队,Peek 用于查看队首元素而不移除。下面这个示例展示了字符串任务按整数优先级依次被处理的过程,注意输出顺序会从优先级 1 开始,而不是按入队顺序。
using System;
using System.Collections.Generic;
var pq = new PriorityQueue<string, int>();
pq.Enqueue("执行数据库备份", 8);
pq.Enqueue("处理用户登录请求", 1);
pq.Enqueue("生成周报", 4);
while (pq.TryDequeue(out var task, out var priority))
{
Console.WriteLine($"正在执行:{task},优先级:{priority}");
}
这个示例的输出顺序固定为“处理用户登录请求、生成周报、执行数据库备份”。需要注意的是,TryDequeue 返回布尔值,避免在队列为空时抛出异常,这在多线程环境下尤其重要。此外,PriorityQueue 的 Count 属性可以快速获取元素数量,但它不是线程安全的,并发访问时需要加锁。
堆排序的特性决定了每次入队和出队的时间复杂度都是 O(log n),远优于线性扫描的 O(n)。如果你的场景需要频繁插入和删除,PriorityQueue 是理想选择;但如果只是偶尔取最大值,直接遍历列表可能更简单。
自定义优先级比较器与复杂对象调度
实际项目中很少只调度字符串,更常见的是将带有多个字段的业务对象放入队列,例如一个包含任务名称、严重程度和创建时间的 TaskItem 类。如果直接使用默认的整数优先级,当两个任务优先级相同时,出队顺序是不确定的,这在要求一定顺序的业务中会引发问题。
为了解决这个问题,可以自定义 IComparer<TPriority> 的实现,把优先级和创建时间组合起来,或者使用降序比较器让高数值优先。下面是一个反转默认顺序的比较器,它让优先级数值越大的任务越先出队。
public class DescendingIntComparer : IComparer<int>
{
public int Compare(int x, int y) => y.CompareTo(x);
}
var pq = new PriorityQueue<string, int>(new DescendingIntComparer());
pq.Enqueue("普通邮件发送", 5);
pq.Enqueue("密码重置请求", 50);
pq.Enqueue("系统告警", 100);
while (pq.TryDequeue(out var task, out var priority))
{
Console.WriteLine($"处理任务:{task}(优先级 {priority})");
}
这个比较器只处理了整数优先级,但更复杂的场景可能需要先比较优先级,再比较创建时间。此时可以把优先级和创建时间封装成一个结构体,让结构体实现 IComparable<T>,再将其作为 TPriority。这样比较逻辑集中在结构体内部,外部调用更简洁。例如定义 TaskPriorityKey 结构体包含严重程度和时间戳,在 CompareTo 方法中先比较严重程度,严重程度相同再比较时间,保证相同优先级下先创建的优先处理。
引入自定义比较器后,调度规则可以更贴近业务。但也要注意比较器会被频繁调用,如果比较逻辑过于复杂,比如包含字符串比较或数据库查询,会明显拉低入队和出队性能。因此比较器内部应尽量只做数值比较,避免分配对象或执行重操作。
实现一个基于优先级的后台任务调度器
将 PriorityQueue 直接暴露给多线程调用并不安全,多个线程同时 Enqueue 或 Dequeue 会导致堆结构损坏。一个常见的做法是使用锁和信号量封装一层调度器,由单一线程消费队列,其他线程只负责提交任务。下面是一个完整的后台任务调度器实现,它使用 lock 保护队列,并用 Monitor.Wait 和 Monitor.Pulse 协调生产者和消费者。
public class PriorityTaskScheduler : IDisposable
{
private readonly PriorityQueue<ScheduledTask, TaskPriorityKey> _queue;
private readonly object _sync = new();
private readonly CancellationTokenSource _cts = new();
private readonly Thread _worker;
public PriorityTaskScheduler()
{
_queue = new PriorityQueue<ScheduledTask, TaskPriorityKey>();
_worker = new Thread(RunLoop)
{
IsBackground = true,
Name = "PriorityTaskWorker"
};
}
public void Start() => _worker.Start();
public void Enqueue(ScheduledTask task, int severity, DateTime createdAt)
{
var key = new TaskPriorityKey(severity, createdAt);
lock (_sync)
{
_queue.Enqueue(task, key);
Monitor.Pulse(_sync);
}
}
private void RunLoop()
{
while (!_cts.IsCancellationRequested)
{
ScheduledTask task = null;
lock (_sync)
{
while (_queue.Count == 0 && !_cts.IsCancellationRequested)
{
Monitor.Wait(_sync);
}
if (_cts.IsCancellationRequested)
break;
task = _queue.Dequeue();
}
try
{
task.Execute();
}
catch (Exception ex)
{
Console.WriteLine($"任务执行失败:{ex.Message}");
}
}
}
public void Dispose()
{
_cts.Cancel();
lock (_sync)
{
Monitor.PulseAll(_sync);
}
_worker.Join();
_cts.Dispose();
}
}
上面的代码中,ScheduledTask 是一个抽象类或接口,包含 Execute 方法;TaskPriorityKey 是之前提到的优先级键结构体。调度器启动后,工作线程会在队列为空时阻塞等待,一旦有任务入队就会被唤醒并按优先级取出执行。异常被捕获在单个任务内部,避免整个调度线程崩溃。
这种单消费者模式的优点在于避免了复杂的并发集合,锁的粒度也可以接受,因为堆操作本身很快。但如果入队频率极高,锁竞争可能成为瓶颈。此时可以考虑使用无锁的 Channel 配合优先级队列,先按通道缓冲再批量入队,或者使用支持并发的优先级队列第三方库。对于大多数业务系统来说,上面的实现已经足够稳定且易于理解。
动态调整优先级也是调度器常见的需求,比如长时间等待的任务可以逐步提升优先级。你可以为任务维护一个 WaitCount 或 Age 字段,并在每次扫描时重新计算优先级键。不过 PriorityQueue 不支持直接修改元素的优先级,需要先出队再以新优先级入队,或者维护一个可重建的堆。在调度器中,一种折中方案是定期将队列元素取出重新构建,虽然 O(n log n) 但频率可控。
性能优化与进阶注意事项
PriorityQueue 内部使用数组存储堆节点,默认容量会随着元素增加自动扩容,扩容时会发生数组复制。如果你预先知道调度任务的大致数量,可以在构造时传入初始容量,例如 new PriorityQueue<string, int>(initialCapacity: 1000),这样可以减少内存分配和复制开销。不过设置过大的初始容量也会浪费内存,需要根据实际负载权衡。
另一个容易被忽略的细节是优先级类型的比较开销。对于 int 这样的值类型,比较成本极低;但如果优先级是自定义结构体并且包含字符串字段,装箱和字符串比较会显著拖慢性能。建议在优先级键中使用基础数值类型,或者用整数编码优先级和序号,例如将严重程度乘以一个大的基数加上自增序列号,这样既能保证唯一性,又能保持比较高效。
还有一点是稳定性问题。PriorityQueue 不保证相同优先级元素的出队顺序,即使它们按时间先后入队。如果业务要求先到先服务,就需要在 TPriority 中编码入队序号,例如使用一个全局递增的 long 计数器,将 (priority, sequence) 组合成可比较的键。这个技巧在很多消息队列和任务调度框架中都能看到。
最后,使用 UnorderedItems 属性可以枚举队列中的所有元素,但枚举顺序不是按优先级排序的,也不能保证稳定。它适合用于快照统计等非关键场景,不要在枚举过程中修改队列,否则会抛出异常。掌握这些进阶注意点后,你就可以放心地把 PriorityQueue 应用到定时任务、事件驱动系统或实时匹配引擎中了。
C# PriorityQueue优先级队列调度算法修改时间:2026-09-21 10:29:59