这篇文章将介绍如何使用 TPL
数据流库中的数据流组件来处理多个组件之间的通信,以及如何在数据可用时处理数据。
它通过向粗粒度的数据流和管道任务提供 进程内消息传递来促进基于角色的编程
包括以下内容
- 源和目标
- 块之间的连接
- 消息筛选
- 消息传递
- 数据流块完成
- 预定义数据流块
- 配置数据流块行为
小提示
TPL
数据流库(System.Threading.Tasks.Dataflow
命名空间)不随.NET
一起分发Visual Studio
中可使用NuGet
来安装该库- 在
.Net Core
中,需要运行dotnet add package System.Threading.Tasks.Dataflow
TPL
数据流库为应用程序(具有高吞吐量和低延迟需求,且需要占用大量 CPU
和 I/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>.Post
和ITargetBlock<TInput>.SendAsync
方法,向目标块发送消息 - 调用
ISourceBlock<TOutput>.Receive
、ISourceBlock<TOutput>.ReceiveAsync
和ISourceBlock<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.SendAsync
、await bufferBlock.ReceiveAsync
等等
BroadcastBlock<T>
该类用于以下场景
- 需向多个组件广播消息时
- 需要将多条消息传递给另一个组件,该组件只需要最新的值的时候
WriteOnceBlock<T>
WriteOnceBlock<T>
对象仅可被写入一次。在目标收到来自 WriteOnceBlock<T>
对象的消息时,不会从该目标删除此消息。如果有多个目标,则其他目标将接收到该消息的副本
因此,其使用场景为——需要仅仅传播很多条消息中的第一条时。在实际项目中,至今我都还没有用过这个。
执行块
执行块就是处理接收到的每条数据。包括
ActionBlock<TInput>
:可以将它看成收到数据时异步执行的操作【ActionBlock
示例】TransformBlock<TInput,TOutput>
: 通过某种方式将收到的数据,转换成另外一种数据,这样目标收到的数据就是转换过后的数据。比如有人给我发了一条消息,但我想我收到的数据全是大写字母形式的,此时就可以采用这种方式,将收到的数据转换成大写字母,这样,我收到的就是大写字母形式的,可以参考【TransformBlock
示例】TransformManyBlock<TInput,TOutput>
:与TransformBlock
不同,它为每一个输入生成零个或多个输出,而不是为每个输入仅生成一个输出。这在需要将收到的数据拆分成多个处理结果的情形下是很有用的【TransformManyBlock
示例】
默认情况下,ActionBlock
、TransformBlock
、TransformManyBlock
都会缓冲输入消息,直到目标块处理它们
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
一般用于需要将收到的数据拆分成多个处理结果的情形下。比如在实际项目中,记录日志组件,收到其他组件发送过来的日志包时。打包的日志包括 Debug
、Info
、Warning
、Error
等日志,这个时候我们可以将它们归类,以 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
类是 ExecutionDataflowBlockOptions
与 GroupingDataflowBlockOptions
的基类
ExecutionDataflowBlockOptions
通过设置 MaxDegreeOfParallelism
属性,为执行块提供了并行处理消息的能力。
- 默认值为 1,保证数据流块一次处理一条消息
- 设置为大于 1 的值:数据流块可以同时处理多条消息
- 设置为 -1:则会使用最大的并发数
GroupingDataflowBlockOptions.Greedy
可通过设置 Greedy
属性来设置分组块是否以贪婪模式运行
- 默认情况下,预定义的数据流块类型在贪婪模式下运行
- 需要将数据流块指定非贪婪模式时,将
Greedy
设置为False
即可
DataflowBlockOptions.MaxMessagesPerTask
使用这个属性可以提高任务间的公平性,其值如下
- 默认值为 -1:数据流块使用的任务会处理尽可能多的消息
- 设置为 -1 以外的值时:数据流块为每个 Task 对象至多处理这个数量的消息。比如设置为 20,那么每个 Task 至多处理 20 条消息
虽然这个属性可以提高任务间的公平性,但它可能会导致该系统创建多个非必要的任务,从而降低性能
DataflowBlockOptions.CancellationToken
用于取消数据流块。当该对象设置为已取消状态时
- 所有监视该令牌(Token)的数据流块都会完成当前项目的执行,但不会开始处理后续项
- 清除所有缓冲的消息,释放所有源和目标块的连接,并转换为已取消状态
至此,这篇文章的内容讲解完毕。欢迎关注公众号【嘿嘿的学习日记】,所有的文章,都会在公众号首发,Thank you~