第九章:C#并发集合
第九章:并发集合
- 第九章:并发集合
- 简介
- 9.1 不可变的栈和队列
- 9.2 不可变列表 (
ImmutableList<T>
) - 9.3 不可变
Set
- 9.4 不可变字典 (
ImmutableDictionary<TKey, TValue>
) - 9.5 并发字典 (
ConcurrentDictionary<TKey, TValue>
) - 9.6 并发队列(
Concurrent Queue<T>
) - 9.7 并发栈(
Concurrent Stack<T>
) - 9.8 并发背包(
ConcurrentBag<T>
) - 9.9 阻塞队列(
Blocking Queue<T>
) - 9.10 阻塞栈与阻塞背包
- 9.11 异步队列
- 9.12 节流队列
- 9.13 采样队列
- 9.14 异步栈和异步背包
- 9.15 同步与异步混合队列
在并发应用程序中,选择合适的集合类型至关重要。本章将介绍一些专门为并发或异步场景设计的集合,帮助你在多线程环境中有效管理数据。
简介
不可变集合
不可变集合是 只读 的数据结构,任何修改操作都会返回新的集合实例,而不会改变现有的集合。这种设计不仅减少了内存浪费,还具有天然的线程安全性,因为不可变对象在多线程环境下无需加锁。你可以通过 System.Collections.Immutable
NuGet 包获取这些集合,并在多线程或单线程应用中使用它们。不可变集合是未来开发中推荐的默认选择,尤其在需要安全并发访问时。
线程安全集合
这些集合允许多个线程同时修改数据,它们通过混合使用 细粒度锁 和 无锁技术 来最小化阻塞时间,甚至完全避免阻塞。线程安全集合的一个特点是,它们的枚举操作会创建集合的快照,从而确保枚举过程的安全。
生产者与消费者集合
这类集合专为 生产者-消费者 模式设计,允许多个生产者向集合中添加数据,多个消费者从集合中取出数据。它们支持 阻塞 和 异步 API,适用于不同的并发场景。例如,当集合为空时,阻塞集合会阻塞调用线程,而异步集合则允许线程异步等待,直至有数据可用。
生产者-消费者集合类型
本章将介绍多种生产者-消费者集合,每种集合适用于不同的并发需求:
- 通道(
Channel
):支持队列语义和异步 API,适用于大多数并发场景。 BlockingCollection<T>
:提供阻塞 API,适合同步的生产者-消费者模型。BufferBlock<T>
:基于数据流模型,适用于异步的场景。AsyncProducerConsumerQueue<T>
和AsyncCollection<T>
:支持异步操作,但适用场景较为特殊。
这些集合都可以通过相关的 NuGet 包获取,例如 System.Threading.Channels
和 System.Threading.Tasks.Dataflow
。
9.1 不可变的栈和队列
问题
假设你需要一个 多线程安全 的栈或队列,这些集合不会频繁改变,但可以安全地被多个线程读取和操作。例如,队列可以用于管理待处理任务,栈可以用于管理撤销操作。
解决方案
不可变栈 和 不可变队列 是最简单的不可变集合。它们的行为类似于标准的 Stack<T>
和 Queue<T>
,但每次修改后都会生成一个新的集合实例,相当于是一个快照,保留原来的集合不变。由于不可变集合的特性,它们天然是线程安全的。
不可变栈(ImmutableStack<T>
)
栈是 先进后出(LIFO)的数据结构。每次 Push
操作会创建一个新的栈实例,保留旧的栈不变。以下是不可变栈的示例:
ImmutableStack<int> stack = ImmutableStack<int>.Empty;
stack = stack.Push(13);
stack = stack.Push(7); // 栈中现在是 [7, 13]
// 枚举栈元素,显示顺序 [7, 13]
foreach (int item in stack)
Trace.WriteLine(item);
int lastItem;
stack = stack.Pop(out lastItem); // 弹出栈顶的7,栈中剩下 [13]
每次修改(如 Push
和 Pop
操作),都会返回一个新的栈实例,而原始栈保持不变。因此,不同的栈实例可以共享相同的内存部分。例如:
ImmutableStack<int> stack = ImmutableStack<int>.Empty;
stack = stack.Push(13);
ImmutableStack<int> biggerStack = stack.Push(7);
// biggerStack 包含 [7, 13],而 stack 仍然是 [13]
foreach (int item in biggerStack)
Trace.WriteLine(item); // 输出 7, 13
foreach (int item in stack)
Trace.WriteLine(item); // 输出 13
这种共享内存的机制使得不可变栈非常高效,尤其在需要保存多个状态快照时。
不可变队列(ImmutableQueue<T>
)
与栈类似,不可变队列是 先进先出(FIFO)的数据结构。每次 Enqueue
操作会生成一个新的队列实例,而旧的队列保持不变。以下是不可变队列的示例:
ImmutableQueue<int> queue = ImmutableQueue<int>.Empty;
queue = queue.Enqueue(13);
queue = queue.Enqueue(7); // 队列中现在是 [13, 7]
// 枚举队列元素,显示顺序 [13, 7]
foreach (int item in queue)
Trace.WriteLine(item);
queue = queue.Dequeue(out int firstItem); // 弹出队首的13,队列中剩下 [7]
讨论
- 不可变栈和队列 适合需要多线程访问且修改较少的场景。因为它们是不可变的,每个实例都是线程安全的。
- 修改操作(如
Push
、Pop
和Enqueue
)都会返回新的实例,原有集合保持不变,可以轻松创建数据的快照。 - 共享内存的机制提高了内存使用效率,尤其适合需要频繁保存状态的场景。
不可变集合不仅适用于并发编程场景,也同样适合单线程应用,尤其在函数式编程风格或需要频繁存储快照的情况下。
9.2 不可变列表 (ImmutableList<T>
)
问题
当需要一个支持索引访问、且不会频繁修改的数据结构时,不可变列表(ImmutableList<T>
)是一个合适的选择。它可以安全地被多个线程读取和操作,但需要注意其性能特性。
解决方案
ImmutableList<T>
提供类似于 List<T>
的方法,如 Insert
、RemoveAt
和 Index
操作,但其表现背后基于树形结构,允许尽可能多的内存共享。以下是一个使用不可变列表的示例:
ImmutableList<int> list = ImmutableList<int>.Empty;
list = list.Insert(0, 13);
list = list.Insert(0, 7); // 在13之前插入7
foreach (int item in list)
Trace.WriteLine(item); // 输出 7, 13
list = list.RemoveAt(1); // 移除索引1的元素,剩下 [7]
性能差异
ImmutableList<T>
与 List<T>
在某些操作上的性能差异显著。以下是常见操作的复杂度对比:
操作 | List<T> |
ImmutableList<T> |
---|---|---|
添加 | 均摊 O(1) | O(log N) |
插入 | O(N) | O(log N) |
移除 | O(N) | O(log N) |
索引访问 | O(1) | O(log N) |
特别地,ImmutableList<T>
的索引操作是 O(log N),而不是 List<T>
的 O(1)。因此,遍历时应尽量使用 foreach
而非 for
,以避免性能问题:
// 推荐的遍历方式
foreach (var item in list)
Trace.WriteLine(item);
// 遍历效率较低
for (int i = 0; i < list.Count; i++)
Trace.WriteLine(list[i]);
讨论
ImmutableList<T>
是一个强大的数据结构,尤其适合需要多线程安全和低频修改的场景。然而,由于其性能特性,不能盲目替换所有的 List<T>
。在大多数情况下,List<T>
仍是默认选择,除非明确需要不可变集合。
ImmutableList<T>
及其他不可变集合可以通过 System.Collections.Immutable
NuGet 包获取。
9.3 不可变 Set
问题
在某些场景下,我们需要一个不会存储重复项、且可以安全地被多个线程读取的数据结构。例如,索引文件中的单词集合是一个典型的用例。为了满足这些需求,可以使用不可变 Set
,这种集合不会频繁变化,并且能确保线程安全。
解决方案
在 .NET 中,有两种主要的不可变 Set
实现:
ImmutableHashSet<T>
:一个无序的唯一项集合。ImmutableSortedSet<T>
:一个通过比较器排序的唯一项集合。
它们提供类似的接口,但在元素存储上有所不同。
ImmutableHashSet<T>
ImmutableHashSet<T>
是一个无序的集合,不保证元素的顺序,但确保不存储重复项。以下是一个示例:
ImmutableHashSet<int> hashSet = ImmutableHashSet<int>.Empty;
hashSet = hashSet.Add(13);
hashSet = hashSet.Add(7); // 以不可预知的顺序显示7和13
foreach (int item in hashSet)
Trace.WriteLine(item); // 输出 7, 13 或 13, 7
hashSet = hashSet.Remove(7); // 移除元素7
ImmutableSortedSet<T>
ImmutableSortedSet<T>
是一个有序集合,元素按照某种排序规则排列,可以通过索引访问最小或最大元素。示例如下:
ImmutableSortedSet<int> sortedSet = ImmutableSortedSet<int>.Empty;
sortedSet = sortedSet.Add(13);
sortedSet = sortedSet.Add(7); // 7 在 13 之前
foreach (int item in sortedSet)
Trace.WriteLine(item); // 输出 7, 13
int smallestItem = sortedSet[0]; // smallestItem == 7
sortedSet = sortedSet.Remove(7); // 移除元素7
性能差异
尽管 ImmutableHashSet<T>
和 ImmutableSortedSet<T>
的结构不同,其大多数操作的时间复杂度相似。以下是两者的典型性能对比:
操作 | ImmutableHashSet<T> |
ImmutableSortedSet<T> |
---|---|---|
添加 | O(log N) | O(log N) |
移除 | O(log N) | O(log N) |
索引访问 | 不适用 | O(log N) |
对于大多数应用,如果不需要元素排序,建议优先选择 ImmutableHashSet<T>
,因为它适用于更多类型。ImmutableSortedSet<T>
需要类型支持比较器,且索引访问的效率较低(O(log N)),这意味着在遍历时应尽量使用 foreach
而非 for
循环。
讨论
虽然不可变 Set
是线程安全且实用的数据结构,但对于大规模数据的填充,性能可能较慢。为优化性能,可以先使用可变集合进行批量操作,最后转换为不可变集合。许多不可变集合,包括 ImmutableHashSet<T>
和 ImmutableSortedSet<T>
,都提供了这种方式的构造器。
你可以通过 System.Collections.Immutable
NuGet 包获取这些不可变集合。
9.4 不可变字典 (ImmutableDictionary<TKey, TValue>
)
问题
在某些场景下,需要一个键–值对集合,它不会频繁更改,并且能够安全地被多个线程访问。例如,引用数据可以存储在这样的集合中,供不同线程在不加锁的情况下读取。
解决方案
ImmutableDictionary<TKey, TValue>
和 ImmutableSortedDictionary<TKey, TValue>
是两种不可变字典:
ImmutableDictionary<TKey, TValue>
:无序字典,键–值对的顺序不可预知。ImmutableSortedDictionary<TKey, TValue>
:有序字典,键–值对按键排序。
它们的接口非常相似。以下是使用 ImmutableDictionary
的示例:
ImmutableDictionary<int, string> dictionary = ImmutableDictionary<int, string>.Empty;
dictionary = dictionary.Add(10, "Ten");
dictionary = dictionary.Add(21, "Twenty-One");
dictionary = dictionary.SetItem(10, "Diez"); // 更新键10的值
foreach (KeyValuePair<int, string> item in dictionary)
Trace.WriteLine($"{item.Key}: {item.Value}"); // 输出 "10: Diez" 和 "21: Twenty-One"
string ten = dictionary[10]; // ten == "Diez"
dictionary = dictionary.Remove(21); // 移除键21
使用 ImmutableSortedDictionary
时,键会按顺序排列:
ImmutableSortedDictionary<int, string> sortedDictionary = ImmutableSortedDictionary<int, string>.Empty;
sortedDictionary = sortedDictionary.Add(10, "Ten");
sortedDictionary = sortedDictionary.Add(21, "Twenty-One");
sortedDictionary = sortedDictionary.SetItem(10, "Diez");
foreach (KeyValuePair<int, string> item in sortedDictionary)
Trace.WriteLine($"{item.Key}: {item.Value}"); // 先输出 "10: Diez",再输出 "21: Twenty-One"
string ten = sortedDictionary[10]; // ten == "Diez"
sortedDictionary = sortedDictionary.Remove(21); // 移除键21
性能对比
ImmutableDictionary
和 ImmutableSortedDictionary
的性能相似,通常的操作耗时如下表所示:
操作 | ImmutableDictionary<TKey, TValue> |
ImmutableSortedDictionary<TKey, TValue> |
---|---|---|
添加 | O(log N) | O(log N) |
设置项 | O(log N) | O(log N) |
访问项 | O(log N) | O(log N) |
移除 | O(log N) | O(log N) |
尽管性能相近,除非明确需要元素排序,通常推荐使用无序的 ImmutableDictionary
,因为它可以应用于更多类型的键,并且整体运行得更快。
讨论
字典是一种常用的工具,尤其在应用程序状态管理和查找场景中。与其他不可变集合类似,不可变字典也提供了高效的构造器,可以在需要初始化大量元素时快速构建字典。如果字典中的数据是在应用程序启动时加载的,通常应使用构造器来构建初始字典;如果数据是逐步构建的,Add
方法则更合适。
你可以通过 System.Collections.Immutable
NuGet 包获取 ImmutableDictionary<TKey, TValue>
和 ImmutableSortedDictionary<TKey, TValue>
。
9.5 并发字典 (ConcurrentDictionary<TKey, TValue>
)
ConcurrentDictionary<TKey, TValue>
是 C# 中一种线程安全的字典集合,位于 System.Collections.Concurrent
命名空间中。它是为了解决多线程场景下的字典操作而设计的,提供高效且线程安全的字典操作,无需显式使用锁 (lock
)。
使用场景
- 多线程或异步环境中,多个线程需要共享并访问相同的字典数据。
- 需要在并发操作下维护字典的正确性和一致性。
- 替代传统的字典 (
Dictionary<TKey, TValue>
) 在并发场景下避免手动使用锁,提高性能和简化代码。
代码示例
以下示例展示了 ConcurrentDictionary<TKey, TValue>
的常见用法:
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
class Program
{
static void Main()
{
var dict = new ConcurrentDictionary<string, int>();
// 添加元素
dict.TryAdd("key1", 1);
dict.TryAdd("key2", 2);
// 更新元素:如果键存在则更新,否则添加
dict.AddOrUpdate("key1", 10, (key, oldValue) => oldValue + 10);
// 获取元素
if (dict.TryGetValue("key1", out int value))
{
Console.WriteLine($"key1 的值为:{value}");
}
// 并行操作
Parallel.For(0, 1000, i =>
{
dict.AddOrUpdate("counter", 1, (key, oldValue) => oldValue + 1);
});
Console.WriteLine($"并行更新后的 counter 值为:{dict["counter"]}");
// 移除元素
dict.TryRemove("key2", out int removedValue);
Console.WriteLine($"已移除的 key2 值为:{removedValue}");
}
}
输出:
key1 的值为:11
并行更新后的 counter 值为:1000
已移除的 key2 值为:2
背后原理
ConcurrentDictionary<TKey, TValue>
使用了一种 分段锁定 (Lock Stripping) 的策略来实现线程安全性。它将内部存储划分为多个独立的桶 (bucket),每个桶可以独立加锁,从而减少了锁冲突,提高了并发性能。
具体来说:
-
分段锁定:
ConcurrentDictionary
会根据键的哈希值将其映射到不同的分段中,不同的分段互不影响。- 当对字典进行写操作(例如
AddOrUpdate
或TryRemove
)时,只会锁定涉及的分段,而不是整个字典。
-
乐观并发控制:
- 在某些情况下,
ConcurrentDictionary
使用了 乐观并发控制,例如在读取数据时尽量避免加锁,而是先尝试读取,然后在发现冲突时才加锁重试。
- 在某些情况下,
-
线程安全的操作:
ConcurrentDictionary
提供了多种原子操作,如AddOrUpdate
、TryAdd
、TryRemove
和TryGetValue
,确保在并发情况下不会发生竞态条件。
常用方法
方法 | 说明 |
---|---|
TryAdd(key, value) |
尝试添加键值对,如果键已存在则返回 false 。 |
AddOrUpdate(key, addValue, updateValueFactory) |
如果键存在则更新值,不存在则添加。 |
TryGetValue(key, out value) |
尝试获取键对应的值,获取成功返回 true 。 |
TryRemove(key, out value) |
尝试移除键值对,移除成功返回 true 。 |
ContainsKey(key) |
检查字典中是否包含指定的键。 |
Count |
获取字典中键值对的数量。 |
Clear() |
清空字典中的所有元素。 |
最佳实践
-
避免过度竞争:
- 尽量减少字典操作的频率,尤其是在高并发场景下。虽然
ConcurrentDictionary
是线程安全的,但过多的写操作仍会引起性能瓶颈。
- 尽量减少字典操作的频率,尤其是在高并发场景下。虽然
-
选择合适的数据结构:
- 在单线程环境下,使用普通的
Dictionary<TKey, TValue>
会有更好的性能。 - 在大多数并发读多于写的情况下,
ConcurrentDictionary
的性能优于手动加锁的字典。
- 在单线程环境下,使用普通的
-
利用原子操作:
- 使用
AddOrUpdate
和GetOrAdd
等原子操作,而不是先检查再更新或添加,避免竞态条件。
- 使用
与其他线程安全集合的比较
集合类型 | 线程安全性 | 使用场景 |
---|---|---|
Dictionary<TKey, TValue> |
非线程安全 | 单线程或手动加锁控制的场景。 |
ConcurrentDictionary<TKey, TValue> |
线程安全 | 多线程并发访问,特别是需要频繁的读写操作。 |
ImmutableDictionary<TKey, TValue> |
线程安全(不可变) | 多线程读多于写,不需要修改字典内容的场景。 |
9.6 并发队列(Concurrent Queue<T>
)
在现代并发编程中,队列是一种常用的数据结构,它以先进先出(FIFO)的方式处理数据。标准的 Queue<T>
在多线程环境下并不安全,C# 提供了 ConcurrentQueue<T>
作为线程安全的替代品,能够在高并发环境下进行无锁(lock-free)操作。
使用场景
- 日志记录系统:日志通常是以队列的形式存储,多个线程可能同时记录日志,而日志处理线程则依次从队列中读取并保存。
- 任务调度器:在任务调度中,多个生产者线程可以将任务排入队列,而工作线程则依次处理这些任务。
- 事件处理系统:多个事件源可能同时产生事件,事件处理器从队列中逐一获取并处理这些事件。
代码示例
下面的示例展示了如何使用 ConcurrentQueue<T>
实现简单的多线程任务队列。
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
class Program
{
private static readonly ConcurrentQueue<int> _queue = new ConcurrentQueue<int>();
static async Task Main()
{
// 启动生产者任务
var producerTask = Task.Run(() => Producer());
// 启动消费者任务
var consumerTask1 = Task.Run(() => Consumer("消费者1"));
var consumerTask2 = Task.Run(() => Consumer("消费者2"));
await Task.WhenAll(producerTask, consumerTask1, consumerTask2);
Console.WriteLine("所有任务已完成");
}
// 生产者线程
static void Producer()
{
for (int i = 0; i < 10; i++)
{
_queue.Enqueue(i);
Console.WriteLine($"生产者添加:{i}");
Thread.Sleep(200); // 模拟生产过程的延迟
}
}
// 消费者线程
static void Consumer(string name)
{
while (true)
{
if (_queue.TryDequeue(out int item))
{
Console.WriteLine($"{name} 消费了:{item}");
Thread.Sleep(300); // 模拟处理过程的延迟
}
else
{
break;
}
}
Console.WriteLine($"{name} 处理完毕");
}
}
输出示例:
生产者添加:0
消费者1 消费了:0
生产者添加:1
消费者2 消费了:1
生产者添加:2
消费者1 消费了:2
生产者添加:3
消费者2 消费了:3
...
消费者1 处理完毕
消费者2 处理完毕
所有任务已完成
背后原理
-
无锁设计:
ConcurrentQueue<T>
是基于无锁算法实现的,内部使用了比较并交换(CAS, Compare-And-Swap)操作。这种设计可以在高并发环境下避免锁的开销,提高性能。ConcurrentQueue<T>
使用链表结构作为底层存储,支持高效的入队和出队操作。
-
线程安全性:
ConcurrentQueue<T>
的所有公共方法都是线程安全的。例如,Enqueue
方法可以被多个线程同时调用,不会产生数据竞态问题。- 读取和修改操作是原子的,即操作之间不会互相干扰,保证了数据一致性。
常用方法
方法 | 说明 |
---|---|
Enqueue(item) |
将元素添加到队列末尾。 |
TryDequeue(out T result) |
尝试从队列开头移除并返回一个元素,如果队列为空则返回 false 。 |
TryPeek(out T result) |
尝试查看队列开头的元素而不移除它,如果队列为空则返回 false 。 |
IsEmpty |
检查队列是否为空。 |
Count |
返回队列中的元素数量(注意:在高并发下,Count 可能不准确,仅用于参考)。 |
最佳实践
-
避免在高并发环境下频繁调用
Count
:Count
方法会遍历队列来计算元素数量,因此在高并发情况下性能较低,尽量避免频繁调用。- 可以通过
IsEmpty
来检查队列是否为空,而不是使用Count == 0
。
-
优先使用
TryDequeue
和TryPeek
:- 这些方法都是线程安全的,不会抛出异常,也不会阻塞线程。
- 在并发环境中,使用这些非阻塞方法可以避免不必要的锁等待。
-
适用于无需严格顺序保证的场景:
ConcurrentQueue<T>
是无锁设计,适用于高吞吐量的场景,但不适合需要严格顺序控制的场景。
-
避免在 UI 线程中直接访问:
- 在 UI 应用程序中,避免在 UI 线程上直接调用可能阻塞的操作,尤其是在高并发下频繁入队和出队的场景。
总结
ConcurrentQueue<T>
是 C# 中常用的并发集合,提供了无锁的队列操作,能够在高并发环境下高效、安全地执行入队和出队操作。它适用于生产者-消费者模式,但需要注意在高并发情况下避免频繁调用 Count
方法。在更复杂的场景下,如需要阻塞行为或异步操作,可以考虑使用 BlockingCollection<T>
或 TPL 数据流 (BufferBlock<T>
) 作为替代方案。
9.7 并发栈(Concurrent Stack<T>
)
ConcurrentStack<T>
是 C# 中的一种线程安全的栈结构,遵循后进先出(LIFO)的原则,适用于高并发环境下的快速入栈和出栈操作。
使用场景
- 任务回滚:需要按逆序撤销操作时,将任务入栈以便后续回退。
- 递归问题迭代化:在某些算法中,栈结构可以代替递归实现。
- 高并发缓存:存储和快速回收对象实例。
代码示例
以下示例展示多个线程如何安全地使用 ConcurrentStack<T>
。
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
class Program
{
private static readonly ConcurrentStack<int> _stack = new ConcurrentStack<int>();
static async Task Main()
{
// 生产者任务:入栈
var producer = Task.Run(() =>
{
for (int i = 0; i < 5; i++)
{
_stack.Push(i);
Console.WriteLine($"生产者入栈:{i}");
}
});
// 消费者任务:出栈
var consumer = Task.Run(() =>
{
while (_stack.TryPop(out int item))
{
Console.WriteLine($"消费者出栈:{item}");
}
});
await Task.WhenAll(producer, consumer);
Console.WriteLine("任务完成");
}
}
主要方法
方法 | 说明 |
---|---|
Push(item) |
将元素压入栈顶。 |
TryPop(out T) |
尝试从栈顶移除并返回元素,若为空则返回 false 。 |
TryPeek(out T) |
查看栈顶元素而不移除,若为空则返回 false 。 |
PushRange(items) |
批量入栈。 |
TryPopRange() |
批量出栈,返回移除的元素数量。 |
背后原理
- 无锁实现:
ConcurrentStack<T>
基于无锁算法设计,使用Interlocked
和 CAS(比较并交换)确保线程安全。 - 链表结构:内部采用链表存储,支持高效的入栈和出栈操作。
最佳实践
- 适合无序任务:使用
ConcurrentStack<T>
时,不需要关心元素的顺序(除了 LIFO 的操作顺序)。 - 批量操作优化性能:对大量数据使用
PushRange
或TryPopRange
可以显著提高性能。 - 避免频繁调用
Count
:Count
的计算可能在高并发下开销较大,不推荐用于实时判断集合大小。
总结
ConcurrentStack<T>
提供了线程安全的 LIFO 操作,适用于需要按逆序处理任务的场景。它简单高效,特别适合任务撤销或递归替代问题。在需要更复杂的行为(如阻塞、容量限制等)时,可以选择其他并发集合,如 BlockingCollection<T>
。
9.8 并发背包(ConcurrentBag<T>
)
ConcurrentBag<T>
是 C# 提供的线程安全集合,适用于高并发场景下的无序数据存取,允许多个线程同时添加和移除元素。
使用场景
- 对象池:用于存储重复使用的对象,避免频繁的分配和回收。
- 无序任务分配:当任务处理顺序不重要时,多个线程可以并行从集合中取任务。
- 高并发数据处理:适用于并发情况下不关心元素顺序的任务,如计数器汇总等。
代码示例
以下示例展示了多个线程如何安全地在 ConcurrentBag<T>
中并发添加和移除元素。
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
class Program
{
private static readonly ConcurrentBag<int> _bag = new ConcurrentBag<int>();
static async Task Main()
{
// 生产者任务:向背包中添加元素
var producer = Task.Run(() =>
{
for (int i = 0; i < 5; i++)
{
_bag.Add(i);
Console.WriteLine($"生产者添加:{i}");
}
});
// 消费者任务:从背包中取出元素
var consumer = Task.Run(() =>
{
while (!_bag.IsEmpty)
{
if (_bag.TryTake(out int item))
{
Console.WriteLine($"消费者取出:{item}");
}
}
});
await Task.WhenAll(producer, consumer);
Console.WriteLine("任务完成");
}
}
主要方法
方法 | 说明 |
---|---|
Add(item) |
向背包中添加一个元素。 |
TryTake(out T) |
尝试从背包中取出一个元素,若背包为空返回 false 。 |
TryPeek(out T) |
查看一个元素但不移除,若背包为空返回 false 。 |
ToArray() |
返回背包中的所有元素组成的数组。 |
IsEmpty |
检查背包是否为空。 |
背后原理
- 无序存储:
ConcurrentBag<T>
不保证元素的顺序,多个线程可以同时添加和移除元素,但移除的顺序是不可预测的。 - 桶式设计:内部采用多个桶(bucket)来存储数据,减少锁的争用,提高并发性能。
- 高效的并发访问:通过线程局部存储(TLS)和分段锁优化,避免频繁的全局锁争用,提升性能。
最佳实践
- 避免依赖顺序:
ConcurrentBag<T>
适合无序数据的场景。若顺序重要,考虑使用其他并发集合。 - 适用于大量临时数据:对于短生命周期且无序的数据,
ConcurrentBag<T>
是一个高效选择。 - 适用于工作窃取模式:如果任务的顺序不重要,可以让多个消费者线程从同一
ConcurrentBag<T>
中工作窃取任务。
总结
ConcurrentBag<T>
是一种高效的线程安全集合,适用于无序任务分配和对象池等场景。它支持并发的元素添加和移除,且通过内部分段优化提高性能。在处理不关心顺序的任务时,ConcurrentBag<T>
是理想的选择,但对于需要顺序或限制容量的场景,建议选择其他并发集合。
9.9 阻塞队列(Blocking Queue<T>
)
在并发编程中,阻塞队列是一种用于在生产者线程和消费者线程之间安全传递数据的集合。
BlockingCollection<T>
是 .NET 提供的通用阻塞集合,在默认情况下是包装的ConcurrentQueue<T>
来充当线程安全的阻塞队列,但它可以通过包装不同的线程安全集合(如 ConcurrentStack<T>
或 ConcurrentBag<T>
),实现后进先出(LIFO)的栈行为或无序存储的背包行为。BlockingCollection<T>
主要用于解决生产者-消费者问题。
使用场景
- 在多线程程序中,需要在不同线程之间传递数据。
- 生产者线程不断产生数据,而消费者线程不断处理数据。
- 需要在生产者速度和消费者速度不匹配时进行调节和控制。
- 当线程池或后台线程负责处理数据时,阻塞队列可以有效避免数据丢失和线程竞争。
代码示例
下面的示例展示了使用 BlockingCollection<T>
实现生产者-消费者模型:
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
class Program
{
private static readonly BlockingCollection<int> _blockingQueue = new BlockingCollection<int>();
static async Task Main()
{
// 启动生产者线程
var producerTask = Task.Run(() => Producer());
// 启动消费者线程
var consumerTask = Task.Run(() => Consumer());
await Task.WhenAll(producerTask, consumerTask);
Console.WriteLine("所有任务已完成");
}
// 生产者线程
static void Producer()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"生产数据:{i}");
_blockingQueue.Add(i);
Thread.Sleep(500); // 模拟生产过程的延迟
}
// 标记生产完成
_blockingQueue.CompleteAdding();
Console.WriteLine("生产者完成所有数据的添加");
}
// 消费者线程
static void Consumer()
{
foreach (var item in _blockingQueue.GetConsumingEnumerable())
{
Console.WriteLine($"消费数据:{item}");
Thread.Sleep(1000); // 模拟处理过程的延迟
}
Console.WriteLine("消费者完成所有数据的处理");
}
}
输出示例:
生产数据:0
消费数据:0
生产数据:1
消费数据:1
生产数据:2
消费数据:2
生产数据:3
消费数据:3
生产数据:4
消费数据:4
生产者完成所有数据的添加
消费者完成所有数据的处理
所有任务已完成
背后原理
-
线程安全性:
BlockingCollection<T>
是基于IProducerConsumerCollection<T>
接口实现的,并且默认使用ConcurrentQueue<T>
作为内部存储容器。- 它使用了内部锁或信号量(SemaphoreSlim)来保证线程安全,使得多个线程能够同时访问集合而不会出现竞态条件。
-
阻塞机制:
BlockingCollection<T>
在获取或添加元素时,如果队列为空或达到容量上限,它会自动阻塞当前线程,直到有数据可供消费或有空间可供添加为止。
-
并发控制:
BlockingCollection<T>
支持容量限制,可以通过构造函数指定最大容量。这种方式可以在生产者生产过快时进行节流,防止队列无限增长导致内存溢出。
常用方法
方法 | 说明 |
---|---|
Add(item) |
向集合中添加元素,如果达到容量上限会阻塞。 |
Take() |
从集合中移除并返回一个元素,如果集合为空会阻塞。 |
TryTake(out T) |
非阻塞地尝试获取一个元素,若无元素返回 false 。 |
CompleteAdding() |
标记集合不再接受新元素,消费者线程会收到结束信号。 |
GetConsumingEnumerable() |
返回可枚举的集合,消费者线程可以使用此方法在数据消费完毕后自动退出循环。 |
IsCompleted |
检查集合是否已完成添加且没有可消费的元素。 |
最佳实践
-
优先使用
GetConsumingEnumerable()
:GetConsumingEnumerable()
能自动处理阻塞和结束信号,代码更为简洁和安全,避免手动处理循环和异常。
-
适当设置容量限制:
- 使用构造函数
BlockingCollection<T>(boundedCapacity)
设置容量上限,防止生产者过快生产导致内存问题。
var boundedQueue = new BlockingCollection<int>(100); // 容量上限为 100
- 使用构造函数
-
在适当时使用
CompleteAdding()
:- 当生产者线程完成数据生产后,调用
CompleteAdding()
通知消费者不会再有新数据,避免消费者线程无限等待。
- 当生产者线程完成数据生产后,调用
-
避免在 UI 线程中使用阻塞操作:
- 在 UI 应用程序中使用时,避免直接调用阻塞方法(如
Add
和Take
),否则可能导致 UI 卡顿。可以使用Task
或async/await
实现异步操作。
- 在 UI 应用程序中使用时,避免直接调用阻塞方法(如
总结
BlockingCollection<T>
是一种适合在多线程环境下使用的阻塞队列,能够简化生产者-消费者模型的实现。它不仅线程安全,还支持容量限制和自动阻塞机制,适用于后台线程和线程池场景。然而,在需要异步操作或复杂数据流的场合,TPL 数据流 (BufferBlock<T>
) 或异步生产者-消费者队列 (AsyncProducerConsumerQueue<T>
) 可能是更好的选择。
9.10 阻塞栈与阻塞背包
BlockingCollection<T>
是 .NET 提供的通用阻塞集合,在默认情况下充当线程安全的阻塞队列,但它可以通过包装不同的线程安全集合(如 ConcurrentStack<T>
或 ConcurrentBag<T>
),实现后进先出(LIFO)的栈行为或无序存储的背包行为。
使用场景
-
阻塞栈(LIFO):
适用于后进先出的任务处理场景,如撤销操作队列、递归任务处理等。 -
阻塞背包(无序):
适用于无序任务分配,如对象池、无序的并发数据处理。
代码示例
以下示例展示了阻塞栈和阻塞背包的基本用法:
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Threading.Tasks;
class Program
{
private static readonly BlockingCollection<int> _blockingStack =
new BlockingCollection<int>(new ConcurrentStack<int>());
private static readonly BlockingCollection<int> _blockingBag =
new BlockingCollection<int>(new ConcurrentBag<int>());
static async Task Main()
{
// 阻塞栈的生产者与消费者
var stackProducer = Task.Run(() =>
{
_blockingStack.Add(1);
_blockingStack.Add(2);
_blockingStack.CompleteAdding(); // 标记为完成
});
var stackConsumer = Task.Run(() =>
{
foreach (var item in _blockingStack.GetConsumingEnumerable())
{
Trace.WriteLine($"阻塞栈消费者处理:{item}");
}
});
await Task.WhenAll(stackProducer, stackConsumer);
// 阻塞背包的生产者与消费者
var bagProducer = Task.Run(() =>
{
_blockingBag.Add(3);
_blockingBag.Add(4);
_blockingBag.CompleteAdding();
});
var bagConsumer = Task.Run(() =>
{
foreach (var item in _blockingBag.GetConsumingEnumerable())
{
Trace.WriteLine($"阻塞背包消费者处理:{item}");
}
});
await Task.WhenAll(bagProducer, bagConsumer);
}
}
阻塞栈与背包的关键特性
-
阻塞行为:
当消费者尝试获取项时,若集合为空,会阻塞直到有项可用,或者集合标记为完成。 -
包装线程安全集合:
- 阻塞栈:通过
ConcurrentStack<T>
实现 LIFO 行为。 - 阻塞背包:通过
ConcurrentBag<T>
实现无序存储。
- 阻塞栈:通过
-
竞争条件的影响:
当生产者和消费者并发运行时,项的顺序可能与预期略有不同,例如消费者可能立即获取最新项,而不是等待CompleteAdding
。
主要方法
方法 | 说明 |
---|---|
Add(item) |
添加元素到集合。 |
CompleteAdding() |
标记集合不再接受新元素。 |
GetConsumingEnumerable() |
以阻塞方式逐个获取集合中的元素。 |
TryTake(out T) |
非阻塞地尝试获取一个元素,若无元素返回 false 。 |
注意事项
-
节流支持:
可以通过设置BlockingCollection<T>
的容量限制内存使用,防止生产者过快填充。 -
生产者与消费者平衡:
在多线程场景中,确保生产者和消费者处理能力相匹配,避免积压。 -
替代方案:
对于需要异步操作的场景,考虑使用BufferBlock<T>
或其他异步队列工具。
总结
BlockingCollection<T>
是一种多功能阻塞集合,适用于需要线程安全数据传递的场景。- 阻塞栈和阻塞背包扩展了
BlockingCollection<T>
的适用范围,分别提供了 LIFO 和无序的存取特性。 - 在复杂并发场景中,配合容量限制与消费者模式,能够有效提高程序的稳定性和吞吐量。
9.11 异步队列
异步队列是一种支持异步生产者-消费者模式的数据结构,适用于需要在不同代码片段间传递数据且避免线程阻塞的场景,例如,数据加载任务在后台进行时,主线程(如 UI 线程)需要异步地显示数据,而不能因为等待数据而被阻塞。。
使用场景
- 异步数据流:在不阻塞线程的情况下实现先进先出的异步数据传递。
- 跨线程数据共享:在生产者和消费者间传递数据,例如后台任务生成数据,主线程更新 UI。
- 高性能高并发:适用于高吞吐量的异步数据生产与消费场景。
实现方案
核心 .NET
框架没有原生的异步队列类型,但可以通过外部库来实现异步 API 的队列。这些库不仅允许生产者和消费者以非阻塞的方式交互,还提供了高性能和高容量的解决方案。
以下是常用的三种异步队列实现方式:
1. Channels 库
System.Threading.Channels
是一种现代化的异步生产者-消费者实现,支持高容量场景并具有高性能。核心概念是 Channel<T>
,由生产者通过 WriteAsync
写入数据,消费者通过 ReadAllAsync
异步读取数据。
代码示例:
using System;
using System.Diagnostics;
using System.Threading.Channels;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
var channel = Channel.CreateUnbounded<int>(); // 创建无界通道
// 生产者
var producer = Task.Run(async () =>
{
var writer = channel.Writer;
await writer.WriteAsync(7);
await writer.WriteAsync(13);
writer.Complete(); // 通知通道生产完成
});
// 消费者
var consumer = Task.Run(async () =>
{
var reader = channel.Reader;
await foreach (var item in reader.ReadAllAsync())
{
Trace.WriteLine($"消费:{item}");
}
});
await Task.WhenAll(producer, consumer);
}
}
优点:
- 支持异步流(
ReadAllAsync
)。 - 提供多种节流和采样策略。
- 高效、灵活,适合现代 .NET 平台。
在 较旧的平台 上,异步流可能不受支持,消费者代码可以使用 WaitToReadAsync
和 TryRead
来替代异步流:
ChannelReader<int> reader = queue.Reader;
while (await reader.WaitToReadAsync())
{
while (reader.TryRead(out int value))
{
Trace.WriteLine(value); // 输出 7 和 13
}
}
WaitToReadAsync
:异步等待通道中有可读取的项。TryRead
:尝试读取项,成功时返回true
,否则返回false
。
2. BufferBlock(TPL 数据流)
BufferBlock<T>
是 TPL 数据流的一部分,提供类似队列的行为。生产者使用 SendAsync
发送数据,消费者通过 ReceiveAsync
接收数据。
代码示例:
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using System.Threading.Tasks.Dataflow;
class Program
{
static async Task Main()
{
var buffer = new BufferBlock<int>(); // 创建 BufferBlock
// 生产者
var producer = Task.Run(async () =>
{
await buffer.SendAsync(7);
await buffer.SendAsync(13);
buffer.Complete(); // 标记完成
});
// 消费者
var consumer = Task.Run(async () =>
{
while (await buffer.OutputAvailableAsync())
{
var item = await buffer.ReceiveAsync();
Trace.WriteLine($"消费:{item}");
}
});
await Task.WhenAll(producer, consumer);
}
}
SendAsync
:异步地将数据发送到队列。OutputAvailableAsync
:异步地检查队列中是否有可用的数据。ReceiveAsync
:异步地从队列中获取数据。
注意:当有多个消费者时,OutputAvailableAsync
可能会为多个消费者返回 true
,即使队列中只有一个可用项。因此,如果有多个消费者,建议使用如下代码模式来处理可能的异常:
while (true)
{
try
{
int item = await _asyncQueue.ReceiveAsync();
Trace.WriteLine(item); // 输出队列中的数据
}
catch (InvalidOperationException)
{
break; // 队列已完成,退出循环
}
}
3. AsyncProducerConsumerQueue
Nito.AsyncEx
提供了 AsyncProducerConsumerQueue<T>
,类似 BufferBlock<T>
,但适合使用该库的项目。
代码示例:
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using Nito.AsyncEx;
class Program
{
static async Task Main()
{
var queue = new AsyncProducerConsumerQueue<int>(); // 创建队列
// 生产者
var producer = Task.Run(async () =>
{
await queue.EnqueueAsync(7);
await queue.EnqueueAsync(13);
queue.CompleteAdding(); // 标记完成
});
// 消费者
var consumer = Task.Run(async () =>
{
while (await queue.OutputAvailableAsync())
{
var item = await queue.DequeueAsync();
Trace.WriteLine($"消费:{item}");
}
});
await Task.WhenAll(producer, consumer);
}
}
EnqueueAsync
:异步地将数据添加到队列。DequeueAsync
:异步地从队列中获取数据。OutputAvailableAsync
:异步地检查队列中是否有可用数据。
与 BufferBlock<T>
类似,如果有多个消费者,代码结构可以如下:
while (true)
{
try
{
int item = await _asyncQueue.DequeueAsync();
Trace.WriteLine(item); // 输出队列中的数据
}
catch (InvalidOperationException)
{
break; // 队列已完成,退出循环
}
}
对比
特性 | Channels | BufferBlock |
AsyncProducerConsumerQueue |
---|---|---|---|
性能 | 高 | 中 | 中 |
灵活性 | 高 | 中 | 低 |
支持异步流 | 是 | 否 | 否 |
适用场景 | 高吞吐量场景 | 数据流管道处理 | 简单生产者-消费者模式 |
库来源 | System.Threading.Channels | System.Threading.Tasks.Dataflow | Nito.AsyncEx |
选择
-
Channels 库:这是官方推荐的方案,适合高性能、高容量的异步生产者-消费者模式。支持异步流(
IAsyncEnumerable
),其代码更为简洁自然,特别是对于较新版本的 .NET 平台。 -
BufferBlock<T>
(TPL 数据流):提供类似的功能,适合已有TPL 数据流
经验的开发者。它的 API 稍微复杂一些,特别是在处理多个消费者时。 -
AsyncProducerConsumerQueue<T>
(AsyncEx 库):与BufferBlock<T>
类似,API 设计更加简洁,但功能上基本一致。适合需要简化异步队列实现的场景。
9.12 节流队列
节流队列通过限制队列的最大容量,防止生产者速度超出消费者处理能力,从而避免内存过度使用。它通过对生产者施加“背压”机制,确保系统运行在可控的资源范围内。
使用场景
- 生产者快于消费者:例如,数据生成器快于处理器,可能导致内存占用过高。
- 跨环境兼容:在硬件性能未知的环境或云实例中运行时,需确保生产与消费的平衡。
- 避免资源争抢:在高并发系统中限制资源使用,避免系统因超载而崩溃。
实现方案
1. Channels (限界通道)
通过 Channel.CreateBounded<T>
创建限制容量的通道,异步对生产者进行节流。
代码示例:
using System;
using System.Diagnostics;
using System.Threading.Channels;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
var queue = Channel.CreateBounded<int>(1); // 限制队列容量为 1
// 生产者
var producer = Task.Run(async () =>
{
var writer = queue.Writer;
await writer.WriteAsync(7); // 成功写入
await writer.WriteAsync(13); // 等待 7 被消费后写入
writer.Complete();
});
// 消费者
var consumer = Task.Run(async () =>
{
var reader = queue.Reader;
await foreach (var item in reader.ReadAllAsync())
{
Trace.WriteLine($"消费:{item}");
await Task.Delay(500); // 模拟处理时间
}
});
await Task.WhenAll(producer, consumer);
}
}
2. BufferBlock(TPL 数据流)
通过设置 BoundedCapacity
,限制队列容量。
代码示例:
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using System.Threading.Tasks.Dataflow;
class Program
{
static async Task Main()
{
var queue = new BufferBlock<int>(new DataflowBlockOptions { BoundedCapacity = 1 });
// 生产者
var producer = Task.Run(async () =>
{
await queue.SendAsync(7); // 成功发送
await queue.SendAsync(13); // 等待 7 被消费后发送
queue.Complete();
});
// 消费者
var consumer = Task.Run(async () =>
{
while (await queue.OutputAvailableAsync())
{
var item = await queue.ReceiveAsync();
Trace.WriteLine($"消费:{item}");
await Task.Delay(500); // 模拟处理时间
}
});
await Task.WhenAll(producer, consumer);
}
}
3. AsyncProducerConsumerQueue
Nito.AsyncEx
提供的 AsyncProducerConsumerQueue<T>
也支持节流,通过 maxCount
参数限制队列大小。
代码示例:
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using Nito.AsyncEx;
class Program
{
static async Task Main()
{
var queue = new AsyncProducerConsumerQueue<int>(maxCount: 1); // 限制队列容量为 1
// 生产者
var producer = Task.Run(async () =>
{
await queue.EnqueueAsync(7); // 成功入队
await queue.EnqueueAsync(13); // 等待 7 被消费后入队
queue.CompleteAdding();
});
// 消费者
var consumer = Task.Run(async () =>
{
while (await queue.OutputAvailableAsync())
{
var item = await queue.DequeueAsync();
Trace.WriteLine($"消费:{item}");
await Task.Delay(500); // 模拟处理时间
}
});
await Task.WhenAll(producer, consumer);
}
}
4. BlockingCollection
BlockingCollection<T>
是线程安全的集合,通过 boundedCapacity
参数实现节流。
代码示例:
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
var queue = new BlockingCollection<int>(boundedCapacity: 1); // 限制容量为 1
// 生产者
var producer = Task.Run(() =>
{
queue.Add(7); // 成功添加
queue.Add(13); // 等待 7 被消费后添加
queue.CompleteAdding();
});
// 消费者
var consumer = Task.Run(() =>
{
foreach (var item in queue.GetConsumingEnumerable())
{
Trace.WriteLine($"消费:{item}");
Task.Delay(500).Wait(); // 模拟处理时间
}
});
await Task.WhenAll(producer, consumer);
}
}
最佳实践
-
适配异步 API:
- 优先使用
Channel
或BufferBlock<T>
,便于与异步操作集成。 - 对于传统同步场景,
BlockingCollection<T>
是一个简单且可靠的选择。
- 优先使用
-
限制容量:
- 根据系统负载合理设置队列容量,避免不必要的内存消耗。
- 生产者和消费者速度不可控时,节流是必需的。
-
考虑环境变化:
- 在云实例或资源有限的设备上,节流机制可以避免程序崩溃或性能下降。
-
采样与节流结合:
- 如果并不需要处理所有项,可以结合采样策略(详见下一节)进一步优化。
9.13 采样队列
采样队列通过限制队列容量并对超出范围的项进行丢弃,从而过滤不必要的队列项。这种方式在生产者速度快于消费者且不需要保留所有数据的场景中尤为有效。
使用场景
- 高频输入:例如,从实时传感器或日志数据中采样关键数据点。
- 消费者性能受限:消费者无法处理生产的全部数据,只需处理最新或关键数据。
- 降低资源使用:避免因为处理所有项而导致内存过度消耗。
实现方案
1. 使用 Channels 采样队列
通过 BoundedChannelFullMode
设置通道的满载行为,常见选项包括:
DropOldest
:当队列满员时丢弃最老的项,保留最新项。DropWrite
:当队列满员时丢弃新写入的项,保留最老项。
代码示例:
using System;
using System.Diagnostics;
using System.Threading.Channels;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
// 丢弃最老的项(默认保留最新的项)
var queue = Channel.CreateBounded<int>(new BoundedChannelOptions(1)
{
FullMode = BoundedChannelFullMode.DropOldest
});
var writer = queue.Writer;
// 生产者
var producer = Task.Run(async () =>
{
await writer.WriteAsync(7); // 添加 7
await writer.WriteAsync(13); // 队列满员,7 被丢弃
await writer.WriteAsync(42); // 队列满员,13 被丢弃
writer.Complete();
});
// 消费者
var consumer = Task.Run(async () =>
{
var reader = queue.Reader;
await foreach (var item in reader.ReadAllAsync())
{
Trace.WriteLine($"消费:{item}"); // 只消费最新的项:42
}
});
await Task.WhenAll(producer, consumer);
}
}
2. 基于时间的采样(System.Reactive)
使用 System.Reactive 提供的时间运算符来限制数据流速率,例如“每秒最多处理10项”。
代码示例:
using System;
using System.Diagnostics;
using System.Reactive.Linq;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
var observable = Observable.Interval(TimeSpan.FromMilliseconds(100)) // 模拟高频生产
.Select(x => (int)x) // 转换为整型
.Sample(TimeSpan.FromSeconds(1)); // 每秒只保留最新项
var subscription = observable.Subscribe(item =>
{
Trace.WriteLine($"消费:{item}");
});
await Task.Delay(5000); // 运行 5 秒
subscription.Dispose();
}
}
3. 自定义采样逻辑
更复杂的采样需求(如基于权重或特定条件)可在消费者代码中实现。例如,消费者只处理偶数项:
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
var queue = new BlockingCollection<int>(boundedCapacity: 10); // 无需特殊采样支持
// 生产者
var producer = Task.Run(() =>
{
for (int i = 0; i < 100; i++) queue.Add(i);
queue.CompleteAdding();
});
// 消费者
var consumer = Task.Run(() =>
{
foreach (var item in queue.GetConsumingEnumerable())
{
if (item % 2 == 0) // 自定义采样:仅保留偶数项
{
Trace.WriteLine($"消费:{item}");
}
}
});
await Task.WhenAll(producer, consumer);
}
}
最佳实践
-
优先使用
DropOldest
模式:- 在高频场景下,丢弃最老的项通常是最符合需求的选择,保留最新数据以供消费。
-
基于时间采样优选
System.Reactive
:- 适用于需要对数据流按时间窗口限速的场景,如日志采样或实时监控数据处理。
-
避免数据丢失:
- 如果所有数据均不可丢弃,采样可能不合适,应改用节流队列(参见前节)。
-
根据场景优化性能:
- 对于低延迟要求,可结合采样策略与轻量级的数据过滤逻辑。
-
测试不同采样策略:
- 根据生产和消费模式,尝试不同的
BoundedChannelFullMode
或时间窗口大小,优化数据流的吞吐与延迟。
- 根据生产和消费模式,尝试不同的
9.14 异步栈和异步背包
问题
异步栈(LIFO,后进先出)和异步背包(无序集合)是 Nito.AsyncEx 库中提供的功能,允许开发者在并发环境下使用异步集合,而不局限于队列的先进先出(FIFO)行为。
解决方案
Nito.AsyncEx
库提供了 AsyncCollection<T>
类型,它支持异步的生产者-消费者模式。AsyncCollection<T>
默认行为类似异步队列,但可以通过传入不同的集合类型来实现异步栈或异步背包,与BlockingCollection<T>
是一个道理,可以说AsyncCollection<T>
是 BlockingCollection<T>
的异步版本。
- 异步栈:使用
ConcurrentStack<T>
实现后进先出(LIFO)。 - 异步背包:使用
ConcurrentBag<T>
实现无序行为。
示例代码
使用 AsyncCollection<T>
实现异步栈和异步背包
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Threading.Tasks;
using Nito.AsyncEx;
class Program
{
static async Task Main()
{
// 创建异步栈(LIFO行为)
var asyncStack = new AsyncCollection<int>(new ConcurrentStack<int>());
// 创建异步背包(无序行为)
var asyncBag = new AsyncCollection<int>(new ConcurrentBag<int>());
// 示例:异步栈生产与消费
await AsyncStackExample(asyncStack);
// 示例:异步背包生产与消费
await AsyncBagExample(asyncBag);
}
private static async Task AsyncStackExample(AsyncCollection<int> asyncStack)
{
// 生产者
var producer = Task.Run(async () =>
{
await asyncStack.AddAsync(7);
await asyncStack.AddAsync(13);
asyncStack.CompleteAdding();
});
// 消费者
var consumer = Task.Run(async () =>
{
while (await asyncStack.OutputAvailableAsync())
{
int item = await asyncStack.TakeAsync();
Trace.WriteLine($"消费(栈):{item}");
}
});
await Task.WhenAll(producer, consumer);
}
private static async Task AsyncBagExample(AsyncCollection<int> asyncBag)
{
// 生产者
var producer = Task.Run(async () =>
{
await asyncBag.AddAsync(7);
await asyncBag.AddAsync(13);
asyncBag.CompleteAdding();
});
// 消费者
var consumer = Task.Run(async () =>
{
while (await asyncBag.OutputAvailableAsync())
{
int item = await asyncBag.TakeAsync();
Trace.WriteLine($"消费(背包):{item}");
}
});
await Task.WhenAll(producer, consumer);
}
}
注意事项
- 对于异步栈来说,如果生产者在消费者启动前完成,栈会按预期后进先出的顺序工作(先添加的后取出)。但如果生产者和消费者并发运行,消费者总是会优先获取最近添加的项,因此行为可能与普通栈略有不同。
- 异步背包没有顺序,消费者获取数据的顺序是无序的。
支持节流
AsyncCollection<T>
支持节流,可以通过设置 maxCount
参数来限制集合的最大容量。当集合满时,生产者会异步等待,直到有空间。
节流示例
var _asyncStack = new AsyncCollection<int>(new ConcurrentStack<int>(), maxCount: 1);
// 生产者代码
await _asyncStack.AddAsync(7); // 立即完成
await _asyncStack.AddAsync(13); // 等待7被移除后才会添加13
_asyncStack.CompleteAdding();
多消费者处理
在多消费者场景下,推荐使用如下模式来处理可能的异常:
while (true)
{
try
{
int item = await _asyncStack.TakeAsync();
Trace.WriteLine(item);
}
catch (InvalidOperationException)
{
break; // 集合已完成,退出循环
}
}
最佳实践
-
选择适合的集合类型:
- 使用
ConcurrentStack<int>
构造异步栈。 - 使用
ConcurrentBag<int>
构造异步背包。
- 使用
-
考虑并发影响:
- 在高并发场景中,异步栈的行为可能偏离严格的 LIFO,需评估是否符合业务需求。
-
优先考虑节流:
- 即使消费者速度通常较快,建议添加合理的容量限制,确保应用程序能够在不同硬件或高负载场景下运行。
-
多消费者场景:
- 若多个消费者需要读取异步栈/背包,需注意分配逻辑,以防止数据遗漏或重复处理。
9.15 同步与异步混合队列
问题
在某些情况下,你可能需要一个队列,既能同步地处理生产者端或消费者端,也能异步地处理另一端。比如,后台线程需要同步阻塞地推送数据,而 UI 线程则需要异步地从队列中拉取数据,以保持响应性。
解决方案
可以使用支持同步和异步 API 的队列类型,如 BufferBlock<T>
、ActionBlock<T>
,或 AsyncProducerConsumerQueue<T>
。
BufferBlock<T>
BufferBlock<T>
是 TPL 数据流
(System.Threading.Tasks.Dataflow
)的一部分,既支持同步方法,也支持异步方法。
示例
异步生产者与异步消费者:
var queue = new BufferBlock<int>();
// 异步生产者代码
await queue.SendAsync(7);
await queue.SendAsync(13);
queue.Complete();
// 异步消费者代码
while (await queue.OutputAvailableAsync())
Trace.WriteLine(await queue.ReceiveAsync());
同步生产者与同步消费者:
var queue = new BufferBlock<int>();
// 同步生产者代码
queue.Post(7);
queue.Post(13);
queue.Complete();
// 同步消费者代码
while (true)
{
try
{
int item = queue.Receive();
Trace.WriteLine(item);
}
catch (InvalidOperationException)
{
break; // 队列已完成
}
}
ActionBlock<T>
ActionBlock<T>
是 TPL 数据流
的另一种块结构,适合定义响应式消费者。它也支持同步和异步的生产者。
示例
消费者代码:
var queue = new ActionBlock<int>(item => Trace.WriteLine(item));
// 异步生产者代码
await queue.SendAsync(7);
await queue.SendAsync(13);
// 同步生产者代码
queue.Post(7);
queue.Post(13);
queue.Complete();
AsyncProducerConsumerQueue<T>
Nito.AsyncEx
提供了 AsyncProducerConsumerQueue<T>
,一个支持同步和异步 API 的生产者-消费者队列。
示例
异步生产者与异步消费者:
var queue = new AsyncProducerConsumerQueue<int>();
// 异步生产者代码
await queue.EnqueueAsync(7);
await queue.EnqueueAsync(13);
queue.CompleteAdding();
// 异步消费者代码
while (await queue.OutputAvailableAsync())
Trace.WriteLine(await queue.DequeueAsync());
同步生产者与同步消费者:
var queue = new AsyncProducerConsumerQueue<int>();
// 同步生产者代码
queue.Enqueue(7);
queue.Enqueue(13);
queue.CompleteAdding();
// 同步消费者代码
foreach (int item in queue.GetConsumingEnumerable())
Trace.WriteLine(item);
Channel<T>
虽然 Channel<T>
的 API 是异步的,但你可以通过 Task.Run
包装同步代码,来强制同步生产和消费。
示例
var queue = Channel.CreateBounded<int>(10);
// 同步生产者代码(通过 Task.Run)
Task.Run(async () => {
await queue.Writer.WriteAsync(7);
await queue.Writer.WriteAsync(13);
queue.Writer.Complete();
}).GetAwaiter().GetResult();
// 同步消费者代码(通过 Task.Run)
Task.Run(async () => {
while (await queue.Reader.WaitToReadAsync())
{
while (queue.Reader.TryRead(out int value))
Trace.WriteLine(value);
}
}).GetAwaiter().GetResult();
讨论
- 推荐使用
BufferBlock<T>
或ActionBlock<T>
,它们是TPL 数据流
的一部分,经过广泛测试和优化。 - 如果你的项目已经使用了
Nito.AsyncEx
库,AsyncProducerConsumerQueue<T>
也是一个不错的选择。 Channel<T>
本质上是异步的,但可以通过Task.Run
包装同步代码来工作。