C--并发编程秘籍第二版-二-

104 阅读1小时+

C# 并发编程秘籍第二版(二)

原文:zh.annas-archive.org/md5/94f6d64de2f76d3e98d9e7e8e4ee1394

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:数据流基础

TPL Dataflow 是一个强大的库,它允许你创建一个网格或管道,然后(异步地)通过它发送数据。Dataflow 是一种非常声明式的编程风格:通常情况下,你先完全定义网格,然后开始处理数据。网格最终成为一个结构,通过它你的数据流动。这需要你用一种不同的方式来思考你的应用,但一旦你跨越了这一步,数据流就变成了许多场景的自然选择。

每个网格由相互链接的各种块组成。单个块很简单,负责数据处理的一个步骤。当块完成对其数据的处理时,它会将结果传递给任何链接的块。

要使用 TPL Dataflow,在你的应用程序中安装 NuGet 包 System.Threading.Tasks.Dataflow

5.1 链接块

问题

你需要将数据流块链接到彼此以创建一个网格。

解决方案

TPL Dataflow 库提供的块仅定义了最基本的成员。许多有用的 TPL Dataflow 方法实际上是扩展方法。LinkTo 扩展方法提供了一种将数据流块连接在一起的简单方法:

var multiplyBlock = new TransformBlock<int, int>(item => item * 2);
var subtractBlock = new TransformBlock<int, int>(item => item - 2);

// After linking, values that exit multiplyBlock will enter subtractBlock.
multiplyBlock.LinkTo(subtractBlock);

默认情况下,链接的数据流块仅传播数据;它们不传播完成状态(或错误)。如果你的数据流是线性的(像一个管道),那么你可能希望传播完成状态。要传播完成状态(和错误),你可以在链接上设置 PropagateCompletion 选项:

var multiplyBlock = new TransformBlock<int, int>(item => item * 2);
var subtractBlock = new TransformBlock<int, int>(item => item - 2);

var options = new DataflowLinkOptions { PropagateCompletion = true };
multiplyBlock.LinkTo(subtractBlock, options);

...

// The first block's completion is automatically propagated to the second block.
multiplyBlock.Complete();
await subtractBlock.Completion;

讨论

一旦链接,数据将自动从源块流向目标块。PropagateCompletion 选项可以在传递数据的同时传递完成状态;然而,在管道的每一步中,故障块会将其异常传播给下一个块,并封装在 AggregateException 中。因此,如果你有一个传播完成的长管道,原始错误可能会嵌套在多个 AggregateException 实例中。AggregateException 有几个成员,例如 Flatten,可以帮助处理这种情况中的错误。

可以用多种方式链接数据流块;你的网格可以有分支和汇合甚至循环。然而,对于大多数场景,简单的线性管道就足够了。我们主要处理管道(并简要涵盖分支);更高级的场景超出了本书的范围。

DataflowLinkOptions类型为您提供了几种不同的选项,您可以在链接上设置这些选项(例如在此解决方案中使用的PropagateCompletion选项),并且LinkTo重载还可以接受一个谓词,您可以使用它来过滤通过链接的数据。如果数据未通过过滤器,则不会被丢弃。通过过滤器的数据通过该链接传输;未通过过滤器的数据尝试通过备用链接传输,并且如果没有其他链接可供其使用,则留在块中。如果数据项在块中被卡住,那么该块将不会生成任何其他数据项;直到移除该数据项为止,整个块都将处于停滞状态。

另请参阅

菜谱 5.2 涵盖了沿着链路传播错误的方法。

菜谱 5.3 介绍了如何移除块之间的链接。

菜谱 8.8 介绍了如何将数据流块链接到 System.Reactive 的可观测流。

5.2 传播错误

问题

您需要一种方法来响应数据流网格中可能发生的错误。

解决方案

如果传递给数据流块的委托引发异常,那么该块将进入故障状态。当块处于故障状态时,它将丢弃所有数据(并停止接受新数据)。下面的代码中的块永远不会生成任何输出数据;第一个值引发异常,第二个值则被丢弃:

var block = new TransformBlock<int, int>(item =>
{
  if (item == 1)
    throw new InvalidOperationException("Blech.");
  return item * 2;
});
block.Post(1);
block.Post(2);

要捕获数据流块的异常,您应该awaitCompletion属性。Completion属性返回一个Task,当块完成时完成,如果块故障,则Completion任务也将故障:

try
{
  var block = new TransformBlock<int, int>(item =>
  {
    if (item == 1)
      throw new InvalidOperationException("Blech.");
    return item * 2;
  });
  block.Post(1);
  await block.Completion;
}
catch (InvalidOperationException)
{
  // The exception is caught here.
}

当使用PropagateCompletion链接选项传播完成时,错误也会被传播。但是,异常会被包装在AggregateException中传递到下一个块。以下示例从管道末端捕获异常,因此如果从先前的块传播异常,则会捕获AggregateException

try
{
  var multiplyBlock = new TransformBlock<int, int>(item =>
  {
    if (item == 1)
      throw new InvalidOperationException("Blech.");
    return item * 2;
  });
  var subtractBlock = new TransformBlock<int, int>(item => item - 2);
  multiplyBlock.LinkTo(subtractBlock,
      new DataflowLinkOptions { PropagateCompletion = true });
  multiplyBlock.Post(1);
  await subtractBlock.Completion;
}
catch (AggregateException)
{
  // The exception is caught here.
}

每个块都将传入的错误包装在AggregateException中,即使传入的错误已经是AggregateException。如果在管道的早期发生错误并在几个链接下行之前被观察到,则原始错误将被包装在多层AggregateException中。AggregateException.Flatten方法简化了这种情况下的错误处理。

讨论

在构建网格(或管道)时,请考虑如何处理错误。在较简单的情况下,最好只传播错误并在最后一次捕获它们。在更复杂的网格中,您可能需要在数据流完成时观察每个块。

或者,如果你希望你的块在面对异常时仍然可用,你可以选择将异常视为另一种数据,让它们与正确处理的数据一起流过网格。使用这种模式,你可以保持数据流网格的操作性,因为块本身不会故障,并且会继续处理下一个数据项。参见 配方 14.6 了解更多详情。

参见

配方 5.1 讲述了如何建立块之间的链接。

配方 5.3 讲述了如何断开块之间的链接。

配方 14.6 讲述了如何在数据流网格中同时传递异常和数据。

5.3 取消链接块

问题

在处理过程中,您需要动态更改数据流的结构。这是一个几乎不常见的高级场景。

解决方案

你可以随时链接或取消链接数据流块;数据可以自由地通过网格流动,随时链接或取消链接都是安全的。链接和取消链接都是完全线程安全的。

当您创建数据流块链接时,请保留 LinkTo 方法返回的 IDisposable,并在希望取消链接块时将其处理掉:

var multiplyBlock = new TransformBlock<int, int>(item => item * 2);
var subtractBlock = new TransformBlock<int, int>(item => item - 2);

IDisposable link = multiplyBlock.LinkTo(subtractBlock);
multiplyBlock.Post(1);
multiplyBlock.Post(2);

// Unlink the blocks.
// The data posted above may or may not have already gone through the link.
// In real-world code, consider a using block rather than calling Dispose.
link.Dispose();

讨论

除非你能保证链接是空闲的,否则在取消链接时可能会出现竞态条件。然而,通常这些竞态条件并不是一个问题;数据要么在断开链接之前流过链接,要么根本不会。没有竞态条件会导致数据的重复或丢失。

取消链接是一个高级场景,但在少数情况下非常有用。举例来说,没有办法改变链接的过滤器。要改变现有链接的过滤器,你需要取消旧的链接并创建一个新的链接,并可以选择设置 DataflowLinkOptions.Append 为 false。另一个例子是,在战略性的点上取消链接可以用来暂停数据流网格。

参见

配方 5.1 讲述了如何建立块之间的链接。

5.4 限流块

问题

在您的数据流网格中存在分叉场景,并希望数据以负载平衡的方式流动。

解决方案

默认情况下,当块产生输出数据时,它会检查所有链接(按创建顺序),并尝试逐个将数据流过每个链接。此外,每个块默认会维护一个输入缓冲区,在准备好处理数据之前可以接受任意数量的数据。

在分支场景中,这会造成问题,其中一个源块链接到两个目标块:然后第二个块就会饿死。当源块生成数据时,它会尝试将数据流向每个链接。第一个目标块将始终接受数据并缓冲它,因此源块永远不会尝试将数据流向第二个目标块。可以通过使用BoundedCapacity块选项节流目标块来解决此问题。默认情况下,BoundedCapacity设置为DataflowBlockOptions.Unbounded,这会导致第一个目标块即使没有准备好处理数据也缓冲所有数据。

BoundedCapacity可以设置为大于零的任意值(或者当然是DataflowBlockOptions.Unbounded)。只要目标块能够跟得上来自源块的数据,简单的值为 1 就足够了:

var sourceBlock = new BufferBlock<int>();
var options = new DataflowBlockOptions { BoundedCapacity = 1 };
var targetBlockA = new BufferBlock<int>(options);
var targetBlockB = new BufferBlock<int>(options);

sourceBlock.LinkTo(targetBlockA);
sourceBlock.LinkTo(targetBlockB);

讨论

节流在分支场景中进行负载平衡非常有用,但可以在任何需要节流行为的地方使用。例如,如果您正在从 I/O 操作中填充数据流网格的数据,可以在网格中的块上应用BoundedCapacity。这样,直到网格准备好处理数据之前,您都不会读取太多 I/O 数据,并且在能够处理它之前,您的网格不会最终缓冲所有输入数据。

另请参阅

Recipe 5.1 介绍如何将块链接在一起。

5.5 使用数据流块进行并行处理

问题

您希望在数据流网格中进行一些并行处理。

解决方案

默认情况下,每个数据流块彼此独立。当您将两个块链接在一起时,它们将独立处理。因此,每个数据流网格内建有一些自然的并行性。

如果需要进一步的操作,例如,如果有一个执行大量 CPU 计算的特定块,则可以通过设置MaxDegreeOfParallelism选项,使该块并行处理其输入数据。默认情况下,此选项设置为 1,因此每个数据流块一次只处理一个数据片段。

BoundedCapacity可以设置为DataflowBlockOptions.Unbounded或大于零的任何值。以下示例允许任意数量的任务同时乘以数据:

var multiplyBlock = new TransformBlock<int, int>(
    item => item * 2,
    new ExecutionDataflowBlockOptions
    {
      MaxDegreeOfParallelism = DataflowBlockOptions.Unbounded
    });
var subtractBlock = new TransformBlock<int, int>(item => item - 2);
multiplyBlock.LinkTo(subtractBlock);

讨论

MaxDegreeOfParallelism选项使得块内的并行处理变得容易。不那么容易的是确定哪些块需要它。一种技术是在调试器中暂停数据流执行,在那里您可以看到排队的数据项数量(即尚未由块处理的数据项)。意外数量的数据项可能表明某些重组或并行化将有所帮助。

如果数据流块进行异步处理,MaxDegreeOfParallelism也适用。在这种情况下,MaxDegreeOfParallelism选项指定并发级别——一定数量的。每个数据项在开始处理时占据一个槽,并且只有在异步处理完全完成时才释放该槽。

参见

食谱 5.1 涵盖了将块链接在一起。

5.6 创建自定义块

问题

您有要放置到自定义数据流块中的可重用逻辑。这样做可以创建包含复杂逻辑的较大块。

解决方案

您可以使用Encapsulate方法剪切具有单个输入和输出块的任何数据流网格的部分。Encapsulate将从两个端点创建一个单一的块。在这些端点之间传播数据和完成是您的责任。以下代码创建了一个自定义数据流块,将数据和完成状态传播到两个块之间:

IPropagatorBlock<int, int> CreateMyCustomBlock()
{
  var multiplyBlock = new TransformBlock<int, int>(item => item * 2);
  var addBlock = new TransformBlock<int, int>(item => item + 2);
  var divideBlock = new TransformBlock<int, int>(item => item / 2);

  var flowCompletion = new DataflowLinkOptions { PropagateCompletion = true };
  multiplyBlock.LinkTo(addBlock, flowCompletion);
  addBlock.LinkTo(divideBlock, flowCompletion);

  return DataflowBlock.Encapsulate(multiplyBlock, divideBlock);
}

讨论

当您将网格封装为自定义块时,请考虑要向用户公开的选项类型。考虑每个块选项应该(或不应该)传递给内部网格;在许多情况下,某些块选项不适用或没有意义。因此,常见的做法是自定义块定义自己的自定义选项,而不是接受DataflowBlockOptions参数。

DataflowBlock.Encapsulate仅封装具有一个输入块和一个输出块的网格。如果您具有具有多个输入和/或输出的可重用网格,则应将其封装在自定义对象中,并将输入和输出作为类型为ITargetBlock<T>(用于输入)和IReceivableSourceBlock<T>(用于输出)的属性公开。

这些示例都使用Encapsulate创建自定义块。也可以自己实现数据流接口,但这要困难得多。Microsoft 有一篇论文描述了创建自定义数据流块的高级技术。

参见

食谱 5.1 涵盖了将块链接在一起。

食谱 5.2 涵盖了沿着块链接传播错误。

第六章:System.Reactive 基础

LINQ 是一组语言功能,使开发人员能够查询序列。最常见的两个 LINQ 提供程序是内置的 LINQ to Objects(基于 IEnumerable<T>)和 LINQ to Entities(基于 IQueryable<T>)。还有许多其他提供程序可用,大多数提供程序具有相同的一般结构。查询是惰性评估的,序列根据需要生成值。在概念上,这是一种拉模型;在评估过程中,逐个从查询中获取值项。

System.Reactive (Rx) 将事件视为随时间到达的数据序列。因此,你可以将 Rx 视为基于 IObservable<T> 的事件 LINQ。观察者和其他 LINQ 提供程序之间的主要区别在于,Rx 是一种“推”模型,即查询定义了程序在事件到达时如何响应。Rx 在 LINQ 基础上构建,作为扩展方法添加了一些强大的新操作符。

本章介绍了一些常见的 Rx 操作。请记住,所有的 LINQ 操作符也都可用,因此像过滤 (Where) 和投影 (Select) 这样的简单操作在概念上与任何其他 LINQ 提供程序的工作方式相同。我们不会在这里介绍这些常见的 LINQ 操作;我们将专注于 Rx 在 LINQ 之上构建的新功能,特别是涉及时间的功能。

要使用 System.Reactive,请将 NuGet 包 System.Reactive 安装到你的应用程序中。

6.1 转换 .NET 事件

问题

你有一个事件,需要将其视为 System.Reactive 的输入流,每次触发事件时通过 OnNext 产生一些数据。

解决方案

Observable 类定义了几个事件转换器。大多数 .NET 框架事件都兼容 FromEventPattern,但如果你有不遵循常规模式的事件,可以使用 FromEvent

如果事件委托类型为 EventHandler<T>,则 FromEventPattern 的效果最佳。许多较新的框架类型使用此事件委托类型。例如,Progress<T> 类型定义了一个 ProgressChanged 事件,类型为 EventHandler<T>,因此可以轻松用 FromEventPattern 包装:

var progress = new Progress<int>();
IObservable<EventPattern<int>> progressReports =
    Observable.FromEventPattern<int>(
        handler => progress.ProgressChanged += handler,
        handler => progress.ProgressChanged -= handler);
progressReports.Subscribe(data => Trace.WriteLine("OnNext: " + data.EventArgs));

注意这里,data.EventArgs 的类型强制为 intFromEventPattern 的类型参数(如前面的例子中的 int)与 EventHandler<T> 中的 T 类型相同。FromEventPattern 的两个 lambda 参数使 System.Reactive 能够订阅和取消订阅事件。

较新的用户界面框架使用 EventHandler<T>,可以轻松与 FromEventPattern 配合使用,但旧类型通常为每个事件定义一个独特的委托类型。这些也可以与 FromEventPattern 一起使用,但需要更多工作。例如,System.Timers.Timer 类定义了一个 Elapsed 事件,类型为 ElapsedEventHandler。你可以像这样用 FromEventPattern 包装旧事件:

var timer = new System.Timers.Timer(interval: 1000) { Enabled = true };
IObservable<EventPattern<ElapsedEventArgs>> ticks =
    Observable.FromEventPattern<ElapsedEventHandler, ElapsedEventArgs>(
        handler => (s, a) => handler(s, a),
        handler => timer.Elapsed += handler,
        handler => timer.Elapsed -= handler);
ticks.Subscribe(data => Trace.WriteLine("OnNext: " + data.EventArgs.SignalTime));

请注意,在此示例中,data.EventArgs仍然是强类型的。FromEventPattern的类型参数现在是唯一的处理程序类型和派生的EventArgs类型。FromEventPattern的第一个 Lambda 参数是从EventHandler<ElapsedEventArgs>ElapsedEventHandler的转换器;该转换器除了传递事件之外不应执行其他操作。

那种语法确实变得笨拙了。这里有另一种选择,使用反射:

var timer = new System.Timers.Timer(interval: 1000) { Enabled = true };
IObservable<EventPattern<object>> ticks =
    Observable.FromEventPattern(timer, nameof(Timer.Elapsed));
ticks.Subscribe(data => Trace.WriteLine("OnNext: "
    + ((ElapsedEventArgs)data.EventArgs).SignalTime));

使用这种方法,调用FromEventPattern会更加简单。请注意,此方法存在一个缺点:消费者无法获得强类型的数据。因为data.EventArgs的类型是object,您必须自己将其转换为ElapsedEventArgs

讨论

事件是 System.Reactive 流的常见数据源。本文介绍了如何包装符合标准事件模式(第一个参数是发送者,第二个参数是事件参数类型)的任何事件。如果您有不寻常的事件类型,仍然可以使用Observable.FromEvent方法重载将它们包装成可观察对象。

当事件被包装成可观察对象时,每次事件被触发时都会调用OnNext。当处理AsyncCompletedEventArgs时,这可能会导致令人惊讶的行为,因为任何异常都作为数据(OnNext)传递,而不是作为错误(OnError)。例如,考虑WebClient.DownloadStringCompleted的这个包装器:

var client = new WebClient();
IObservable<EventPattern<object>> downloadedStrings =
    Observable.
    FromEventPattern(client, nameof(WebClient.DownloadStringCompleted));
downloadedStrings.Subscribe(
    data =>
    {
      var eventArgs = (DownloadStringCompletedEventArgs)data.EventArgs;
      if (eventArgs.Error != null)
        Trace.WriteLine("OnNext: (Error) " + eventArgs.Error);
      else
        Trace.WriteLine("OnNext: " + eventArgs.Result);
    },
    ex => Trace.WriteLine("OnError: " + ex.ToString()),
    () => Trace.WriteLine("OnCompleted"));
client.DownloadStringAsync(new Uri("http://invalid.example.com/"));

WebClient.DownloadStringAsync以错误完成时,事件会通过AsyncCompletedEventArgs.Error中的异常来触发。不幸的是,System.Reactive 将此视为数据事件,因此如果随后运行前述代码,则会打印出OnNext: (Error)而不是OnError:

有些事件的订阅和取消订阅必须在特定的上下文中完成。例如,许多 UI 控件上的事件必须从 UI 线程订阅。System.Reactive 提供了一个操作符来控制订阅和取消订阅的上下文:SubscribeOn。在大多数情况下,UI 基础的订阅都是从 UI 线程进行的,所以SubscribeOn操作符在大多数情况下并不是必需的。

提示

SubscribeOn控制添加和移除事件处理程序的代码的上下文。不要将其与ObserveOn混淆,后者控制可观察通知的上下文(传递给Subscribe的委托)。

参见

Recipe 6.2 介绍了如何更改引发事件的上下文。

Recipe 6.4 介绍了如何节流事件,以防止订阅者被压倒。

6.2 将通知发送到上下文

问题

System.Reactive 尽力保持线程无关性。因此,它会在当前线程中引发通知(例如,OnNext)。每个OnNext通知都将按顺序发生,但不一定在同一线程上。

您通常希望在特定上下文中引发这些通知。例如,UI 元素应仅从拥有它们的 UI 线程进行操作,因此如果您在响应在线程池线程上到达的通知时更新 UI,则需要切换到 UI 线程。

解决方案

System.Reactive 提供了 ObserveOn 操作符,用于将通知移动到另一个调度程序上。

考虑以下示例,它使用 Interval 操作符每秒创建一个 OnNext 通知:

private void Button_Click(object sender, RoutedEventArgs e)
{
  Trace.WriteLine($"UI thread is {Environment.CurrentManagedThreadId}");
  Observable.Interval(TimeSpan.FromSeconds(1))
      .Subscribe(x => Trace.WriteLine(
          $"Interval {x} on thread {Environment.CurrentManagedThreadId}"));
}

在我的机器上,输出看起来如下:

UI thread is 9
Interval 0 on thread 10
Interval 1 on thread 10
Interval 2 on thread 11
Interval 3 on thread 11
Interval 4 on thread 10
Interval 5 on thread 11
Interval 6 on thread 11

由于 Interval 基于定时器(没有特定的线程),通知是在线程池线程上引发的,而不是在 UI 线程上。如果您需要更新 UI 元素,您可以通过 ObserveOn 传递通知,并传递表示 UI 线程的同步上下文。

private void Button_Click(object sender, RoutedEventArgs e)
{
  SynchronizationContext uiContext = SynchronizationContext.Current;
  Trace.WriteLine($"UI thread is {Environment.CurrentManagedThreadId}");
  Observable.Interval(TimeSpan.FromSeconds(1))
      .ObserveOn(uiContext)
      .Subscribe(x => Trace.WriteLine(
          $"Interval {x} on thread {Environment.CurrentManagedThreadId}"));
}

ObserveOn 的另一个常见用法是在必要时将 UI 线程 切换到其他线程。考虑这样一种情况:每当鼠标移动时,您需要进行一些耗费 CPU 的计算。默认情况下,所有鼠标移动都在 UI 线程上引发,因此您可以使用 ObserveOn 将这些通知移动到线程池线程上,执行计算,然后将结果通知移回 UI 线程:

SynchronizationContext uiContext = SynchronizationContext.Current;
Trace.WriteLine($"UI thread is {Environment.CurrentManagedThreadId}");
Observable.FromEventPattern<MouseEventHandler, MouseEventArgs>(
        handler => (s, a) => handler(s, a),
        handler => MouseMove += handler,
        handler => MouseMove -= handler)
    .Select(evt => evt.EventArgs.GetPosition(this))
    .ObserveOn(Scheduler.Default)
    .Select(position =>
    {
      // Complex calculation
      Thread.Sleep(100);
      var result = position.X + position.Y;
      var thread = Environment.CurrentManagedThreadId;
      Trace.WriteLine($"Calculated result {result} on thread {thread}");
      return result;
    })
    .ObserveOn(uiContext)
    .Subscribe(x => Trace.WriteLine(
        $"Result {x} on thread {Environment.CurrentManagedThreadId}"));

如果您执行此示例,您会看到计算在线程池线程上进行,并且结果在 UI 线程上打印出来。但是,您也会注意到计算和结果会滞后于输入;它们会排队,因为鼠标位置更新频率超过每 100 毫秒。System.Reactive 提供了几种处理此情况的技术;其中一种常见的技术在 Recipe 6.4 中介绍,即节流输入。

讨论

ObserveOn 实际上将通知移动到 System.Reactive 的 调度程序 上。本文介绍了默认的(线程池)调度程序以及创建 UI 调度程序的一种方法。ObserveOn 操作符的最常见用途是在 UI 线程上移动或移出,但调度程序在其他场景中也很有用。调度程序在更高级的场景中也很有用,例如在单元测试时模拟时间流逝,您可以在 Recipe 7.6 中找到相关内容。

提示

ObserveOn 控制观察通知的上下文。这与控制添加和移除事件处理程序的代码上下文的 SubscribeOn 不同。

参见

Recipe 6.1 讲解了如何从事件创建序列,并使用 SubscribeOn

Recipe 6.4 讲解了对事件流进行节流处理。

Recipe 7.6 介绍了用于测试 System.Reactive 代码的特殊调度程序。

6.3 使用窗口和缓冲区分组事件数据

问题

你有一系列事件,并且希望在事件到达时对其进行分组。例如,您需要对输入的成对事件作出反应。另一个例子是,您需要在两秒的时间窗口内对所有输入作出反应。

解决方案

System.Reactive 提供了一对操作符来分组输入序列:BufferWindowBuffer 会保存输入事件,直到组合完成,然后一次性将它们作为事件集合转发。Window 会逻辑上分组输入事件,但会随着它们的到来立即传递。Buffer 的返回类型是 IObservable<IList<T>>(事件流的集合);Window 的返回类型是 IObservable<IObservable<T>>(事件流的事件流)。

以下示例使用 Interval 操作符每秒创建一个 OnNext 通知,并以两个为一组进行缓冲:

Observable.Interval(TimeSpan.FromSeconds(1))
    .Buffer(2)
    .Subscribe(x => Trace.WriteLine(
        $"{DateTime.Now.Second}: Got {x[0]} and {x[1]}"));

在我的机器上,此代码每两秒产生一对输出:

13: Got 0 and 1
15: Got 2 and 3
17: Got 4 and 5
19: Got 6 and 7
21: Got 8 and 9

以下是使用 Window 创建两个事件组的类似示例:

Observable.Interval(TimeSpan.FromSeconds(1))
    .Window(2)
    .Subscribe(group =>
    {
      Trace.WriteLine($"{DateTime.Now.Second}: Starting new group");
      group.Subscribe(
          x => Trace.WriteLine($"{DateTime.Now.Second}: Saw {x}"),
          () => Trace.WriteLine($"{DateTime.Now.Second}: Ending group"));
    });

在我的机器上,此 Window 示例产生以下输出:

17: Starting new group
18: Saw 0
19: Saw 1
19: Ending group
19: Starting new group
20: Saw 2
21: Saw 3
21: Ending group
21: Starting new group
22: Saw 4
23: Saw 5
23: Ending group
23: Starting new group

这些示例说明了 BufferWindow 之间的区别。Buffer 等待其组内的所有事件,然后发布单个集合。Window 以相同的方式分组事件,但会在事件到达时即刻发布它们;Window 立即发布一个可观察对象,用于发布该窗口的事件。

BufferWindow 也适用于时间跨度。以下代码示例中,所有鼠标移动事件都在一秒钟的窗口内收集:

private void Button_Click(object sender, RoutedEventArgs e)
{
  Observable.FromEventPattern<MouseEventHandler, MouseEventArgs>(
          handler => (s, a) => handler(s, a),
          handler => MouseMove += handler,
          handler => MouseMove -= handler)
      .Buffer(TimeSpan.FromSeconds(1))
      .Subscribe(x => Trace.WriteLine(
          $"{DateTime.Now.Second}: Saw {x.Count} items."));
}

根据鼠标的移动方式,您应该看到类似以下的输出:

49: Saw 93 items.
50: Saw 98 items.
51: Saw 39 items.
52: Saw 0 items.
53: Saw 4 items.
54: Saw 0 items.
55: Saw 58 items.

讨论

BufferWindow 是您用来管理输入并将其形成您想要的形式的工具之一。另一种有用的技术是限流,您将在 Recipe 6.4 中了解更多。

BufferWindow 都有其他重载版本,可用于更高级的场景。带有 skiptimeShift 参数的重载允许您创建与其他组重叠或在组之间跳过元素的组。还有带有委托参数的重载,允许您动态定义组的边界。

参见

Recipe 6.1 讲解了如何从事件中创建序列。

Recipe 6.4 讲解了如何限流事件流。

6.4 通过限流和采样来控制事件流

问题

编写响应式代码时常见的问题是事件到达速度过快。快速移动的事件流可能会超出程序的处理能力。

解决方案

System.Reactive 提供了专门用于处理大量事件数据的操作符。ThrottleSample 操作符为我们提供了两种不同的方法来控制快速输入事件。

Throttle 操作符建立了一个滑动超时窗口。当接收到新事件时,它会重置超时窗口。当超时窗口到期时,它会发布窗口内最后到达的事件值。

以下示例监控鼠标移动,并使用 Throttle 仅在鼠标静止一秒钟后报告更新:

private void Button_Click(object sender, RoutedEventArgs e)
{
  Observable.FromEventPattern<MouseEventHandler, MouseEventArgs>(
          handler => (s, a) => handler(s, a),
          handler => MouseMove += handler,
          handler => MouseMove -= handler)
      .Select(x => x.EventArgs.GetPosition(this))
      .Throttle(TimeSpan.FromSeconds(1))
      .Subscribe(x => Trace.WriteLine(
          $"{DateTime.Now.Second}: Saw {x.X + x.Y}"));
}

输出因鼠标移动而大不相同,但在我的机器上的一个示例运行看起来是这样的:

47: Saw 139
49: Saw 137
51: Saw 424
56: Saw 226

Throttle经常用于像自动完成这样的情况,当用户在文本框中输入文本时,您不希望在用户停止输入之前进行实际查找。

Sample 采用了不同的方法来控制快速移动的序列。Sample 建立了一个常规的超时周期,并在每次超时到期时发布该窗口内的最新值。如果在抽样周期内没有收到值,则不会发布任何结果。

下面的示例捕获鼠标移动并在一秒间隔内对其进行抽样。与 Throttle 示例不同,这个 Sample 示例不需要您保持鼠标静止以查看数据:

private void Button_Click(object sender, RoutedEventArgs e)
{
  Observable.FromEventPattern<MouseEventHandler, MouseEventArgs>(
          handler => (s, a) => handler(s, a),
          handler => MouseMove += handler,
          handler => MouseMove -= handler)
      .Select(x => x.EventArgs.GetPosition(this))
      .Sample(TimeSpan.FromSeconds(1))
      .Subscribe(x => Trace.WriteLine(
          $"{DateTime.Now.Second}: Saw {x.X + x.Y}"));
}

当我第一次让鼠标静止几秒钟然后持续移动时,这是我在我的机器上的输出:

12: Saw 311
17: Saw 254
18: Saw 269
19: Saw 342
20: Saw 224
21: Saw 277

讨论

对于驯服输入的洪流来说,节流和抽样是必不可少的工具。不要忘记您还可以使用标准 LINQ Where 运算符轻松进行过滤。您可以将 ThrottleSample 运算符视为类似于 Where,只是它们基于时间窗口而不是事件数据进行过滤。这三个运算符各自以不同的方式帮助您驯服快速移动的输入流。

参见

Recipe 6.1 介绍了如何从事件创建序列。

Recipe 6.2 介绍了如何改变事件触发的上下文。

6.5 超时

问题

您期望在一定时间内收到事件,并确保您的程序能够及时响应,即使事件没有及时到达。最常见的情况是,这种期望的事件是单个异步操作(例如,期待来自 Web 服务请求的响应)。

解决方案

Timeout 运算符在其输入流上建立了一个滑动超时窗口。每当新事件到达时,超时窗口就会被重置。如果在该窗口内没有看到事件而超时,则 Timeout 运算符将使用一个 TimeoutExceptionOnError 通知结束流。

下面的示例发出对示例域的网页请求,并设置了一秒钟的超时。为了启动网页请求,代码使用 ToObservableTask<T> 转换为 IObservable<T>(参见 Recipe 8.6):

void GetWithTimeout(HttpClient client)
{
  client.GetStringAsync("http://www.example.com/").ToObservable()
      .Timeout(TimeSpan.FromSeconds(1))
      .Subscribe(
          x => Trace.WriteLine($"{DateTime.Now.Second}: Saw {x.Length}"),
          ex => Trace.WriteLine(ex));
}

Timeout 对于异步操作(例如 Web 请求)非常理想,但它可以应用于任何事件流。下面的示例将 Timeout 应用于鼠标移动,这样更容易玩耍:

private void Button_Click(object sender, RoutedEventArgs e)
{
  Observable.FromEventPattern<MouseEventHandler, MouseEventArgs>(
          handler => (s, a) => handler(s, a),
          handler => MouseMove += handler,
          handler => MouseMove -= handler)
      .Select(x => x.EventArgs.GetPosition(this))
      .Timeout(TimeSpan.FromSeconds(1))
      .Subscribe(
          x => Trace.WriteLine($"{DateTime.Now.Second}: Saw {x.X + x.Y}"),
          ex => Trace.WriteLine(ex));
}

在我的电脑上,我移动了鼠标一下然后静止了一秒钟,得到了以下结果:

16: Saw 180
16: Saw 178
16: Saw 177
16: Saw 176
System.TimeoutException: The operation has timed out.

请注意,一旦TimeoutException被发送到OnError,流就结束了。不再传递更多的鼠标移动。也许您并不希望出现这种行为,因此Timeout操作符有多个重载版本,当超时发生时,会用第二个流替代结束流,并不抛出异常。

下面示例中的代码观察鼠标移动直到超时。超时后,代码观察鼠标点击:

private void Button_Click(object sender, RoutedEventArgs e)
{
  IObservable<Point> clicks =
      Observable.FromEventPattern<MouseButtonEventHandler, MouseButtonEventArgs>(
          handler => (s, a) => handler(s, a),
          handler => MouseDown += handler,
          handler => MouseDown -= handler)
      .Select(x => x.EventArgs.GetPosition(this));

  Observable.FromEventPattern<MouseEventHandler, MouseEventArgs>(
          handler => (s, a) => handler(s, a),
          handler => MouseMove += handler,
          handler => MouseMove -= handler)
      .Select(x => x.EventArgs.GetPosition(this))
      .Timeout(TimeSpan.FromSeconds(1), clicks)
      .Subscribe(
          x => Trace.WriteLine($"{DateTime.Now.Second}: Saw {x.X},{x.Y}"),
          ex => Trace.WriteLine(ex));
}

在我的机器上,我稍微移动了一下鼠标,然后静止了一秒钟,然后点击了几个不同的点。以下输出显示了鼠标移动快速通过直到超时,然后显示了两次点击:

49: Saw 95,39
49: Saw 94,39
49: Saw 94,38
49: Saw 94,37
53: Saw 130,141
55: Saw 469,4

讨论

在复杂应用程序中,Timeout是一个必不可少的操作符,因为即使世界其他地方没有响应,您始终希望程序响应。当您有异步操作时,它尤其有用,但它也可以应用于任何事件流。请注意,底层操作实际上并没有被取消;在超时的情况下,操作将继续执行,直到成功或失败。

参见

6.1 菜谱介绍了如何从事件创建序列。

8.6 菜谱介绍了如何将异步代码包装为可观察事件流。

10.6 菜谱介绍了如何因CancellationToken取消订阅序列。

10.3 菜谱介绍了如何使用CancellationToken作为超时。

第七章:测试

测试是软件质量的重要组成部分。近年来,单元测试的支持者已经变得司空见惯;似乎你无论读什么或听什么都会提到它。一些人推广测试驱动开发,这是一种编码风格,确保在应用程序完成时有全面的测试。单元测试对代码质量和整体完成时间的好处是众所周知的,但许多开发者仍然不写单元测试。

我鼓励你至少写一些单元测试。从你感到最缺乏信心的代码开始。根据我的经验,单元测试给了我两个主要优势:

  • 更好地理解代码。 你知道应用程序的那一部分能工作但你不知道为什么吗?当真正奇怪的 bug 报告出现时,它总是潜在地存在于你的脑海中。为你觉得困难的代码编写单元测试是了解它如何工作的一个绝佳方法。在编写描述其行为的单元测试后,代码不再神秘;你最终会得到一组描述其行为及其对其他代码的依赖关系的单元测试。

  • 更大的改动信心。 迟早你会收到需要修改令你害怕的代码的功能请求,你将无法再假装它不存在(我知道那种感觉,我也经历过!)。最好是主动出击:在功能请求到来之前为可怕的代码编写单元测试。一旦你的单元测试完成,你就会有一个早期警报系统,如果你的修改破坏了现有行为,它会立即提醒你。当你有一个拉取请求时,单元测试也会让你更有信心,确保代码变更不会破坏现有行为。

这两个优势同样适用于你自己的代码,不仅仅是别人的代码。我相信还有其他优点。单元测试减少 bug 的频率吗?很可能是。单元测试减少项目总体时间吗?可能是。但我描述的优势是确定的;每次我写单元测试时,我都能感受到它们。所以,这是我对单元测试的推销。

本章包含的配方全都是关于测试的。很多开发者(即使通常编写单元测试的那些人)都会避开测试并发代码,因为他们认为这很难。然而,正如这些配方将展示的那样,单元测试并发代码并不像他们想象的那么难。现代特性和库,如async和 System.Reactive,在测试方面都进行了大量的思考,这一点很明显。我鼓励你使用这些配方来编写单元测试,特别是如果你对并发编程还很陌生(即新的并发代码看起来很难或令人害怕)。

7.1 异步方法的单元测试

问题

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

解决方案

大多数现代单元测试框架支持async Task单元测试方法,包括 MSTest、NUnit 和 xUnit。MSTest 从 Visual Studio 2012 开始支持这些测试。如果你使用其他单元测试框架,可能需要升级到最新版本。

这里是一个async MSTest 单元测试的例子:

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

单元测试框架会注意到方法的返回类型是Task,并智能地等待任务完成,然后标记测试为“成功”或“失败”。

如果你的单元测试框架不支持async Task单元测试,那么它需要一些帮助来等待正在测试的异步操作。一种选择是使用GetAwaiter().GetResult()来同步阻塞任务;如果你使用GetAwaiter().GetResult()而不是Wait(),它会避免AggregateException包装器(如果任务有异常的话)。然而,我更喜欢使用Nito.AsyncEx NuGet 包中的AsyncContext类型:

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

AsyncContext.Run会等待所有异步方法完成。

讨论

最初,模拟异步依赖可能有点笨拙。建议至少测试你的方法如何响应同步成功(使用Task.FromResult进行模拟)、同步错误(使用Task.FromException进行模拟)和异步成功(使用Task.Yield进行模拟并返回值)。你可以在第 2.2 节中找到有关Task.FromResultTask.FromException的覆盖率。Task.Yield可用于强制异步行为,主要用于单元测试:

interface IMyInterface
{
  Task<int> SomethingAsync();
}

class SynchronousSuccess : IMyInterface
{
  public Task<int> SomethingAsync()
  {
    return Task.FromResult(13);
  }
}

class SynchronousError : IMyInterface
{
  public Task<int> SomethingAsync()
  {
    return Task.FromException<int>(new InvalidOperationException());
  }
}

class AsynchronousSuccess : IMyInterface
{
  public async Task<int> SomethingAsync()
  {
    await Task.Yield(); // Force asynchronous behavior.
    return 13;
  }
}

在测试异步代码时,死锁和竞争条件可能比测试同步代码更容易出现。我发现每个测试设置超时时间非常有用;在 Visual Studio 中,你可以向解决方案添加一个测试设置文件,以便设置单独的测试超时时间。默认值相当高;我通常将每个测试的超时设置为两秒。

提示

AsyncContext类型位于Nito.AsyncEx NuGet 包中。

参见

第 7.2 节涵盖了预期失败的异步方法的单元测试。

7.2 测试失败预期的异步方法

问题

你需要编写一个单元测试来检查async Task方法的特定失败情况。

解决方案

如果你在桌面或服务器开发中,MSTest 确实支持通过常规的ExpectedExceptionAttribute进行失败测试:

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

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

大多数现代单元测试框架都以某种形式包含Assert.ThrowsAsync<TException>。例如,你可以像这样使用 xUnit 的ThrowsAsync

[Fact]
public async Task Divide_WhenDenominatorIsZero_ThrowsDivideByZero()
{
  await Assert.ThrowsAsync<DivideByZeroException>(async () =>
  {
    await MyClass.DivideAsync(4, 0);
  });
}
警告

不要忘记await ThrowsAsync 返回的任务!await 将传播任何检测到的断言失败。如果您忽略await并忽略编译器警告,那么您的单元测试将始终在不管方法行为如何的情况下静默成功。

不幸的是,其他几个单元测试框架不包括与 async 兼容的 ThrowsAsync 等效项。如果您发现自己处于这种情况中,请创建您自己的:

/// <summary>
/// Ensures that an asynchronous delegate throws an exception.
/// </summary>
/// <typeparam name="TException">
/// The type of exception to expect.
/// </typeparam>
/// <param name="action">The asynchronous delegate to test.</param>
/// <param name="allowDerivedTypes">
/// Whether derived types should be accepted.
/// </param>
public static async Task<TException> ThrowsAsync<TException>(Func<Task> action,
    bool allowDerivedTypes = true)
    where TException : Exception
{
  try
  {
    await action();
    var name = typeof(Exception).Name;
    Assert.Fail($"Delegate did not throw expected exception {name}.");
    return null;
  }
  catch (Exception ex)
  {
    if (allowDerivedTypes && !(ex is TException))
      Assert.Fail($"Delegate threw exception of type {ex.GetType().Name}" +
          $", but {typeof(TException).Name} or a derived type was expected.");
    if (!allowDerivedTypes && ex.GetType() != typeof(TException))
      Assert.Fail($"Delegate threw exception of type {ex.GetType().Name}" +
          $", but {typeof(TException).Name} was expected.");
    return (TException)ex;
  }
}

您可以像使用任何其他 Assert.ThrowsAsync<TException> 方法一样使用此方法。不要忘记await返回值!

讨论

测试错误处理与测试成功场景一样重要。有人甚至会说更重要,因为成功的场景是软件发布前每个人都会尝试的场景。如果您的应用行为异常,那可能是由于意外的错误情况。

然而,我鼓励开发人员不要再使用 ExpectedException。最好在特定点测试抛出异常,而不是在测试过程中的任何时候测试异常。请使用 ThrowsAsync(或您的单元测试框架中的等效项),或者像最后一个代码示例中一样使用 ThrowsAsync 的实现。

另请参阅

Recipe 7.1 涵盖了单元测试异步方法的基础知识。

7.3 单元测试 async void 方法

问题

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

解决方案

停止。

而不是解决这个问题,您应尽全力避免它。如果可能将您的 async void 方法更改为 async Task 方法,则应这样做。

如果您的方法必须async void(例如,为了满足接口方法签名),则考虑编写两个方法:一个是包含所有逻辑的 async Task 方法,另一个是只调用 async Task 方法并等待结果的 async void 包装器。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();
  });
}

AsyncContext 类型将等待所有异步操作完成(包括 async void 方法)并传播它们引发的异常。

提示

Nito.AsyncEx NuGet 包中包含 AsyncContext 类型。

讨论

async 代码中的一个关键指导原则是避免使用 async void。我强烈建议您重构代码,而不是为了单元测试 async void 方法而使用 AsyncContext

另请参阅

Recipe 7.1 涵盖了单元测试异步方法的基础知识。

7.4 单元测试数据流网格

问题

您的应用程序中有一个数据流网格,并且您需要验证其正常工作。

解决方案

数据流网格是独立的:它们有自己的生命周期,并且本质上是异步的。因此,测试它们的最自然方式是使用异步单元测试。以下单元测试验证来自 Recipe 5.6 的自定义数据流块:

[TestMethod]
public async Task MyCustomBlock_AddsOneToDataItems()
{
  var myCustomBlock = CreateMyCustomBlock();

  myCustomBlock.Post(3);
  myCustomBlock.Post(13);
  myCustomBlock.Complete();

  Assert.AreEqual(4, myCustomBlock.Receive());
  Assert.AreEqual(14, myCustomBlock.Receive());
  await myCustomBlock.Completion;
}

单元测试失败并不是那么简单。这是因为数据流网格中的异常每次传播到下一个块时都会被包装在另一个AggregateException中。以下示例使用一个辅助方法来确保异常将丢弃数据并通过自定义块传播:

[TestMethod]
public async Task MyCustomBlock_Fault_DiscardsDataAndFaults()
{
  var myCustomBlock = CreateMyCustomBlock();

  myCustomBlock.Post(3);
  myCustomBlock.Post(13);
  (myCustomBlock as IDataflowBlock).Fault(new InvalidOperationException());

  try
  {
    await myCustomBlock.Completion;
  }
  catch (AggregateException ex)
  {
    AssertExceptionIs<InvalidOperationException>(
        ex.Flatten().InnerException, false);
  }
}

public static void AssertExceptionIs<TException>(Exception ex,
    bool allowDerivedTypes = true)
{
  if (allowDerivedTypes && !(ex is TException))
    Assert.Fail($"Exception is of type {ex.GetType().Name}, but " +
        $"{typeof(TException).Name} or a derived type was expected.");
  if (!allowDerivedTypes && ex.GetType() != typeof(TException))
    Assert.Fail($"Exception is of type {ex.GetType().Name}, but " +
        $"{typeof(TException).Name} was expected.");
}

讨论

直接对数据流网格进行单元测试是可行的,但有些笨拙。如果您的网格是较大组件的一部分,那么您可能会发现仅对较大组件进行单元测试(隐式测试网格)更容易。但如果您正在开发可重用的自定义块或网格,则应使用类似前面的单元测试。

参见

Recipe 7.1 涵盖了对async方法的单元测试。

7.5 单元测试 System.Reactive 可观察序列

问题

您的程序的一部分正在使用IObservable<T>,您需要找到一种方法来对其进行单元测试。

解决方案

System.Reactive 有许多操作符来生成序列(例如Return)和其他可以将响应序列转换为常规集合或项的操作符(例如SingleAsync)。你可以使用诸如Return的操作符来创建可观察依赖的存根,并使用诸如SingleAsync的操作符来测试输出。

考虑以下代码,它将 HTTP 服务作为依赖项,并对 HTTP 调用应用超时:

public interface IHttpService
{
  IObservable<string> GetString(string url);
}

public class MyTimeoutClass
{
  private readonly IHttpService _httpService;

  public MyTimeoutClass(IHttpService httpService)
  {
    _httpService = httpService;
  }

  public IObservable<string> GetStringWithTimeout(string url)
  {
    return _httpService.GetString(url)
        .Timeout(TimeSpan.FromSeconds(1));
  }
}

受测试系统是MyTimeoutClass,它消耗一个可观察依赖项并产生一个可观察输出。

Return操作符创建一个包含单个元素的冷序列;您可以使用Return来构建一个简单的存根。SingleAsync操作符返回一个在下一个事件到达时完成的Task<T>SingleAsync可以用于像以下这样的简单单元测试:

class SuccessHttpServiceStub : IHttpService
{
  public IObservable<string> GetString(string url)
  {
    return Observable.Return("stub");
  }
}

[TestMethod]
public async Task MyTimeoutClass_SuccessfulGet_ReturnsResult()
{
  var stub = new SuccessHttpServiceStub();
  var my = new MyTimeoutClass(stub);

  var result = await my.GetStringWithTimeout("http://www.example.com/")
      .SingleAsync();

  Assert.AreEqual("stub", result);
}

在存根代码中另一个重要的操作符是Throw,它返回以错误结束的可观察序列。该操作符使我们能够对错误情况进行单元测试。以下示例使用了来自 Recipe 7.2 的ThrowsAsync辅助方法:

private class FailureHttpServiceStub : IHttpService
{
  public IObservable<string> GetString(string url)
  {
    return Observable.Throw<string>(new HttpRequestException());
  }
}

[TestMethod]
public async Task MyTimeoutClass_FailedGet_PropagatesFailure()
{
  var stub = new FailureHttpServiceStub();
  var my = new MyTimeoutClass(stub);

  await ThrowsAsync<HttpRequestException>(async () =>
  {
    await my.GetStringWithTimeout("http://www.example.com/")
        .SingleAsync();
  });
}

讨论

ReturnThrow非常适合创建可观察存根,而SingleAsync是测试具有async单元测试的简单方法。它们对于简单的可观察序列是一个很好的组合,但是一旦涉及到时间,它们就无法很好地支持。例如,如果您想测试MyTimeoutClass的超时功能,单元测试将需要等待一段时间。然而,这是一个不好的方法:它通过引入竞争条件使您的单元测试不可靠,并且随着添加更多单元测试,它不会很好地扩展。Recipe 7.6 介绍了 System.Reactive 如何特殊处理使您能够模拟时间本身的方法。

参见

Recipe 7.1 讨论了对 async 方法进行单元测试,这与等待 SingleAsync 的单元测试非常相似。

Recipe 7.6 讨论了依赖于时间推移的可观测序列的单元测试。

7.6 使用伪造调度器对 System.Reactive 可观测对象进行单元测试

问题

你有一个依赖于时间的可观测对象,并且想编写一个不依赖于时间的单元测试。依赖于时间的可观测对象包括使用超时、窗口/缓冲以及节流/抽样的对象。你希望对这些进行单元测试,但不希望单元测试运行时间过长。

解决方案

当然,你可以在单元测试中添加延迟;然而,这种方法存在两个问题:1)单元测试运行时间长,2)由于单元测试同时运行,造成了竞争条件,使得时间难以预测。

System.Reactive(Rx)库设计时考虑了测试;事实上,Rx 库本身经过了大量单元测试。为了实现彻底的单元测试,Rx 引入了一个称为 scheduler 的概念,并且 每个 处理时间的 Rx 运算符都是使用这个抽象调度器实现的。

为了使你的可观测对象可测试,你需要允许调用者指定调度器。例如,你可以从 Recipe 7.5 中获取 MyTimeoutClass 并添加一个调度器:

public interface IHttpService
{
  IObservable<string> GetString(string url);
}

public class MyTimeoutClass
{
  private readonly IHttpService _httpService;

  public MyTimeoutClass(IHttpService httpService)
  {
    _httpService = httpService;
  }

  public IObservable<string> GetStringWithTimeout(string url,
      IScheduler scheduler = null)
  {
    return _httpService.GetString(url)
        .Timeout(TimeSpan.FromSeconds(1), scheduler ?? Scheduler.Default);
  }
}

接下来,你可以修改你的 HTTP 服务存根,使其也能理解调度,然后引入可变的延迟:

private class SuccessHttpServiceStub : IHttpService
{
  public IScheduler Scheduler { get; set; }
  public TimeSpan Delay { get; set; }

  public IObservable<string> GetString(string url)
  {
    return Observable.Return("stub")
        .Delay(Delay, Scheduler);
  }
}

现在你可以继续使用 TestScheduler,这是 System.Reactive 库中包含的一种类型。 TestScheduler 让你强大地控制(虚拟)时间。

提示

TestScheduler 与 System.Reactive 的其余部分不在同一个 NuGet 包中;你需要安装 Microsoft.Reactive.Testing NuGet 包。

TestScheduler 让你完全控制时间,但通常你只需设置好你的代码,然后调用 TestScheduler.StartStart 会虚拟推进时间,直到所有操作完成。一个简单的成功测试用例如下所示:

[TestMethod]
public void MyTimeoutClass_SuccessfulGetShortDelay_ReturnsResult()
{
  var scheduler = new TestScheduler();
  var stub = new SuccessHttpServiceStub
  {
    Scheduler = scheduler,
    Delay = TimeSpan.FromSeconds(0.5),
  };
  var my = new MyTimeoutClass(stub);
  string result = null;

  my.GetStringWithTimeout("http://www.example.com/", scheduler)
      .Subscribe(r => { result = r; });

  scheduler.Start();

  Assert.AreEqual("stub", result);
}

该代码模拟了半秒钟的网络延迟。需要注意的是,这个单元测试 不会 花费半秒钟来运行;在我的机器上,大约只需 70 毫秒。半秒钟的延迟仅存在于虚拟时间中。这个单元测试的另一个显著区别是它不是异步的;因为你使用了 TestScheduler,所有测试可以立即完成。

现在一切都在使用测试调度器,很容易测试超时情况:

[TestMethod]
public void MyTimeoutClass_SuccessfulGetLongDelay_ThrowsTimeoutException()
{
  var scheduler = new TestScheduler();
  var stub = new SuccessHttpServiceStub
  {
    Scheduler = scheduler,
    Delay = TimeSpan.FromSeconds(1.5),
  };
  var my = new MyTimeoutClass(stub);
  Exception result = null;

  my.GetStringWithTimeout("http://www.example.com/", scheduler)
      .Subscribe(_ => Assert.Fail("Received value"), ex => { result = ex; });

  scheduler.Start();

  Assert.IsInstanceOfType(result, typeof(TimeoutException));
}

再次强调,前面的单元测试不需要花费 1 秒(或 1.5 秒)来运行;它立即执行,使用虚拟时间。

讨论

在这个配方中,我们只是浅尝了一下 System.Reactive 的调度器和虚拟时间。我建议您在开始编写 System.Reactive 代码时就开始进行单元测试;随着代码变得越来越复杂,您可以放心使用 Microsoft.Reactive.Testing 来处理它。

TestScheduler 还有 AdvanceToAdvanceBy 方法,这些方法使您能够逐步在虚拟时间中前进。在某些情况下,这可能很有用,但您应该努力让您的单元测试只测试一件事情。要测试超时,您可以编写一个单元测试,部分前进 TestScheduler 并确保超时不会过早发生,然后前进 TestScheduler 超过超时值并确保超时确实发生。然而,我更喜欢尽可能运行分开的单元测试;例如,一个单元测试确保超时不会过早发生,另一个单元测试确保超时稍后发生。

另请参见

配方 7.5 覆盖了观察序列单元测试的基础知识。

第八章:互操作性

异步、并行、响应式——每种方法都有其适用的场合,但它们如何一起工作呢?

在本章中,我们将探讨各种互操作场景,在这些场景中,您将学习如何结合这些不同的方法。您将了解到它们是互补的,而不是竞争的;在一个方法遇到另一个方法的边界处,几乎没有摩擦。

8.1 **异步包装器用于带有“Completed”事件的“Async”方法

问题

存在一种较旧的异步模式,使用名为*`Operation`*Async的方法以及名为*`Operation`*Completed的事件。您希望使用旧的异步模式执行操作并等待结果。

提示

*`Operation`*Async*`Operation`*Completed模式称为事件驱动的异步模式(EAP)。您将把它们封装成遵循任务异步模式(TAP)的返回Task方法。

解决方案

通过使用TaskCompletionSource<TResult>类型,您可以创建异步操作的包装器。TaskCompletionSource<TResult>类型控制Task<TResult>,并使您能够在适当的时候完成任务。

此示例定义了一个用于下载stringWebClient的扩展方法。WebClient类型定义了DownloadStringAsyncDownloadStringCompleted。使用这些,您可以定义一个DownloadStringTaskAsync方法,如下所示:

public static Task<string> DownloadStringTaskAsync(this WebClient client,
    Uri address)
{
  var tcs = new TaskCompletionSource<string>();

  // The event handler will complete the task and unregister itself.
  DownloadStringCompletedEventHandler handler = null;
  handler = (_, e) =>
  {
    client.DownloadStringCompleted -= handler;
    if (e.Cancelled)
      tcs.TrySetCanceled();
    else if (e.Error != null)
      tcs.TrySetException(e.Error);
    else
      tcs.TrySetResult(e.Result);
  };

  // Register for the event and *then* start the operation.
  client.DownloadStringCompleted += handler;
  client.DownloadStringAsync(address);

  return tcs.Task;
}

讨论

这个特定的例子并不是很有用,因为WebClient已经定义了DownloadStringTaskAsync,而且有一个更加支持asyncHttpClient可以使用。然而,这种技术同样适用于接口未更新为使用Task的旧异步代码。

提示

对于新代码,始终使用HttpClient。仅在使用旧代码时使用WebClient

通常,用于下载字符串的 TAP 方法将命名为*`Operation`*Async(例如,DownloadStringAsync);但是,在这种情况下,该命名约定不起作用,因为 EAP 已经定义了具有该名称的方法。在这里,约定是将 TAP 方法命名为*`Operation`*TaskAsync(例如,DownloadStringTaskAsync)。

在包装 EAP 方法时,存在“启动”方法可能会抛出异常的可能性;在前面的示例中,DownloadStringAsync可能会抛出异常。在这种情况下,您需要决定是允许异常传播还是捕获异常并调用TrySetException。大多数情况下,这些点抛出的异常是使用错误,所以无论选择哪种选项都没关系。如果不确定异常是否是使用错误,那么建议捕获异常并调用TrySetException

参见

Recipe 8.2 介绍了对 APM 方法(Begin*`Operation`*End*`Operation`*)的 TAP 包装器。

Recipe 8.3 介绍了任何类型通知的 TAP 包装器。

8.2 **异步包装器用于“Begin/End”方法

问题

旧的异步模式使用一对名为Begin*`Operation`*End*`Operation`*的方法,IAsyncResult表示异步操作。您有一个遵循旧的异步模式的操作,并希望使用await消费它。

提示

Begin*`Operation`*End*`Operation`*模式称为异步编程模型(APM)。您将把它们包装成遵循基于任务的异步模式(TAP)的返回Task的方法。

解决方案

包装 APM 的最佳方法是使用TaskFactory类型上的FromAsync方法之一。FromAsync在内部使用TaskCompletionSource<TResult>,但在包装 APM 时,使用FromAsync要简单得多。

此示例定义了一个为WebRequest定义扩展方法的例子,该方法发送 HTTP 请求并获取响应。WebRequest类型定义了BeginGetResponseEndGetResponse;您可以像这样定义一个GetResponseAsync方法:

public static Task<WebResponse> GetResponseAsync(this WebRequest client)
{
  return Task<WebResponse>.Factory.FromAsync(client.BeginGetResponse,
      client.EndGetResponse, null);
}

讨论

FromAsync有令人困惑的多种重载!

通常最好像示例中那样调用FromAsync。首先,传递Begin*`Operation`*方法(不调用它),然后传递End*`Operation`*方法(不调用它)。接下来,传递Begin*`Operation`*所需的所有参数,但最后的AsyncCallbackobject参数除外。最后,传递null

特别是,在调用FromAsync之前不要调用Begin*`Operation`*方法。您可以调用FromAsync,传递从Begin*`Operation`*获取的IAsyncOperation,但如果以这种方式调用它,FromAsync将被迫使用较低效的实现。

也许你会想知道为什么推荐的模式总是在最后传递一个null。在.NET 4.0 中引入了FromAsyncTask类型,而async还不存在。那时,在异步回调中常见使用state对象,而Task类型通过其AsyncState成员支持此功能。在新的async模式中,不再需要状态对象,因此对于state参数总是传递null是正常的。如今,state仅用于在优化内存使用时避免闭包实例。

另请参阅

配方 8.3 涵盖了为任何类型的通知编写 TAP 包装器。

8.3 任意内容的异步包装器

问题

您有一个不寻常或非标准的异步操作或事件,并希望通过await消费它。

解决方案

TaskCompletionSource<T>类型可用于在任何场景中构造Task<T>对象。使用TaskCompletionSource<T>,您可以以三种不同的方式完成任务:成功的结果、故障或取消。

async出现之前,Microsoft 推荐了另外两种异步模式:APM(食谱 8.2)和 EAP(食谱 8.1)。然而,APM 和 EAP 都相当笨拙,并且在某些情况下很难正确使用。因此,出现了一种非官方的约定,使用回调方法,如以下方法:

public interface IMyAsyncHttpService
{
  void DownloadString(Uri address, Action<string, Exception> callback);
}

这类方法遵循这样的约定,即DownloadString将启动(异步)下载,并在完成时通过回调调用callback,传递结果或异常。通常情况下,callback在后台线程上被调用。

类似前面示例的非标准异步方法可以使用TaskCompletionSource<T>来进行包装,以便它自然地与await一起工作,如下一个示例所示:

public static Task<string> DownloadStringAsync(
    this IMyAsyncHttpService httpService, Uri address)
{
  var tcs = new TaskCompletionSource<string>();
  httpService.DownloadString(address, (result, exception) =>
  {
    if (exception != null)
      tcs.TrySetException(exception);
    else
      tcs.TrySetResult(result);
  });
  return tcs.Task;
}

讨论

您可以使用相同的TaskCompletionSource<T>模式来包装任何非标准的异步方法,无论多么非标准。首先创建TaskCompletionSource<T>实例。接下来,安排一个回调,使TaskCompletionSource<T>适当地完成其任务。然后,启动实际的异步操作。最后,返回附加到该TaskCompletionSource<T>Task<T>

为了确保这种模式的重要性,您必须确保TaskCompletionSource<T>始终被完成。特别是要仔细考虑您的错误处理,并确保TaskCompletionSource<T>会得到适当的完成。在最后一个示例中,异常明确地传递到回调中,因此您不需要catch块;但有些非标准模式可能需要您在回调中捕获异常并将其放置在TaskCompletionSource<T>上。

参见

食谱 8.1 涵盖了用于 EAP 成员的 TAP 包装器(*`Operation`*Async*`Operation`*Completed)。

食谱 8.2 涵盖了用于 APM 成员的 TAP 包装器(Begin*`Operation`*End*`Operation`*)。

8.4 用于并行代码的异步包装器

问题

您有(CPU 绑定的)并行处理,希望使用await消耗它。通常情况下,这是希望的,以使您的 UI 线程不会因等待并行处理完成而阻塞。

解决方案

Parallel 类型和并行 LINQ 使用线程池来进行并行处理。它们还会将调用线程作为其中一个并行处理线程,因此如果从 UI 线程调用并行方法,则 UI 将在处理完成之前无响应。

为了保持 UI 的响应性,在Task.Run中包装并await结果:

await Task.Run(() => Parallel.ForEach(...));

本食谱的关键在于并行代码包括调用线程在其用于并行处理的线程池中。这对于并行 LINQ 和 Parallel 类都是如此。

讨论

这是一个简单的配方,但经常被忽视。通过使用 Task.Run,您将所有并行处理推送到线程池。Task.Run 返回一个代表该并行工作的 Task,UI 线程可以(异步地)等待其完成。

本配方仅适用于 UI 代码。在服务器端(例如 ASP.NET)很少进行并行处理,因为服务器主机已经执行了并行处理。因此,服务器端代码不应执行并行处理,也不应将工作推送到线程池。

另请参阅

第四章介绍了并行代码的基础知识。

第二章介绍了异步代码的基础知识。

8.5 用于 System.Reactive 可观察对象的异步包装器

问题

您有一个您希望使用 await 消耗的可观察流。

解决方案

首先,您需要决定您对事件流中的哪些可观察事件感兴趣。这些是常见的情况:

  • 流结束前的最后一个事件

  • 下一个事件

  • 所有事件

要捕获流中的最后一个事件,您可以 await LastAsync 的结果,或者直接 await 可观察对象:

IObservable<int> observable = ...;
int lastElement = await observable.LastAsync();
// or:  int lastElement = await observable;

当您 await 可观察对象或 LastAsync 时,代码(异步地)等待直到流完成并返回最后一个元素。在底层,await 是订阅流。

要捕获流中的下一个事件,请使用 FirstAsync。在以下代码中,await 订阅流,然后在第一个事件到达时完成(并取消订阅):

IObservable<int> observable = ...;
int nextElement = await observable.FirstAsync();

要捕获流中的所有事件,您可以使用 ToList

IObservable<int> observable = ...;
IList<int> allElements = await observable.ToList();

讨论

System.Reactive 库提供了使用 await 消耗流所需的所有工具。唯一棘手的部分是您必须考虑 awaitable 是否会等待直到流完成。在本配方的示例中,LastAsyncToList 和直接 await 将等待直到流完成;FirstAsync 仅等待下一个事件。

如果这些示例不满足您的需求,请记住,您可以完全利用 LINQ 的强大功能以及 System.Reactive 操纵器。诸如 TakeBuffer 的运算符也可以帮助您在不必等待整个流完成的情况下异步等待所需的元素。

一些与 await 一起使用的运算符——如 FirstAsyncLastAsync——实际上并不返回 Task<T>。如果您计划使用 Task.WhenAllTask.WhenAny,那么您需要一个实际的 Task<T>,您可以通过在任何可观察对象上调用 ToTask 来获得。ToTask 将返回一个在流中完成的 Task<T>

另请参阅

配方 8.6 介绍了在可观察流中使用异步代码的方法。

配方 8.8 介绍了将可观察流用作数据流块输入的方法(可以执行异步工作)。

6.3 节介绍了用于可观察流的窗口和缓冲区。

8.6 System.Reactive 对异步代码的可观察包装

问题

您有一个想要与可观察对象结合的异步操作。

解决方案

任何异步操作都可以被视为执行以下两种操作之一的可观察流:

  • 生成单个元素然后完成

  • 未产生任何元素的故障

要实现这种转换,System.Reactive 库可以简单地将Task<T>转换为IObservable<T>。以下代码开始异步下载网页,并将其视为可观察序列:

IObservable<HttpResponseMessage> GetPage(HttpClient client)
{
  Task<HttpResponseMessage> task =
      client.GetAsync("http://www.example.com/");
  return task.ToObservable();
}

ToObservable方法假定您已经调用了async方法并有一个Task可以转换。

另一种方法是调用StartAsyncStartAsync也会立即调用async方法,但支持取消:如果取消订阅,则会取消async方法:

IObservable<HttpResponseMessage> GetPage(HttpClient client)
{
  return Observable.StartAsync(
      token => client.GetAsync("http://www.example.com/", token));
}

ToObservableStartAsync立即启动异步操作,无需等待订阅;可观察对象是“热的”。要创建一个“冷”的可观察对象,仅在订阅时开始操作,请使用FromAsync(它也支持像StartAsync一样的取消):

IObservable<HttpResponseMessage> GetPage(HttpClient client)
{
  return Observable.FromAsync(
      token => client.GetAsync("http://www.example.com/", token));
}

FromAsyncToObservableStartAsync显著不同,后两者返回已经开始的async操作的可观察对象。FromAsync每次订阅时启动一个新的独立async操作。

最后,您可以使用SelectMany的特殊重载来为源流中的每个事件启动异步操作。SelectMany也支持取消。

以下示例获取现有的 URL 事件流,然后在每个 URL 到达时初始化请求:

IObservable<HttpResponseMessage> GetPages(
    IObservable<string> urls, HttpClient client)
{
  return urls.SelectMany(
      (url, token) => client.GetAsync(url, token));
}

讨论

System.Reactive 在引入async之前就已存在,但添加了这些操作符(及其他操作符),以便与async代码良好地互操作。建议您使用描述的操作符,即使您可以使用其他 System.Reactive 操作符构建相同的功能。

参见

8.5 节介绍了如何使用异步代码消耗可观察流。

8.8 节涵盖了使用数据流块(可能包含异步代码)作为可观察流源的方法。

8.7 异步流与数据流网格

问题

您的解决方案的一部分使用了异步流,另一部分使用了数据流网格,并且需要在它们之间传递数据。

解决方案

将通道作为异步流来消耗是内置在通道类型中的;有关详细信息,请参见 9.8 节。将 TPL Dataflow 块作为异步流消耗则稍微复杂一些,但当然是可行的。我发现最简单的方法是首先为数据流块定义一个扩展方法,使其 API 更类似于通道,然后使用该扩展方法将它们作为异步流消耗:

public static class DataflowExtensions
{
  public static bool TryReceiveItem<T>(this ISourceBlock<T> block, out T value)
  {
    if (block is IReceivableSourceBlock<T> receivableSourceBlock)
      return receivableSourceBlock.TryReceive(out value);

    try
    {
      value = block.Receive(TimeSpan.Zero);
      return true;
    }
    catch (TimeoutException)
    {
      // There is no item available right now.
      value = default;
      return false;
    }
    catch (InvalidOperationException)
    {
      // The block is complete and there are no more items.
      value = default;
      return false;
    }
  }

  public static async IAsyncEnumerable<T> ReceiveAllAsync<T>(
      this ISourceBlock<T> block,
 [EnumeratorCancellation] CancellationToken cancellationToken = default)
  {
    while (await block
        .OutputAvailableAsync(cancellationToken).ConfigureAwait(false))
    {
      while (block.TryReceiveItem(out var value))
      {
        yield return value;
      }
    }
  }
}

详细内容请参见第 3.4 节,关于EnumeratorCancellation属性的详细信息。

使用前面代码示例中的扩展方法,可以将任何输出数据流块消耗为异步流:

var multiplyBlock = new TransformBlock<int, int>(value => value * 2);

multiplyBlock.Post(5);
multiplyBlock.Post(2);
multiplyBlock.Complete();

await foreach (int item in multiplyBlock.ReceiveAllAsync())
{
  Console.WriteLine(item);
}

还可以将异步流用作数据流块的项目来源。您只需循环获取项目并将其放入块中。以下代码中有几个假设可能不适用于每种场景。首先,代码假设您希望在流完成时完成块。其次,它始终在其调用线程上运行;某些场景可能希望始终在线程池线程上运行整个循环:

public static async Task WriteToBlockAsync<T>(
    this IAsyncEnumerable<T> enumerable,
    ITargetBlock<T> block, CancellationToken token = default)
{
  try
  {
    await foreach (var item in enumerable
        .WithCancellation(token).ConfigureAwait(false))
    {
      await block.SendAsync(item, token).ConfigureAwait(false);
    }

    block.Complete();
  }
  catch (Exception ex)
  {
    block.Fault(ex);
  }
}

讨论

此处的扩展方法旨在作为一个起点。特别是,WriteToBlockAsync扩展方法确实做了一些假设;在使用之前,请务必考虑这些方法的行为,并确保它们在您的场景中的行为是适当的。

查看也可参考

第 9.8 节介绍了如何将通道作为异步流进行消耗。

第 3.4 节介绍了取消异步流的相关内容。

第五章介绍了 TPL Dataflow 的相关技巧。

第三章介绍了异步流的相关技巧。

8.8 System.Reactive 可观察对象和数据流网格

问题

您的解决方案的一部分使用了 System.Reactive 的可观察对象,另一部分使用了数据流网格,您需要它们进行通信。

System.Reactive 的可观察对象和数据流网格各自具有自己的用途,部分概念重叠;此处演示了它们如何轻松地协同工作,以便您可以在作业的每个部分使用最佳工具。

解决方案

首先,让我们考虑将数据流块用作可观察流的输入。以下代码创建了一个缓冲块(不进行任何处理),并通过调用AsObservable从该块创建了一个可观察接口:

var buffer = new BufferBlock<int>();
IObservable<int> integers = buffer.AsObservable();
integers.Subscribe(data => Trace.WriteLine(data),
    ex => Trace.WriteLine(ex),
    () => Trace.WriteLine("Done"));

buffer.Post(13);

缓冲块和可观察流可以正常完成或出现错误,而AsObservable方法将块的完成(或故障)转换为可观察流的完成。但是,如果块因异常而故障,则在传递给可观察流时该异常将被包装在AggregateException中。这类似于链接块传播它们的故障的方式。

将网格视为可观察流的目的地只是稍微复杂了一点。以下代码调用AsObserver以使块能够订阅可观察流:

IObservable<DateTimeOffset> ticks =
    Observable.Interval(TimeSpan.FromSeconds(1))
        .Timestamp()
        .Select(x => x.Timestamp)
        .Take(5);

var display = new ActionBlock<DateTimeOffset>(x => Trace.WriteLine(x));
ticks.Subscribe(display.AsObserver());

try
{
  display.Completion.Wait();
  Trace.WriteLine("Done.");
}
catch (Exception ex)
{
  Trace.WriteLine(ex);
}

与之前一样,可观察流的完成被转换为块的完成,而可观察流的任何错误被转换为块的故障。

讨论

数据流块和可观察流在概念上有很多共同之处。它们都通过它们传递数据,并且都理解完成和故障。它们设计用于不同的场景;TPL 数据流适用于异步和并行编程的混合,而 System.Reactive 则适用于反应式编程。然而,概念上的重叠足够兼容,它们能够非常自然地很好地协同工作。

参见

Recipe 8.5 讲解了如何使用异步代码消耗可观察流。

Recipe 8.6 讲解了如何在可观察流中使用异步代码。

8.9 将 System.Reactive 可观察对象转换为异步流

问题

您的解决方案的一部分使用了 System.Reactive 的可观察对象,并且希望将它们作为异步流消耗。

解决方案

System.Reactive 的可观察对象是推送型的,而异步流是拉取型的。因此,一开始就需要意识到这种概念上的不匹配。您需要一种方法来保持对可观察流的响应性,存储其通知直到消费代码请求它们。

最简单的解决方案已经包含在 System.Linq.Async 库中:

IObservable<long> observable =
    Observable.Interval(TimeSpan.FromSeconds(1));

// WARNING: May consume unbounded memory; see discussion!
IAsyncEnumerable<long> enumerable =
    observable.ToAsyncEnumerable();
提示

ToAsyncEnumerable 扩展方法位于 System.Linq.Async NuGet 包中。

然而,需要注意的是,这个简单的 ToAsyncEnumerable 扩展方法在内部使用了一个无界的生产者/消费者队列。本质上,这与您可以自己编写的使用通道作为无界生产者/消费者队列的扩展方法相同:

// WARNING: May consume unbounded memory; see discussion!
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(
    this IObservable<T> observable)
{
  Channel<T> buffer = Channel.CreateUnbounded<T>();
  using (observable.Subscribe(
      value => buffer.Writer.TryWrite(value),
      error => buffer.Writer.Complete(error),
      () => buffer.Writer.Complete()))
  {
    await foreach (T item in buffer.Reader.ReadAllAsync())
      yield return item;
  }
}

这些都是简单的解决方案,但它们使用了无界队列,因此只有在消费者能够(最终)跟上可观察事件时才应使用它们。如果生产者在一段时间内运行得比消费者快,是可以接受的;在此期间,可观察事件进入缓冲区。只要生产者最终赶上,前述解决方案就会起作用。但是,如果生产者始终比消费者运行得快,可观察事件将继续到达,扩展缓冲区,并最终耗尽进程的所有内存。

您可以通过使用有界队列来避免内存问题。其中的折衷是,如果可观察事件填满队列,您必须决定如何处理额外的项。一种选择是丢弃额外的项;以下示例代码使用有界通道,在缓冲区满时丢弃最旧的可观察通知:

// WARNING: May discard items; see discussion!
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(
    this IObservable<T> observable, int bufferSize)
{
  var bufferOptions = new BoundedChannelOptions(bufferSize)
  {
    FullMode = BoundedChannelFullMode.DropOldest,
  };
  Channel<T> buffer = Channel.CreateBounded<T>(bufferOptions);
  using (observable.Subscribe(
      value => buffer.Writer.TryWrite(value),
      error => buffer.Writer.Complete(error),
      () => buffer.Writer.Complete()))
  {
    await foreach (T item in buffer.Reader.ReadAllAsync())
      yield return item;
  }
}

讨论

当您的生产者运行速度快于消费者时,您有两个选择:要么缓冲生产者项目(假设生产者最终能够赶上),要么限制生产者的项目数量。本食谱的第二种解决方案通过丢弃不适合缓冲区的项目来限制生产者的项目。您还可以通过使用专为此设计的可观察操作符,如 ThrottleSample 来限制生产者的项目;详细信息请参见 食谱 6.4。根据您的需求,在将输入可观察对象转换为 IAsyncEnumerable<T> 之前,最好使用 ThrottleSample 技术中的一种来限制输入可观察对象。

除了有界队列和无界队列,还有第三个选项未在此处介绍:使用背压来通知可观察流,在缓冲区准备好接收通知之前必须停止生成通知。不幸的是,截至撰写本文时,System.Reactive 尚未标准化背压模式,因此这不是一个可行的选项。背压是复杂而微妙的,其他语言的响应式库已经实现了不同的背压模式。尚不清楚 System.Reactive 是否会采纳其中一种,发明自己的背压模式,还是干脆放弃解决背压的问题。

另请参阅

食谱 6.4 介绍了用于节流输入的 System.Reactive 操作符。

食谱 9.8 介绍了如何使用 Channel 作为无限制的生产者/消费者队列。

食谱 9.10 介绍了使用 Channel 作为采样队列,在其满时丢弃项目。

第九章:集合

在并发应用程序中,使用适当的集合是至关重要的。我不是在谈论像List<T>这样的标准集合;我假设你已经了解了这些。本章的目的是介绍专门用于并发或异步使用的新集合。

不可变集合 是永远不会改变的集合实例。乍一看,这听起来完全没用;但实际上它们非常有用,即使在单线程、非并发应用程序中也是如此。只读操作(例如枚举)直接作用于不可变实例。写操作(例如添加项目)返回一个新的不可变实例,而不是更改现有实例。这并不像听起来的那么浪费,因为大多数情况下,不可变集合共享大部分内存。此外,不可变集合具有隐式安全的优势,可以从多个线程安全访问;因为它们不能改变,所以它们是线程安全的。

小贴士

不可变集合位于System.Collections.Immutable NuGet 包中。

不可变集合是新的,但在新开发中应考虑使用它们,除非你需要一个可变实例。如果你不熟悉不可变集合,我建议你从 Recipe 9.1 开始,即使你不需要栈或队列,因为我将覆盖所有不可变集合遵循的几种常见模式。

有特殊的方法可以更有效地构建具有大量现有元素的不可变集合;这些示例代码仅逐个添加元素。如果需要加快初始化速度,MSDN 文档详细介绍了如何高效构建不可变集合。

线程安全集合

这些可变集合实例可以同时被多个线程修改。线程安全的集合使用细粒度锁和无锁技术的混合方式,以确保线程被阻塞的时间最少(通常根本不会被阻塞)。对于许多线程安全集合,枚举集合会创建集合的快照,然后枚举该快照。线程安全集合的关键优势在于,它们可以安全地从多个线程访问,但操作只会在很短的时间内(如果有的话)阻塞你的代码。

生产者/消费者集合

这些可变集合实例被设计用于特定目的:允许(可能多个)生产者向集合推送项目,同时允许(可能多个)消费者从集合中取出项目。因此,它们充当生产者代码和消费者代码之间的桥梁,同时还具有限制集合中项目数量的选项。生产者/消费者集合可以具有阻塞或异步 API。例如,当集合为空时,阻塞生产者/消费者集合将阻塞调用的消费者线程,直到添加另一个项目;但异步生产者/消费者集合将允许调用的消费者线程异步等待直到添加另一个项目。

本章节中使用了许多不同的生产者/消费者集合,不同的生产者/消费者集合具有不同的优势。查看 表 9-1 可以帮助确定您应该使用哪一个。

表 9-1. 生产者/消费者集合

特性ChannelsBlockingCollectionBufferBlockAsyncProducer-ConsumerQueueAsyncCollection
队列语义
堆栈/袋子语义
同步 API
异步 API
当满时丢弃项目
由 Microsoft 测试
提示

Channels 可在 System.Threading.Channels NuGet 包中找到,BufferBlock<T>System.Threading.Tasks.Dataflow NuGet 包中,AsyncProducerConsumerQueue<T>AsyncCollection<T>Nito.AsyncEx NuGet 包中。

9.1 不可变堆栈和队列

问题

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

例如,队列可以用作执行操作的序列,堆栈可以用作撤销操作的序列。

解决方案

不可变堆栈和队列是最简单的不可变集合。它们的行为与标准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

请注意,在示例中我们不断重写本地变量 stack。不可变集合遵循一种模式,它们返回一个更新的集合;原始集合引用不会改变。这意味着一旦您获得对特定不可变集合实例的引用,它将永远不会改变。考虑以下示例:

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的内存。这种实现方式非常高效,同时可以轻松快速地获取当前状态的快照。每个不可变集合实例本身天然线程安全,但不可变集合也可以在单线程应用程序中使用。在我看来,不可变集合在代码更具功能性或需要存储大量快照且希望尽可能共享内存时尤为有用。

队列类似于栈,但是它们是先进先出的数据结构。以下代码创建了一个空的不可变队列,入队两个项,枚举这些项,然后出队一个项:

ImmutableQueue<int> queue = ImmutableQueue<int>.Empty;
queue = queue.Enqueue(13);
queue = queue.Enqueue(7);

// Displays "13" followed by "7".
foreach (int item in queue)
  Trace.WriteLine(item);

int nextItem;
queue = queue.Dequeue(out nextItem);
// Displays "13".
Trace.WriteLine(nextItem);

讨论

这个菜谱介绍了两种最简单的不可变集合,栈和队列。还涵盖了几个对所有不可变集合都适用的重要设计理念:

  • 不可变集合的实例永远不会改变。

  • 由于它永远不会改变,因此天然线程安全。

  • 当您对不可变集合调用修改方法时,会返回一个新的修改后的集合。

警告

即使不可变集合是线程安全的,对不可变集合的引用并不是线程安全的。指向不可变集合的变量需要与其他变量一样的同步保护(参见第十二章)。

不可变集合非常适合共享状态。然而,它们不适合作为通信通道。特别是不要使用不可变队列在线程间通信;生产者/消费者队列在这方面效果更佳。

小贴士

ImmutableStack<T>ImmutableQueue<T>可以在System.Collections.Immutable NuGet 包中找到。

另请参阅

菜谱 9.6 介绍了线程安全(阻塞式)可变队列。

菜谱 9.7 介绍了线程安全(阻塞式)可变栈。

菜谱 9.8 介绍了支持异步的可变队列。

菜谱 9.11 介绍了支持异步的可变栈。

菜谱 9.12 介绍了阻塞/异步可变队列。

9.2 不可变列表

问题

您需要一种可以进行索引而不经常变化且可以安全地被多个线程访问的数据结构。

解决方案

列表是一种通用的数据结构,可以用于各种应用状态。不可变列表允许索引,但需要注意性能特征。它们并不仅仅是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>之间存在性能差异(参见表 9-2)。

表 9-2. 不可变列表的性能差异

操作ListImmutableList
添加摊销 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),而不是您可能期望的 O(1)。如果在现有代码中用ImmutableList<T>替换List<T>,则需要考虑如何访问集合中的项。

这意味着在可能的情况下应使用foreach而不是for。在ImmutableList<T>上执行的foreach循环的时间复杂度为 O(N),而在相同集合上执行的for循环的时间复杂度为 O(N * log N):

// The best way to iterate over an ImmutableList<T>.
foreach (var item in list)
  Trace.WriteLine(item);

// This will also work, but it will be much slower.
for (int i = 0; i != list.Count; ++i)
  Trace.WriteLine(list[i]);

讨论

ImmutableList<T>是一个很好的通用数据结构,但由于其性能差异,您不能盲目地用它替换所有List<T>的用法。List<T>通常是默认使用的——除非需要不同的集合。ImmutableList<T>并不是如此普遍;您需要仔细考虑其他不可变集合,并选择最适合您情况的那个。

提示

ImmutableList<T>位于System.Collections.Immutable NuGet 包中。

参见

食谱 9.1 涵盖了不可变堆栈和队列,类似于只允许访问特定元素的列表。

MSDN 对ImmutableList<T>.Builder的文档介绍了一种有效的填充不可变列表的方法。

9.3 不可变集合

问题

您需要一个数据结构,不需要存储重复项,不经常更改,并且可以安全地由多个线程访问。

例如,文件中的单词索引是集合的一个好用例。

解决方案

有两种不可变集合类型: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-3)。

表 9-3. 不可变集合的性能

操作ImmutableHashSetImmutableSortedSet
添加O(log N)O(log N)
移除O(log N)O(log N)
项[索引]n/aO(log N)

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

关于排序集的一个重要说明是,其索引是 O(log N),而不是 O(1),就像 ImmutableList<T> 一样,它在 Recipe 9.2 中讨论过。这意味着在这种情况下应该尽可能使用 foreach 而不是 for 来处理 ImmutableSortedSet<T>

讨论

不可变集合是有用的数据结构,但是填充大型不可变集合可能会很慢。大多数不可变集合都有特殊的构建器,可以在可变方式下快速构建它们,然后将其转换为不可变集合。对许多不可变集合而言是如此,但我发现它们对于不可变集合特别有用。

小贴士

ImmutableHashSet<T>ImmutableSortedSet<T> 在 NuGet System.Collections.Immutable 包中。

参见

Recipe 9.7 讨论了线程安全的可变背包,它们类似于集合。

Recipe 9.11 讨论了与异步兼容的可变背包。

MSDN documentation on ImmutableHashSet<T>.Builder 讨论了填充不可变哈希集的高效方式。

MSDN documentation on ImmutableSortedSet<T>.Builder 讨论了填充不可变排序集的高效方式。

9.4 不可变字典

问题

您需要一个不经常更改且可以安全地被多个线程访问的键/值集合。例如,您可能希望在查找集合中存储参考数据,这些参考数据很少更改,但应该对不同线程可用。

解决方案

有两种不可变字典类型:ImmutableDictionary<TKey, TValue>ImmutableSortedDictionary<TKey, TValue>。从它们的名称可以猜出,ImmutableDictionary 中的项没有可预测的顺序,而 ImmutableSortedDictionary 确保其元素已排序。

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

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);

注意使用 SetItem。在可变字典中,您可以尝试像 dictionary[key] = item 这样做,但是不可变字典必须返回更新后的不可变字典,因此它们使用 SetItem 方法代替:

ImmutableSortedDictionary<int, string> sortedDictionary =
    ImmutableSortedDictionary<int, string>.Empty;
sortedDictionary = sortedDictionary.Add(10, "Ten");
sortedDictionary = sortedDictionary.Add(21, "Twenty-One");
sortedDictionary = sortedDictionary.SetItem(10, "Diez");

// Displays "10Diez" followed by "21Twenty-One".
foreach (KeyValuePair<int, string> item in sortedDictionary)
  Trace.WriteLine(item.Key + item.Value);

string ten = sortedDictionary[10];
// ten == "Diez"

sortedDictionary = sortedDictionary.Remove(21);

无序字典和有序字典有类似的性能,但我建议您使用无序字典,除非您需要元素排序(参见 Table 9-4)。总体而言,无序字典可能略快。此外,无序字典可用于任何键类型,而有序字典要求它们的键类型完全可比较。

表 9-4 不可变字典的性能

操作ImmutableDictionary<TKey, TV>ImmutableSortedDictionary<TKey, TV>
AddO(log N)O(log N)
SetItemO(log N)O(log N)
Item[key]O(log N)O(log N)
RemoveO(log N)O(log N)

讨论

在我的经验中,字典是处理应用程序状态时常见且有用的工具。它们可用于任何类型的键/值或查找场景。

与其他不可变集合一样,不可变字典具有用于高效构建的构建器机制,如果字典包含许多元素。例如,如果在启动时加载初始引用数据,则应使用构建器机制来构建初始的不可变字典。另一方面,如果您的引用数据在应用程序执行过程中逐渐构建,则使用常规的不可变字典 Add 方法可能是可接受的。

提示

ImmutableDictionary<TK, TV>ImmutableSortedDictionary<TK, TV> 包含在 System.Collections.Immutable NuGet 包中。

参见

第 9.5 节 涵盖了线程安全的可变字典。

MSDN 关于 ImmutableDictionary<TK,TV>.Builder 涵盖了填充不可变字典的高效方式。

MSDN 关于 ImmutableSortedDictionary<TK,TV>.Builder 涵盖了填充不可变排序字典的高效方式。

9.5 线程安全的字典

问题

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

解决方案

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

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

首先,让我们学习如何向集合写入值。要设置键的值,您可以使用 AddOrUpdate

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

AddOrUpdate 有点复杂,因为它必须根据并发字典的当前内容执行几项操作。第一个方法参数是键。第二个参数是一个委托,将键(在本例中为 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";

索引语法功能较弱;它不提供根据现有值更新值的能力。然而,语法更简单,如果你已经有了要存储在字典中的值,它可以正常工作。

查看如何读取值。这可以通过TryGetValue轻松完成:

// 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);

TryRemoveTryGetValue几乎相同(当然),只有在字典中找到键时才会删除键/值对。

讨论

尽管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.4 介绍了不可变字典,如果字典的内容变化非常少,则非常理想。

9.6 阻塞队列

问题

您需要一个传输介质,用于将消息或数据从一个线程传递到另一个线程。例如,一个线程可以加载数据,并在加载时将其推送到传输介质;同时,传输介质的接收端有其他线程接收并处理数据。

解决方案

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

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

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

通常,一个线程要么向集合添加项目要么从集合中移除项目,但不会两者兼而有之。添加项目的线程称为生产者线程,移除项目的线程称为消费者线程

生产者线程可以通过调用Add添加项目,并且当生产者线程完成时(即所有项目都已添加),可以通过调用CompleteAdding完成集合。这会通知集合不再添加项目,并且集合可以通知其消费者没有更多项目。

这是一个简单的生产者示例,添加两个项目,然后标记集合为完成:

_blockingQueue.Add(7);
_blockingQueue.Add(13);
_blockingQueue.CompleteAdding();

消费者线程通常在循环中运行,等待下一个项目然后处理它。如果将生产者代码放在单独的线程中(例如通过Task.Run),那么可以像这样消费这些项目:

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

如果您希望有多个消费者,可以同时从多个线程调用GetConsumingEnumerable。然而,每个项目只会传递给这些线程中的一个。当集合完成时,可枚举对象完成。

讨论

前面的示例都使用了GetConsumingEnumerable来作为消费者线程的一种常见情况。然而,也有一个Take成员允许消费者只消费单个项目而不是运行循环消费所有项目。

当您使用这样的传输介质时,需要考虑如果生产者运行得比消费者快会发生什么。如果您生成的项目比您消费它们的速度快,那么可能需要限制您的队列。

当您想要异步访问传输介质时,例如 UI 线程希望充当消费者时,阻塞队列非常适合(例如线程池线程)。配方 9.8 介绍了异步队列。

提示

每当您将这样的传输介质引入到您的应用程序中时,请考虑切换到 TPL Dataflow 库。大多数情况下,使用 TPL Dataflow 比构建自己的传输介质和后台线程更简单。

BufferBlock<T>来自 TPL Dataflow 可以像阻塞队列一样工作,TPL Dataflow 允许构建用于处理的管道或网格。然而,在许多更简单的情况下,像BlockingCollection<T>这样的普通阻塞队列是适当的设计选择。

您还可以使用AsyncEx库的AsyncProducerConsumerQueue<T>,它可以像阻塞队列一样工作。

参见

9.7 食谱 涵盖了阻塞栈和袋,如果您需要类似的传输通道而不需要先进先出语义。

9.8 食谱 涵盖了具有异步而不是阻塞 API 的队列。

9.12 食谱 涵盖了既有异步又有阻塞 API 的队列。

9.9 食谱 涵盖了限制其项目数量的队列。

9.7 阻塞栈和袋

问题

您需要一个传输通道来从一个线程传递消息或数据到另一个线程,但不希望(或不需要)这个通道具有先进先出语义。

解决方案

.NET 类型BlockingCollection<T>默认作为阻塞队列,但也可以像任何种类的生产者/消费者集合一样工作。实际上,它是围绕实现IProducerConsumerCollection<T>的线程安全集合的包装器。

所以,你可以创建一个具有后进先出(栈)语义或无序(袋)语义的BlockingCollection<T>

BlockingCollection<int> _blockingStack = new BlockingCollection<int>(
    new ConcurrentStack<int>());
BlockingCollection<int> _blockingBag = new BlockingCollection<int>(
    new ConcurrentBag<int>());

重要的是要记住,现在围绕项目排序存在竞争条件。如果让相同的生产者代码在任何消费者代码之前执行,然后在生产者代码之后执行消费者代码,则项目的顺序将完全像栈一样:

// Producer code
_blockingStack.Add(7);
_blockingStack.Add(13);
_blockingStack.CompleteAdding();

// Consumer code
// Displays "13" followed by "7".
foreach (int item in _blockingStack.GetConsumingEnumerable())
  Trace.WriteLine(item);

当生产者代码和消费者代码在不同的线程上(这是通常情况),消费者始终获取最近添加的项目。例如,生产者可以添加7,消费者可以取7,生产者可以添加13,消费者可以取13。消费者在返回第一个项目之前不会等待CompleteAdding的调用。

讨论

关于阻塞队列应用于限制内存使用的节流考虑与阻塞栈和袋相同。如果您的生产者运行得比消费者快,而且您需要限制阻塞栈/袋的内存使用,可以像 9.9 食谱中所示那样使用节流。

本篇介绍使用GetConsumingEnumerable作为消费者代码;这是最常见的场景。还有一个Take成员,允许消费者只消费单个项目,而不是运行循环消费所有项目。

如果您想异步访问共享的栈或袋而不是通过阻塞(例如,让您的 UI 线程充当消费者),请参阅 9.11 食谱。

参见

9.6 食谱 涵盖了阻塞队列,比阻塞栈或袋更常用。

9.11 食谱 涵盖了异步栈和袋。

9.8 异步队列

问题

你需要一种传递消息或数据的通道,以先进先出的方式从代码的一部分传递到另一部分,而不阻塞线程。

例如,一个代码片段可以正在加载数据,它在加载时将数据推送到通道中;同时,UI 线程正在接收数据并显示它。

解决方案

你需要的是一个具有异步 API 的队列。核心 .NET 框架中没有这样的类型,但可以从 NuGet 上找到几个选项。

第一种选项是使用 Channels。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);

这种更自然的消费者代码使用了异步流;更多信息请参阅第三章。截至本文撰写时,异步流仅适用于最新的 .NET 平台;旧平台可以使用以下模式:

// Consumer code (older platforms)
// Displays "7" followed by "13".
ChannelReader<int> reader = queue.Reader;
while (await reader.WaitToReadAsync())
  while (reader.TryRead(out int value))
    Trace.WriteLine(value);

注意旧平台消费者代码中的双重while循环;这是正常的。WaitToReadAsync会异步等待直到有可读取的项或通道已标记为完成;当有可读取的项时返回trueTryRead会尝试读取一个项(立即和同步),如果读取到项则返回true。如果TryRead返回false,这可能是因为当前没有可用项,或者可能是因为通道已标记为完成且以后不会再有更多项。因此,当TryRead返回false时,内部while循环退出,并且消费者再次调用WaitToReadAsync,如果通道已标记为完成,则返回false

另一种生产者/消费者队列选项是使用 TPL Dataflow 库中的BufferBlock<T>BufferBlock<T>与通道相似。以下示例显示了如何声明BufferBlock<T>,生产者代码的样子以及消费者代码的样子:

var _asyncQueue = new BufferBlock<int>();

// Producer code
await _asyncQueue.SendAsync(7);
await _asyncQueue.SendAsync(13);
_asyncQueue.Complete();

// Consumer code
// Displays "7" followed by "13".
while (await _asyncQueue.OutputAvailableAsync())
  Trace.WriteLine(await _asyncQueue.ReceiveAsync());

示例消费者代码使用了OutputAvailableAsync,如果只有一个消费者则确实很有用。如果有多个消费者,则可能OutputAvailableAsync会对多个消费者返回true,即使只有一个项。如果队列已完成,则ReceiveAsync将抛出InvalidOperationException。因此,如果有多个消费者,则消费者代码通常看起来更像是以下这样:

while (true)
{
  int item;
  try
  {
    item = await _asyncQueue.ReceiveAsync();
  }
  catch (InvalidOperationException)
  {
    break;
  }
  Trace.WriteLine(item);
}

您还可以使用Nito.AsyncEx NuGet 库中的AsyncProducerConsumerQueue<T>类型。其 API 与BufferBlock<T>类似但并非完全相同:

var _asyncQueue = new AsyncProducerConsumerQueue<int>();

// Producer code
await _asyncQueue.EnqueueAsync(7);
await _asyncQueue.EnqueueAsync(13);
_asyncQueue.CompleteAdding();

// Consumer code
// Displays "7" followed by "13".
while (await _asyncQueue.OutputAvailableAsync())
  Trace.WriteLine(await _asyncQueue.DequeueAsync());

这个消费者代码还使用了OutputAvailableAsync,并且和BufferBlock<T>一样存在相同的问题。如果有多个消费者,消费者代码通常看起来更像是以下这样:

while (true)
{
  int item;
  try
  {
    item = await _asyncQueue.DequeueAsync();
  }
  catch (InvalidOperationException)
  {
    break;
  }
  Trace.WriteLine(item);
}

讨论

我建议在可能的情况下尽量使用通道作为异步生产者/消费者队列。除了节流外,它们还具有多个抽样选项,并且高度优化。但是,如果您的应用逻辑可以表达为通过其流动数据的“管道”,那么 TPL Dataflow 可能是一个更自然的选择。最后的选择是AsyncProducerConsumerQueue<T>,如果您的应用已经使用了来自AsyncEx的其他类型,则可能会有意义。

小贴士

通道可以在System.Threading.Channels NuGet 包中找到。BufferBlock<T> 类型在System.Threading.Tasks.Dataflow NuGet 包中。AsyncProducerConsumerQueue<T> 类型在Nito.AsyncEx NuGet 包中。

参见

9.6 配方涵盖了具有阻塞语义而不是异步语义的生产者/消费者队列。

9.12 配方涵盖了既有阻塞又有异步语义的生产者/消费者队列。

9.7 配方涵盖了异步堆栈和包,如果您想要一个没有先入先出语义的类似通道。

9.9 节流队列

问题

您有一个生产者/消费者队列,而且您的生产者可能比消费者运行得更快,这将导致不必要的内存使用。您还想保留所有队列项,因此需要一种方法来限制生产者的速度。

解决方案

当您使用生产者/消费者队列时,除非您确信消费者总是更快,否则您确实需要考虑如果您的生产者比您的消费者跑得快会发生什么。如果您生产的速度快于消费速度,则可能需要对队列进行节流。您可以通过指定最大元素数量来节流队列。当队列“满”时,它会向生产者施加背压,阻塞它们直到队列有更多空间。

通道可以通过创建有界通道而不是无界通道来进行节流。由于通道是异步的,生产者将被异步地限制:

Channel<int> queue = Channel.CreateBounded<int>(1);
ChannelWriter<int> writer = queue.Writer;

// This Write completes immediately.
await writer.WriteAsync(7);

// This Write (asynchronously) waits for the 7 to be removed
// before it enqueues the 13.
await writer.WriteAsync(13);

writer.Complete();

BufferBlock<T> 内置支持节流,详细探讨请参阅 5.4 配方。使用数据流块时,您可以设置BoundedCapacity选项:

var queue = new BufferBlock<int>(
    new DataflowBlockOptions { BoundedCapacity = 1 });

// This Send completes immediately.
await queue.SendAsync(7);

// This Send (asynchronously) waits for the 7 to be removed
// before it enqueues the 13.
await queue.SendAsync(13);

queue.Complete();

上述代码片段中的生产者使用了异步的SendAsync API;同样的方法适用于同步的Post API。

AsyncEx 类型 AsyncProducerConsumerQueue<T> 支持节流。只需用适当的值构造队列:

var queue = new AsyncProducerConsumerQueue<int>(maxCount: 1);

// This Enqueue completes immediately.
await queue.EnqueueAsync(7);

// This Enqueue (asynchronously) waits for the 7 to be removed
// before it enqueues the 13.
await queue.EnqueueAsync(13);

queue.CompleteAdding();

阻塞生产者/消费者队列还支持节流。您可以使用BlockingCollection<T>在创建时传递适当的值来限制项目的数量:

var queue = new BlockingCollection<int>(boundedCapacity: 1);

// This Add completes immediately.
queue.Add(7);

// This Add waits for the 7 to be removed before it adds the 13.
queue.Add(13);

queue.CompleteAdding();

讨论

当生产者可能比消费者运行得更快时,节流是必要的。一个您必须考虑的场景是,如果您的应用程序运行在不同于您的硬件的环境中,生产者是否可能比消费者更快。通常需要一些节流以确保您的应用程序可以在未来的硬件和/或云实例上运行,这些硬件和实例通常比开发者的机器更受限制。

节流将对生产者施加反压力,使其放慢速度以确保消费者能够处理所有项目,而不会导致不必要的内存压力。如果您不需要处理每个项目,您可以选择采样而不是节流。请参见食谱 9.10 了解采样生产者/消费者队列的方法。

提示

通道位于System.Threading.Channels NuGet 包中。BufferBlock<T>类型位于System.Threading.Tasks.Dataflow NuGet 包中。AsyncProducerConsumerQueue<T>类型位于Nito.AsyncEx NuGet 包中。

另请参阅

食谱 9.8 介绍了基本的异步生产者/消费者队列使用方法。

食谱 9.6 介绍了基本的同步生产者/消费者队列使用方法。

食谱 9.10 介绍了采样生产者/消费者队列,作为节流的替代方法。

9.10 采样队列

问题

您有一个生产者/消费者队列,但您的生产者可能比您的消费者运行得更快,这导致了不必要的内存使用。您不需要保留所有队列项目;您需要一种方法来筛选队列项目,以便较慢的生产者只需要处理重要的项目。

解决方案

通道是应用输入项采样的最简单方法。一个常见的例子是始终获取最新的n个项目,在队列满时丢弃最旧的项目:

Channel<int> queue = Channel.CreateBounded<int>(
    new BoundedChannelOptions(1)
    {
      FullMode = BoundedChannelFullMode.DropOldest,
    });
ChannelWriter<int> writer = queue.Writer;

// This Write completes immediately.
await writer.WriteAsync(7);

// This Write also completes immediately.
// The 7 is discarded unless a consumer has already retrieved it.
await writer.WriteAsync(13);

这是一种简单的方法来控制输入流,防止其淹没消费者。

还有其他BoundedChannelFullMode选项。例如,如果您希望保留最旧的项目,您可以在通道满时丢弃任何新项目:

Channel<int> queue = Channel.CreateBounded<int>(
    new BoundedChannelOptions(1)
    {
      FullMode = BoundedChannelFullMode.DropWrite,
    });
ChannelWriter<int> writer = queue.Writer;

// This Write completes immediately.
await writer.WriteAsync(7);

// This Write also completes immediately.
// The 13 is discarded unless a consumer has already retrieved the 7.
await writer.WriteAsync(13);

讨论

通道非常适合进行简单的采样。在许多情况下特别有用的选项是BoundedChannelFullMode.DropOldest。更复杂的采样可能需要由消费者自行完成。

如果您需要进行基于时间的采样,例如“每秒只有 10 个项目”,请使用 System.Reactive。System.Reactive 具有与时间相关的自然操作符。

提示

通道位于System.Threading.Channels NuGet 包中。

另请参阅

食谱 9.9 介绍了限制通道流量的节流功能,通过阻塞生产者而不是丢弃项目来限制通道中的项目数量。

食谱 9.8 介绍了基本的通道使用,包括生产者和消费者代码。

菜谱 6.4 介绍了使用System.Reactive进行节流和采样,支持基于时间的采样。

9.11 异步堆栈和包

问题

您需要一个传输管道,将消息或数据从代码的一部分传递到另一部分,但您不希望(或不需要)该传输管道具有先进先出的语义。

解决方案

Nito.AsyncEx库提供了类型AsyncCollection<T>,默认情况下类似于异步队列,但也可以充当任何类型的生产者/消费者集合。围绕IProducerConsumerCollection<T>的包装器,AsyncCollection<T>也是.NET BlockingCollection<T>async等效项,该项在菜谱 9.7 中有所介绍。

AsyncCollection<T>支持后进先出(堆栈)或无序(包)语义,取决于您传递给其构造函数的集合类型:

var _asyncStack = new AsyncCollection<int>(
    new ConcurrentStack<int>());
var _asyncBag = new AsyncCollection<int>(
    new ConcurrentBag<int>());

请注意,在堆栈中项目顺序方面存在竞争条件。如果所有生产者在消费者开始之前完成,则项目的顺序类似于常规堆栈:

// Producer code
await _asyncStack.AddAsync(7);
await _asyncStack.AddAsync(13);
_asyncStack.CompleteAdding();

// Consumer code
// Displays "13" followed by "7".
while (await _asyncStack.OutputAvailableAsync())
  Trace.WriteLine(await _asyncStack.TakeAsync());

当生产者和消费者同时执行(这是通常情况),消费者总是会获取最近添加的项。这将导致整个集合的行为不完全像一个堆栈。当然,包集合根本没有排序。

AsyncCollection<T>支持节流,如果生产者可能比消费者更快地向集合中添加内容,则这是必需的。只需使用适当的值构造集合即可:

var _asyncStack = new AsyncCollection<int>(
    new ConcurrentStack<int>(), maxCount: 1);

现在相同的生产者代码将根据需要异步等待:

// This Add completes immediately.
await _asyncStack.AddAsync(7);

// This Add (asynchronously) waits for the 7 to be removed
// before it enqueues the 13.
await _asyncStack.AddAsync(13);

_asyncStack.CompleteAdding();

示例消费者代码使用了OutputAvailableAsync,其限制与菜谱 9.8 中描述的相同。如果有多个消费者,消费者代码通常看起来更像以下内容:

while (true)
{
  int item;
  try
  {
    item = await _asyncStack.TakeAsync();
  }
  catch (InvalidOperationException)
  {
    break;
  }
  Trace.WriteLine(item);
}

讨论

AsyncCollection<T>只是具有略有不同 API 的BlockingCollection<T>的异步等效项。

提示

AsyncCollection<T>类型位于Nito.AsyncEx NuGet 包中。

参见

菜谱 9.8 介绍了异步队列,比异步堆栈或包更为常见。

菜谱 9.7 介绍了同步(阻塞)堆栈和包。

9.12 阻塞/异步队列

问题

您需要一个传输管道,以先进先出的方式将消息或数据从代码的一部分传递到另一部分,并且需要灵活性,以将生产者端或消费者端视为同步或异步。

例如,后台线程可能正在加载数据并将其推送到传输管道中,如果传输管道太满,您希望后台线程同步阻塞。同时,UI 线程正在从传输管道接收数据,您希望 UI 线程异步从传输管道中拉取数据,以保持 UI 的响应性。

解决方案

在查看第 9.6 节中的阻塞队列和第 9.8 节中的异步队列后,现在我们将学习一些同时支持阻塞和异步 API 的队列类型。

第一个是 TPL Dataflow NuGet 库中的BufferBlock<T>ActionBlock<T>BufferBlock<T>可以很容易地用作异步生产者/消费者队列(详见第 9.8 节):

var queue = new BufferBlock<int>();

// Producer code
await queue.SendAsync(7);
await queue.SendAsync(13);
queue.Complete();

// Consumer code for a single consumer
while (await queue.OutputAvailableAsync())
  Trace.WriteLine(await queue.ReceiveAsync());

// Consumer code for multiple consumers
while (true)
{
  int item;
  try
  {
    item = await queue.ReceiveAsync();
  }
  catch (InvalidOperationException)
  {
    break;
  }

  Trace.WriteLine(item);
}

如您在以下示例中所见,BufferBlock<T>也支持生产者和消费者的同步 API:

var queue = new BufferBlock<int>();

// Producer code
queue.Post(7);
queue.Post(13);
queue.Complete();

// Consumer code
while (true)
{
  int item;
  try
  {
    item = queue.Receive();
  }
  catch (InvalidOperationException)
  {
    break;
  }

  Trace.WriteLine(item);
}

使用BufferBlock<T>的消费者代码相当笨拙,因为这不是编写代码的“数据流方式”。TPL Dataflow 库包括许多可以链接在一起的块,使您能够定义反应网格。在这种情况下,可以使用ActionBlock<T>定义完成特定操作的生产者/消费者队列:

// Consumer code is passed to queue constructor.
ActionBlock<int> queue = new ActionBlock<int>(item => Trace.WriteLine(item));

// Asynchronous producer code
await queue.SendAsync(7);
await queue.SendAsync(13);

// Synchronous producer code
queue.Post(7);
queue.Post(13);
queue.Complete();

如果在您期望的平台上 TPL Dataflow 库不可用,则Nito.AsyncEx中也有一个AsyncProducerConsumerQueue<T>类型,它还支持同步和异步方法:

var queue = new AsyncProducerConsumerQueue<int>();

// Asynchronous producer code
await queue.EnqueueAsync(7);
await queue.EnqueueAsync(13);

// Synchronous producer code
queue.Enqueue(7);
queue.Enqueue(13);

queue.CompleteAdding();

// Asynchronous single consumer code
while (await queue.OutputAvailableAsync())
  Trace.WriteLine(await queue.DequeueAsync());

// Asynchronous multi-consumer code
while (true)
{
  int item;
  try
  {
    item = await queue.DequeueAsync();
  }
  catch (InvalidOperationException)
  {
    break;
  }
  Trace.WriteLine(item);
}

// Synchronous consumer code
foreach (int item in queue.GetConsumingEnumerable())
  Trace.WriteLine(item);

讨论

如果可能的话,我建议使用BufferBlock<T>ActionBlock<T>,因为 TPL Dataflow 库经过的测试比Nito.AsyncEx库更加全面。然而,如果您的应用程序已经使用了AsyncEx库的其他类型,那么AsyncProducerConsumerQueue<T>可能也会很有用。

也可以间接地使用System.Threading.Channels进行同步操作。它们的自然 API 是异步的,但由于它们是线程安全的集合,您可以通过将生产或消费代码包装在Task.Run中,然后阻塞Task.Run返回的任务来强制它们同步工作,就像这样:

Channel<int> queue = Channel.CreateBounded<int>(10);

// Producer code
ChannelWriter<int> writer = queue.Writer;
Task.Run(async () =>
{
  await writer.WriteAsync(7);
  await writer.WriteAsync(13);
  writer.Complete();
}).GetAwaiter().GetResult();

// Consumer code
ChannelReader<int> reader = queue.Reader;
Task.Run(async () =>
{
  while (await reader.WaitToReadAsync())
    while (reader.TryRead(out int value))
      Trace.WriteLine(value);
}).GetAwaiter().GetResult();

TPL Dataflow 块,AsyncProducerConsumerQueue<T>和 Channels 都支持通过在构造过程中传递选项来进行节流。当生产者推送项目比消费者消耗它们更快时,节流是必需的,这可能会导致您的应用程序占用大量内存。

小贴士

BufferBlock<T>ActionBlock<T>类型位于System.Threading.Tasks.Dataflow NuGet 包中。AsyncProducerConsumerQueue<T>类型位于Nito.AsyncEx NuGet 包中。Channels 位于System.Threading.Channels NuGet 包中。

另请参阅

第 9.6 节介绍了阻塞生产者/消费者队列。

第 9.8 节介绍了异步生产者/消费者队列。

第 5.4 节介绍了数据流块的节流。