《Concurrency in C# Cookbook》--- 读书随记(4)

CHAPTER 7 Testing

《Concurrency in C# Cookbook》
Asynchronous, Parallel, and Multithreaded Programming

Author: Stephen Cleary

如果需要电子书的小伙伴,可以留下邮箱,看到了会发送的

7.1 Unit Testing async Methods

Problem

您有一个需要进行单元测试的 async 方法

Solution

使用 MSTest 框架

[TestMethod]
public async Task MyMethodAsync_ReturnsFalse()
{
    var objectUnderTest = ...;
    bool result = await objectUnderTest.MyMethodAsync();
    Assert.IsFalse(result);
}

7.2 Unit Testing async Methods Expected to Fail

Problem

你需要编写一个单元测试来检查 async 任务方法的特定故障

Solution

如果您正在进行桌面或服务器开发,MSTest 确实通过常规的 ExpectedExceptionAttribute 支持故障测试

// Not a recommended solution; see below.
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public async Task Divide_WhenDenominatorIsZero_ThrowsDivideByZero()
{
    await MyClass.DivideAsync(4, 0);
}

然而,这个解决方案并不是最好的: ExpectedException 实际上是一个糟糕的设计。它期望的异常可能由单元测试方法调用的任何方法引发。更好的设计会检查特定的代码片段是否抛出该异常,而不是整个单元测试

7.3 Unit Testing async void Methods

Problem

你有一个 async 的 void 方法需要进行单元测试

Solution

与其解决这个问题,你应该尽你的死亡级别最好避免它。如果可以把你的 async void 方法改成 async 的 Task 方法,那么就这么做吧

如果你的方法必须是 async void 的(例如,为了满足接口方法签名) ,那么考虑写两个方法: 一个是包含所有逻辑的 async Task 方法,另一个是调用 async Task 方法并等待结果的 void 包装 async。Async void 方法满足架构需求,而 async Task 方法(包含所有逻辑)是可测试的

如果不可能改变你的方法,你必须单元测试一个 async 的 void 方法,那么有一种方法可以做到。您可以使用来自 Nito.AsyncEx 的 AsyncContext 类

// Not a recommended solution; see the rest of this section.
[TestMethod]
public void MyMethodAsync_DoesNotThrow()
{
    AsyncContext.Run(() =>
    {
        var objectUnderTest = new Sut(); // ...;
        objectUnderTest.MyVoidMethodAsync();
    });
}

7.4 Unit Testing Dataflow Meshes

暂时跳过

7.5 Unit Testing System.Reactive Observables

暂时跳过

7.6 Unit Testing System.Reactive Observables with Faked Scheduling

暂时跳过

CHAPTER 8 Interop

暂时跳过

CHAPTER 9 Collections

9.1 Immutable Stacks and Queues

Problem

您需要一个不经常更改并且可以由多个线程安全访问的堆栈或队列

Solution

不可变堆栈和队列是最简单的不可变集合。它们的行为非常类似于标准的 Stack < T > 和 Queue < T > 。在性能方面,不可变的堆栈和队列具有与标准堆栈和队列相同的时间复杂性; 但是,在集合频繁更新的简单场景中,标准堆栈和队列更快

ImmutableStack<int> stack = ImmutableStack<int>.Empty;
stack = stack.Push(13);
stack = stack.Push(7);
// Displays "7" followed by "13".
foreach (int item in stack)
    Trace.WriteLine(item);
int lastItem;
stack = stack.Pop(out lastItem);
// lastItem == 7

在示例中请注意,我们一直在覆盖本地变量堆栈。不可变集合遵循的模式是返回更新后的集合; 原始集合引用没有变化。这意味着一旦您拥有了对特定不可变集合实例的引用,它将永远不会改变。考虑下面的例子:

ImmutableStack<int> stack = ImmutableStack<int>.Empty;
stack = stack.Push(13);
ImmutableStack<int> biggerStack = stack.Push(7);

// Displays "7" followed by "13".
foreach (int item in biggerStack)
    Trace.WriteLine(item);
// Only displays "13".
foreach (int item in stack)
    Trace.WriteLine(item);

两个堆栈共享用于包含项目13的内存。这种类型的实现非常有效,同时允许您轻松快照当前状态。每个不可变集合实例自然是线程安全的,但是也可以在单线程应用程序中使用不可变集合。根据我的经验,当代码更加functional或者需要存储大量快照并希望它们尽可能共享内存时,不可变集合特别有用

9.2 Immutable Lists

Problem

您需要一个可以索引的数据结构,该结构不会经常更改,并且可以被多个线程安全地访问

Solution

列表是一种通用的数据结构,可用于各种应用程序状态。不可变列表确实允许索引,但是您需要了解性能特征。它们不仅仅是 List < T > 的替代品

ImmutableList < T > 支持类似于 List < T > 的方法,如下面的示例所示

ImmutableList<int> list = ImmutableList<int>.Empty;
list = list.Insert(0, 13);
list = list.Insert(0, 7);

// Displays "7" followed by "13".
foreach (int item in list)
    Trace.WriteLine(item);
list = list.RemoveAt(1);

不可变列表在内部组织为二叉树,以便不可变列表实例可以最大化与其他实例共享的内存量。因此,对于一些常见操作,ImmutableList < T > 和 List < T > 之间存在性能差异

值得注意的是,ImmutableList < T > 的索引操作是 O (log N) ,而不是 O (1) ,正如您可能期望的那样。如果在现有代码中用 ImmutableList < T > 替换 List < T > ,则需要考虑算法如何访问集合中的项

这意味着您应该尽可能使用 foreach 而不是 for。ImmutableList < T > 上的 foreach 循环在 O (N)时间内执行,而同一集合上的 for 循环在 O (N * log N)时间内执行

9.3 Immutable Sets

Problem

您需要的数据结构不需要存储副本,不需要经常更改,并且可以由多个线程安全地访问

Solution

有两种不可变集合类型: ImmutableHashSet < T > 是惟一项的集合,ImmutableSortedSet < T > 是唯一项的排序集合。两种类型都有类似的接口

ImmutableHashSet<int> hashSet = ImmutableHashSet<int>.Empty;
hashSet = hashSet.Add(13);
hashSet = hashSet.Add(7);
// Displays "7" and "13" in an unpredictable order.
foreach (int item in hashSet)
    Trace.WriteLine(item);
hashSet = hashSet.Remove(7);

只有排序的集合允许像列表一样对其进行索引

ImmutableSortedSet<int> sortedSet = ImmutableSortedSet<int>.Empty;
sortedSet = sortedSet.Add(13);
sortedSet = sortedSet.Add(7);
// Displays "7" followed by "13".
foreach (int item in sortedSet)
    Trace.WriteLine(item);
int smallestItem = sortedSet[0];
// smallestItem == 7
sortedSet = sortedSet.Remove(7);

未排序集和已排序集具有相似的性能

但是,我建议您使用未排序的集合,除非您知道它需要排序。许多类型只支持基本相等,而不支持完全比较,因此无排序集可用于比排序集多得多的类型

9.4 Immutable Dictionaries

Problem

您需要一个不经常更改且可由多个线程安全访问的键/值集合

Solution

有两种不可变的字典类型: ImmutableDictionary < TKey,TValue > 和 ImmutableSortedDictionary < TKey,TValue >

这两种集合类型的成员非常相似:

ImmutableDictionary<int, string> dictionary =
    ImmutableDictionary<int, string>.Empty;
dictionary = dictionary.Add(10, "Ten");
dictionary = dictionary.Add(21, "Twenty-One");
dictionary = dictionary.SetItem(10, "Diez");
// Displays "10Diez" and "21Twenty-One" in an unpredictable order.
foreach (KeyValuePair<int, string> item in dictionary)
    Trace.WriteLine(item.Key + item.Value);
string ten = dictionary[10];
// ten == "Diez"
dictionary = dictionary.Remove(21);

未排序的字典和已排序的字典具有相似的性能,但是我建议您使用未排序的字典,除非您需要对元素进行排序。未排序的字典总体上可以快一点。

9.5 Threadsafe Dictionaries

Problem

您有一个需要保持同步的键/值集合(例如,内存中的缓存) ,即使有多个线程正在读取和写入它

Solution

.NET 框架中的 ConcurrentDictionary < TKey,TValue > 类型是数据结构的真正瑰宝。它是线程安全的,使用了细粒度锁和无锁技术的混合,以确保在绝大多数场景中的快速访问

它的 API 确实需要一些时间来适应。它与标准 Dictionary < TKey,TValue > 类型非常不同,因为它必须处理来自多个线程的并发访问。但是一旦您学习了这个的基本知识,您就会发现 Concurrent Dictionary < TKey,TValue > 是最有用的集合类型之一

首先是新增操作:

var dictionary = new ConcurrentDictionary<int, string>();
string newValue = dictionary.AddOrUpdate(0, key => "Zero", (key, oldValue) => "Zero");

AddOrUpdate 有点复杂,因为它必须根据并发字典的当前内容执行多项操作。第一个方法参数是key。第二个参数是一个委托,它将键(在本例中为0)转换为要添加到字典中的值(在本例中为“ Zero”)。只有在字典中不存在该键时才调用此委托。第三个参数是另一个委托,它将键(0)和旧值转换为要存储在字典中的更新值(“ Zero”)。只有在字典中确实存在该键时才调用此委托。AddOrUpdate 返回该键的新值(与某个委托返回的值相同)

现在说说真正让您头脑发热的部分: 为了让并发字典正常工作,AddOrUpdate 可能需要多次调用其中一个(或两个)委托。这种情况很少见,但是有可能发生。因此,您的委托应该简单、快速,而且不会造成副作用。这意味着您的委托应该只创建值; 它不应该更改应用程序中的任何其他变量。对于传递给 ConcurrentDictionary < TKey,TValue > 上的方法的所有委托,都遵循同样的原则

还有其他几种方法可以向字典中添加值。一种快捷方式是只使用索引语法:

// Using the same "dictionary" as above.
// Adds (or updates) key 0 to have the value "Zero".
dictionary[0] = "Zero";

索引语法没有那么强大; 它不允许您基于现有值更新值。但是,如果您已经在字典中存储了要存储的值,那么语法会更简单,而且工作得很好

然后是从字典中读取值:

// Using the same "dictionary" as above.
bool keyExists = dictionary.TryGetValue(0, out string currentValue);

如果在字典中找到键,TryGetValue 将返回 true 并设置 out 值。如果找不到键,TryGetValue 将返回 false。您还可以使用索引语法来读取值,但是我发现这种方法没有多大用处,因为如果找不到键,它会抛出异常。请记住,一个并发字典有多个线程读取、更新、添加和删除值; 在许多情况下,在尝试读取某个键之前,很难知道它是否存在

删除值:

// Using the same "dictionary" as above.
bool keyExisted = dictionary.TryRemove(0, out string removedValue);

Discussion

尽管 ConcurrentDictionary < TKey,TValue > 是线程安全的,但这并不意味着它的操作是原子的。如果多个线程并发地调用 AddOrUpdate,那么两个线程都可以检测到键不存在,并且都可以并发地执行创建新值的委托

我认为 ConcurrentDictionary < TKey,TValue > 非常棒,主要是因为它有非常强大的 AddOrUpdate 方法。然而,这并不适用于所有情况。当有多个线程读写共享集合时,ConcurrentDictionary < TKey,TValue > 是最佳选择。如果不是经常更新(如果更少) ,则 ImmutableDictionary < TKey,TValue > 可能是更好的选择

ConcurrentDictionary < TKey,TValue > 最适合于多个线程共享相同集合的共享数据情况。如果一些线程只添加元素,而其他线程只删除元素,那么最好使用生产者/消费者集合

ConcurrentDictionary < TKey,TValue > 不是唯一的线程安全集合,BCL 还提供了 ConcurrentStack < T > 、 ConcurrentQueue < T > 和 ConcurrentBag < T >

9.6 Blocking Queues

Problem

您需要一个管道来将消息或数据从一个线程传递到另一个线程。例如,一个线程可以加载数据,它在加载时向下推动管道; 同时,在管道的接收端有其他线程接收数据并处理它

Solution

.NET 类型 BlockingCollection < T > 就是为这种管道而设计的。默认情况下,BlockingCollection < T > 是一个阻塞队列,提供先进先出行为

阻塞队列需要由多个线程共享,它通常被定义为私有的只读字段

private readonly BlockingCollection<int> _blockingQueue = new BlockingCollection<int>();

生产者线程可以通过调用 Add 来添加项,当生产者线程完成时(当所有项都已添加时) ,它可以通过调用 CompleteAdd 来完成集合。这将通知集合不再向其添加任何项,然后集合可以通知其使用者不再添加任何项

_blockingQueue.Add(7);
_blockingQueue.Add(13);
_blockingQueue.CompleteAdding();
// Displays "7" followed by "13".
foreach (int item in _blockingQueue.GetConsumingEnumerable())
    Trace.WriteLine(item);

如果希望具有多个使用者,则可以同时从多个线程调用 GetConsumer ingEnumable。但是,每个项只传递给其中一个线程。当收集工作完成后,enumerable 就完成了

当您使用这样的管道时,您确实需要考虑如果您的生产商比您的消费者运行得更快会发生什么。如果生成项的速度超过了消耗它们的速度,那么可能需要调整队列

9.8 Asynchronous Queues

Problem

您需要一个管道来以先进先出的方式将消息或数据从代码的一部分传递到另一部分,而不会阻塞线程

Solution

您需要的是一个具有异步 API 的队列。在核心 NET 框架中没有这样的类型,但是 NuGet 提供了一些选项

Channels 是异步生产者/消费者集合的现代库,它非常重视高容量场景的高性能。生产者通常使用 WriteAsync 将项目写入通道,当他们全部完成生产时,其中一个调用 Complete 通知通道将来不会再有任何项目,如下所示

Channel<int> queue = Channel.CreateUnbounded<int>();

// Producer code
ChannelWriter<int> writer = queue.Writer;
await writer.WriteAsync(7);
await writer.WriteAsync(13);
writer.Complete();

// Consumer code
// Displays "7" followed by "13".
ChannelReader<int> reader = queue.Reader;
await foreach (int value in reader.ReadAllAsync())
    Trace.WriteLine(value);

Discussion

我建议尽可能使用 Channels 处理异步生产者/使用者队列。除了节流,它们还有多种采样选项,并且它们是高度优化的

posted @   huang1993  阅读(63)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· DeepSeek “源神”启动!「GitHub 热点速览」
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· DeepSeek R1 简明指南:架构、训练、本地部署及硬件要求
· NetPad:一个.NET开源、跨平台的C#编辑器
点击右上角即可分享
微信分享提示