温故之.NET TPL 数据流

这篇文章将介绍如何使用 TPL 数据流库中的数据流组件来处理多个组件之间的通信,以及如何在数据可用时处理数据。
它通过向粗粒度的数据流和管道任务提供 进程内消息传递来促进基于角色的编程

包括以下内容

  • 源和目标
  • 块之间的连接
  • 消息筛选
  • 消息传递
  • 数据流块完成
  • 预定义数据流块
  • 配置数据流块行为

小提示

  • TPL 数据流库(System.Threading.Tasks.Dataflow 命名空间)不随 .NET 一起分发
  • Visual Studio 中可使用 NuGet 来安装该库
  • .Net Core 中,需要运行 dotnet add package System.Threading.Tasks.Dataflow

TPL 数据流库为应用程序(具有高吞吐量和低延迟需求,且需要占用大量 CPUI/O 操作的应用程序)的并行化和消息传递提供了一种理想的实现方式
通过它,我们可以控制数据如何在系统各个组件之间流动;同时,我们也可以决定如何缓存这些数据

举例如下
从硬盘加载两张图片,然后将这两张图片合成为一张图片

  • 传统编程模型下,通常需要使用回调和同步对象(例如锁)来协调各个任务以及如何去访问共享数据
  • 而在 数据流编程模型下,我们可以在从磁盘读取图像时,就创建处理图像的数据流对象。在数据流模型下,我们声明好以下两点即可:
    1. 数据之间的依赖关系
    2. 当数据可用时的处理方式
    这些我们后面会举例说明

因为运行时会帮助我们管理数据之间的依赖关系,且其任务调度不需要阻塞,所以数据流可以通过有效管理基础线程,来提高应用程序或服务的响应能力和吞吐量

源和目标

TPL 数据流库包括数据流块,它是缓冲并处理数据的数据结构。 TPL 定义了三种数据流块:源块(source blocks)、目标块(target blocks)和传播器块(propagator blocks)。

  • 源块作为数据源,用于读取从源传过来的数据,即数据接收。以 ISourceBlock<TOutput> 接口来表示
  • 目标块作为数据接收方,用于写入需要向目标传递的数据,即发送数据。以 ITargetBlock<TInput> 接口来表示
  • 传播器块作为源块和目标块,可以读取和写入。以 IPropagatorBlock<TInput,TOutput> 接口来表示

块之间的连接

我们可以通过连接数据流块形成管道。这样在当数据可用时,我们就可以通过管道异步传播数据

我们可以通过 ISourceBlock<TOutput>.LinkTo 方法将源数据流块链接到目标块

源和目标是多对多的关系:即一个源可以链接到多个目标块;一个目标块可以有多个源

消息筛选

在通过 ISourceBlock<TOutput>.LinkTo 将目标链接到源之后,我们可以根据消息的值来决定目标块是否处理该消息

对于大多数预定义的数据流块类型,如果源块链接到多个目标块,那么当其中一个目标块拒绝消息时,源将向下一个目标提供该消息。

源向目标提供消息的顺序是由源定义的,可以根据源类型的不同而不同。 一个目标接受消息后,大多数源块类型会停止提供该消息。

BroadcastBlock<T> 类除外,无论目标是否拒绝消息,它都会向所有目标提供每一条消息

如果在一个源连接多个目标时使用筛选机制,那我们应该确保至少一个目标块能够接收每一条消息。否则,我们的应用程序可能发生死锁

消息传递

如果需要在应用各个模块间传播消息

  • 我们可以调用 ITargetBlock<TInput>.PostITargetBlock<TInput>.SendAsync 方法,向目标块发送消息
  • 调用 ISourceBlock<TOutput>.ReceiveISourceBlock<TOutput>.ReceiveAsyncISourceBlock<TOutput>.TryReceive 方法来接收源块发送的消息

源块通过 ITargetBlock<TInput>.OfferMessage 向目标提供数据,目标可以接受消息、拒绝消息或延迟接收消息

  • 目标接受消息:OfferMessage 方法会返回 Accepted
  • 目标拒绝消息:OfferMessage 方法会返回 Declined。当目标不想再接收来自源的任何消息时,我们可以让 OfferMessage 返回 DecliningPermanently
  • 目标延迟接收消息:OfferMessage 方法会返回 Postponed

数据流块完成

完成状态的数据流块不会再继续处理其他的工作。每个数据流块都有相关的 Task 对象来表示数据流块的完成状态,这样我们就可以像操作 Task 一样,来对数据流块的完成状态进行监控。完成状态属性定义如下

public interface IDataflowBlock {
    // 可以看到,它返回的是 Task 对象
    Task Completion { get; }

    void Complete();
    void Fault(Exception exception);
}
复制代码

因此,我们可以通过以下两种方式来等待完成

// 方式一
IDataflowBlock block;
try {
    block.Completion.Wait();
} catch (AggregateException ae) {
    // 处理异常
}

// 方式二
block.Completion.ContinueWith(task => {
    // 此处可以通过 task.Status 或 task 其他的一些属性来确定数据流块完成之后的状态
    Console.WriteLine($"状态:{task.Status}");
});
复制代码

如果使用方式一,个人有以下建议

应该使用 try-catch 来将 block.Completion.Wait() 包装起来,通过捕获异常,我们就能知道结束的原因。
比如当我们显式取消数据流块时,OperationCanceledException 异常就会引发,只不过它不是单独引发的,而是包含在AggregateException 对象的 InnerExceptions 属性中

如果不需要获取结束的原因,个人更推荐使用方式二。毕竟要简洁很多

预定义数据流块

TPL 数据流库提供了多个预定义的数据流块类型。分为三个类别:缓冲块(buffering blocks)执行块(execution blocks)分组块(grouping blocks)。数据流块的命名空间为 System.Threading.Tasks.Dataflow,以下类型均位于这个命名空间内

  • 缓冲块:BufferBlock<T>BroadcastBlock<T>WriteOnceBlock<T>
  • 执行块:ActionBlock<TInput>TransformBlock<TInput,TOutput>TransformManyBlock<TInput,TOutput>
  • 分组块:BatchBlock<T>JoinBlock<T1,T2>BatchedJoinBlock<T1,T2>

如果这些预定义类型无法满足需求,我们可以通过以下方式来扩展

  • 实现 ISourceBlock<TOutput>ITargetBlock<TInput> 接口,自行处理内部逻辑
  • 使用 DataflowBlock.Encapsulate(ITargetBlock<TInput>, ISourceBlock<TOutput>) 方法来封装现有数据流块类型

缓冲块

缓冲块存放的数据供数据使用者使用。它包括 BufferBlock<T>BroadcastBlock<T>WriteOnceBlock<T> 三种类型

BufferBlock<T>

其表示一般用途的异步消息结构。它维护了一个先进先出 (FIFO) 的消息队列,该队列可由多个源写入或者从多个目标读取

在目标收到来自 BufferBlock<T> 的消息时,将从消息队列中删除此消息。因此,虽然一个 BufferBlock<T> 对象可以具有多个目标,但只有一个目标将接收每条消息(因为第一个接收到之后,该消息就被删除了,其他人就无福消受了)。

因此,它主要用于以下场景

两个组件之间,需要将多条消息传递给另一个组件,且该组件必须接收每条消息时

示例代码如下

var bufferBlock = new BufferBlock<int>();
// 发送 4 条消息
Parallel.For(0, 4, i => {
    bufferBlock.Post(i);
});

// 接收消息
// 推荐使用 TryReceive,因为它不会阻止当前线程
// 因此,在需要偶尔检查消息时很有用,比如我们偶尔检查我们银行卡的余额。。
while (bufferBlock.TryReceive(out int value)) {
    Console.WriteLine(value);
}

Console.ReadLine();
复制代码

如果我们希望使用异步的方式来处理,那我们调用以 Async 结尾的方法即可。比如 await bufferBlock.SendAsyncawait bufferBlock.ReceiveAsync 等等

BroadcastBlock<T>

该类用于以下场景

  • 需向多个组件广播消息时
  • 需要将多条消息传递给另一个组件,该组件只需要最新的值的时候

WriteOnceBlock<T>

WriteOnceBlock<T> 对象仅可被写入一次。在目标收到来自 WriteOnceBlock<T> 对象的消息时,不会从该目标删除此消息。如果有多个目标,则其他目标将接收到该消息的副本

因此,其使用场景为——需要仅仅传播很多条消息中的第一条时。在实际项目中,至今我都还没有用过这个。

执行块

执行块就是处理接收到的每条数据。包括

  • ActionBlock<TInput>:可以将它看成收到数据时异步执行的操作【ActionBlock 示例】
  • TransformBlock<TInput,TOutput>: 通过某种方式将收到的数据,转换成另外一种数据,这样目标收到的数据就是转换过后的数据。比如有人给我发了一条消息,但我想我收到的数据全是大写字母形式的,此时就可以采用这种方式,将收到的数据转换成大写字母,这样,我收到的就是大写字母形式的,可以参考【TransformBlock 示例】
  • TransformManyBlock<TInput,TOutput>:与TransformBlock不同,它为每一个输入生成零个或多个输出,而不是为每个输入仅生成一个输出。这在需要将收到的数据拆分成多个处理结果的情形下是很有用的【TransformManyBlock 示例】

默认情况下,ActionBlockTransformBlockTransformManyBlock 都会缓冲输入消息,直到目标块处理它们

ActionBlock 示例

var actionBlock = new ActionBlock<string>(msg => {
   // 用于模拟收到消息时执行的操作
    Console.WriteLine(msg);
});

// 发送消息
actionBlock.Post("This is an action msg");

// 这个不能少,需要用它来阻塞当前线程,等待消息处理完成
Console.ReadLine();
// 也可以使用以下方式来等待完成
actionBlock.Complete();
actionBlock.Completion.Wait();
复制代码

TransformBlock 示例

// 注意构造函数里面的代码 msg => msg.ToUpper()
// 它用于将消息转换成大写
var transformBlock = new TransformBlock<string, string>(msg => msg.ToUpper());
// 发送 hello 消息
transformBlock.Post("hello");
// 读取消息
Console.WriteLine(transformBlock.Receive());
复制代码

输出结果如下

HELLO
复制代码

看,是不是很神奇

TransformManyBlock 示例

var transformManyBlock = new TransformManyBlock<string, char>(s => s.ToCharArray());
// 发送 hello 消息
transformManyBlock.Post("hello");
while (true) {
    Console.WriteLine(transformManyBlock.Receive());
}
复制代码

输出如下

h
e
l
l
o
复制代码

因此,TransformManyBlock 一般用于需要将收到的数据拆分成多个处理结果的情形下。比如在实际项目中,记录日志组件,收到其他组件发送过来的日志包时。打包的日志包括 DebugInfoWarningError 等日志,这个时候我们可以将它们归类,以 Dictionary<LogType, string> 的方式给写日志的组件。

分组块

分组块用于在一定的约束条件下,合并一个或多个源的数据,分组块包括以下类型

  • BatchBlock<T>:将一系列输入数据合并到一个数组中,供目标块使用。与前面的 TransformManyBlock 有点反着来的感觉
    它可运行于贪婪模式和非贪婪模式,默认为贪婪模式
    1. 贪婪模式下,BatchBlock<T> 对象接受它提供的每条消息,并在接收指定数量的元素后传播数组
    2. 在非贪婪模式下,BatchBlock<T> 对象推迟所有传入的消息,直到足够的源给块提供消息来形成 batch 为止
    3. 贪婪模式处理开销较少,故通常比非贪婪模式更高效。见【BatchBlock 示例】
  • JoinBlock<T1,T2>:它用于将从多个 Target 发送的数据转换成元组以共目标使用。见【JoinBlock 示例】
  • BatchedJoinBlock<T1,T2>:它用于收集各个batch的元素,然后以元组的方式提供给目标。可以认为,它就是前两个的组合体。因此,就不举例了

BatchBlock 示例

using System;
using System.Linq;
using System.Threading.Tasks;
using System.Threading.Tasks.Dataflow;

namespace App {
    class Program {
        static void Main(string[] args) {
            var batchBlock = new BatchBlock<int>(10);

            // 发送模拟数据
            // 这儿有 13 个数据
            // 由于 BatchBlock 每一个 batch 的大小是 10,因此,这 13 个数据会被分成两个 batch 发送给目标
            for (int i = 0; i < 20; i++) {
                batchBlock.Post(i);
            }
            // 以下代码用于把剩下的数据装入 batch 中发送给目标(以下注释以 batchSize = 10 为基础)
            // 1. 如果数据量可以被 10 整除,比如 20,30 等。这时候可以不用调用这个方法。因为它的数据刚好够 2 个batch
            // 2. 如果数据流不能被 10 整出,比如 13,32 等。这时候必须要调用这个方法,否则最后那个 batch 会因为数据不够 10 个而等待
            batchBlock.Complete();

            Console.WriteLine("Batch 1");
            var batch1 = batchBlock.Receive();
            foreach (var item in batch1) {
                Console.Write($"{item} ");
            }

            Console.WriteLine("\r\nBatch 2");
            var batch2 = batchBlock.Receive();
            foreach (var item in batch2) {
                Console.Write($"{item} ");
            }
        }
    }
}
复制代码

输出如下

Batch 1
0 1 2 3 4 5 6 7 8 9
Batch 2
10 11 12 13 14 15 16 17 18 19
复制代码

JoinBlock 示例

var joinBlock = new JoinBlock<int, int, int>();
            
joinBlock.Target1.Post(3);
joinBlock.Target2.Post(5);
joinBlock.Target3.Post(7);

var data = joinBlock.Receive();
Console.WriteLine($"收到数据 {data}");
复制代码

输出如下

收到数据 (3, 5, 7)
复制代码

配置数据流块行为

即通过 DataflowBlockOptions 来管理这些基础任务。DataflowBlockOptions 类是 ExecutionDataflowBlockOptionsGroupingDataflowBlockOptions 的基类

ExecutionDataflowBlockOptions

通过设置 MaxDegreeOfParallelism 属性,为执行块提供了并行处理消息的能力。

  1. 默认值为 1,保证数据流块一次处理一条消息
  2. 设置为大于 1 的值:数据流块可以同时处理多条消息
  3. 设置为 -1:则会使用最大的并发数

GroupingDataflowBlockOptions.Greedy

可通过设置 Greedy 属性来设置分组块是否以贪婪模式运行

  • 默认情况下,预定义的数据流块类型在贪婪模式下运行
  • 需要将数据流块指定非贪婪模式时,将 Greedy 设置为 False 即可

DataflowBlockOptions.MaxMessagesPerTask

使用这个属性可以提高任务间的公平性,其值如下

  1. 默认值为 -1:数据流块使用的任务会处理尽可能多的消息
  2. 设置为 -1 以外的值时:数据流块为每个 Task 对象至多处理这个数量的消息。比如设置为 20,那么每个 Task 至多处理 20 条消息

虽然这个属性可以提高任务间的公平性,但它可能会导致该系统创建多个非必要的任务,从而降低性能

DataflowBlockOptions.CancellationToken

用于取消数据流块。当该对象设置为已取消状态时

  • 所有监视该令牌(Token)的数据流块都会完成当前项目的执行,但不会开始处理后续项
  • 清除所有缓冲的消息,释放所有源和目标块的连接,并转换为已取消状态

至此,这篇文章的内容讲解完毕。欢迎关注公众号【嘿嘿的学习日记】,所有的文章,都会在公众号首发,Thank you~

转载于:https://juejin.im/post/5b39a9bbf265da59aa2da3f3

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值