[英]How do I split and merge this dataflow pipeline?
我正在尝试使用具有以下形式的 tpl 创建数据流:
-> LoadDataBlock1 -> ProcessDataBlock1 ->
GetInputPathsBlock -> LoadDataBlock2 -> ProcessDataBlock2 -> MergeDataBlock -> SaveDataBlock
-> LoadDataBlock3 -> ProcessDataBlock3 ->
...
-> LoadDataBlockN -> ProcessDataBlockN ->
这个想法是, GetInputPathsBlock
是一个块,它找到要加载的输入数据的路径,然后将路径发送到每个LoadDataBlock
。 LoadDataBlocks 都是相同的(除了它们每个都从 GetInputPaths 接收到一个唯一的 inputPath 字符串)。 然后将加载的数据发送到ProcessDataBlock
,它会进行一些简单的处理。 然后来自每个ProcessDataBlock
的数据被发送到MergeDataBlock
,MergeDataBlock 合并它并将其发送到SaveDataBlock
,然后将其保存到文件中。
将其视为需要每个月运行的数据流。 首先为每天的数据找到路径。 每天的数据都被加载和处理,然后在整个月中合并在一起并保存。 每个月都可以并行运行,一个月中每一天的数据可以并行加载并并行处理(在单个日期数据加载完成后),当月的所有内容都加载并处理完毕后,可以合并并保存.
我试过的
据我所知TransformManyBlock<TInput,string>
可用于进行拆分( GetInputPathsBlock
),并且可以链接到普通的TransformBlock<string,InputData>
( LoadDataBlock
),然后从那里链接到另一个TransformBlock<InputData,ProcessedData>
( ProcessDataBlock
),但我不知道如何将其合并回单个块。
我看过的
我找到了这个答案,它使用TransformManyBlock
到 go 从IEnumerable<item>
到item
,但我不完全理解它,我无法将TransformBlock<InputData,ProcessedData>
( ProcessDataBlock
)链接到TransformBlock<IEnumerable<ProcessedData>>,ProcessedData>
,所以不知道怎么用。
我也看到过这样的答案,它建议使用JoinBlock
,但是输入文件的数量 N 不同,并且文件都以相同的方式加载。
还有这个答案,它似乎做了我想要的,但我不完全理解它,我不知道如何将字典的设置转移到我的案例中。
如何拆分和合并我的数据流?
TransformManyBlock
两次吗? 我会使用嵌套块来避免拆分我的每月数据,然后再次合并它们。 以下是处理 2020 年所有日子的两个嵌套TransformBlock
的示例:
var monthlyBlock = new TransformBlock<int, List<string>>(async (month) =>
{
var dailyBlock = new TransformBlock<int, string>(async (day) =>
{
await Task.Delay(100); // Simulate async work
return day.ToString();
}, new ExecutionDataflowBlockOptions() { MaxDegreeOfParallelism = 4 });
foreach (var day in Enumerable.Range(1, DateTime.DaysInMonth(2020, month)))
await dailyBlock.SendAsync(day);
dailyBlock.Complete();
var dailyResults = await dailyBlock.ToListAsync();
return dailyResults;
}, new ExecutionDataflowBlockOptions() { MaxDegreeOfParallelism = 1 });
foreach (var month in Enumerable.Range(1, 12))
await monthlyBlock.SendAsync(month);
monthlyBlock.Complete();
为了收集内部块的每日结果,我使用了扩展方法ToListAsync
,如下所示:
public static async Task<List<T>> ToListAsync<T>(this IReceivableSourceBlock<T> block,
CancellationToken cancellationToken = default)
{
var list = new List<T>();
while (await block.OutputAvailableAsync(cancellationToken).ConfigureAwait(false))
{
while (block.TryReceive(out var item))
{
list.Add(item);
}
}
await block.Completion.ConfigureAwait(false); // Propagate possible exception
return list;
}
您的问题的答案是:不,您不需要其他块类型,是的,您可以使用 TransformManyBlock 两次,是的,它确实有意义。 我写了一些代码来证明它,它在底部,还有一些关于它如何工作的注释,在这之后。
正如您所描述的,该代码使用拆分然后合并管道。 至于您正在努力解决的问题:可以通过将已处理的项目添加到列表中来完成将各个文件的数据合并在一起。 然后,如果它具有预期的最终项目数,我们只将列表传递到下一个块。 这可以通过返回零或一项的相当简单的 TransformMany 块来完成。 此块无法并行化,因为该列表不是线程安全的。
一旦你有了这样的管道,你就可以通过使用传递给块的选项来测试并行化和排序。 下面的代码将每个块的并行化设置为无界,并让 DataFlow 代码对其进行排序。 在我的机器上,它最大化了所有内核/逻辑处理器,并且受 CPU 限制,这正是我们想要的。 排序已启用,但关闭它并没有太大区别:同样,我们受 CPU 限制。
最后,我不得不说这是一项非常酷的技术,但您实际上可以更简单地使用 PLINQ 解决这个问题,只需几行代码即可获得同样快的结果。 最大的缺点是,如果这样做,您将无法轻松地将快速到达的消息逐步添加到管道中:PLINQ 更适合一个大批量进程。 但是,对于您的用例,PLINQ 可能是更好的解决方案。
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks.Dataflow;
namespace ParallelDataFlow
{
class Program
{
static void Main(string[] args)
{
new Program().Run();
Console.ReadLine();
}
private void Run()
{
Stopwatch s = new Stopwatch();
s.Start();
// Can experiment with parallelization of blocks by changing MaxDegreeOfParallelism
var options = new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = DataflowBlockOptions.Unbounded };
var getInputPathsBlock = new TransformManyBlock<(int, int), WorkItem>(date => GetWorkItemWithInputPath(date), options);
var loadDataBlock = new TransformBlock<WorkItem, WorkItem>(workItem => LoadDataIntoWorkItem(workItem), options);
var processDataBlock = new TransformBlock<WorkItem, WorkItem>(workItem => ProcessDataForWorkItem(workItem), options);
var waitForProcessedDataBlock = new TransformManyBlock<WorkItem, List<WorkItem>>(workItem => WaitForWorkItems(workItem)); // Can't parallelize this block
var mergeDataBlock = new TransformBlock<List<WorkItem>, List<WorkItem>>(list => MergeWorkItemData(list), options);
var saveDataBlock = new ActionBlock<List<WorkItem>>(list => SaveWorkItemData(list), options);
var linkOptions = new DataflowLinkOptions { PropagateCompletion = true };
getInputPathsBlock.LinkTo(loadDataBlock, linkOptions);
loadDataBlock.LinkTo(processDataBlock, linkOptions);
processDataBlock.LinkTo(waitForProcessedDataBlock, linkOptions);
waitForProcessedDataBlock.LinkTo(mergeDataBlock, linkOptions);
mergeDataBlock.LinkTo(saveDataBlock, linkOptions);
// We post individual tuples of (year, month) to our pipeline, as many as we want
getInputPathsBlock.Post((1903, 2)); // Post one month and date
var dates = from y in Enumerable.Range(2015, 5) from m in Enumerable.Range(1, 12) select (y, m);
foreach (var date in dates) getInputPathsBlock.Post(date); // Post a big sequence
getInputPathsBlock.Complete();
saveDataBlock.Completion.Wait();
s.Stop();
Console.WriteLine($"Completed in {s.ElapsedMilliseconds}ms on {ThreadAndTime()}");
}
private IEnumerable<WorkItem> GetWorkItemWithInputPath((int year, int month) date)
{
List<WorkItem> processedWorkItems = new List<WorkItem>(); // Will store merged results
return GetInputPaths(date.year, date.month).Select(
path => new WorkItem
{
Year = date.year,
Month = date.month,
FilePath = path,
ProcessedWorkItems = processedWorkItems
});
}
// Get filepaths of form e.g. Files/20191101.txt These aren't real files, they just show how it could work.
private IEnumerable<string> GetInputPaths(int year, int month) =>
Enumerable.Range(0, GetNumberOfFiles(year, month)).Select(i => $@"Files/{year}{Pad(month)}{Pad(i + 1)}.txt");
private int GetNumberOfFiles(int year, int month) => DateTime.DaysInMonth(year, month);
private WorkItem LoadDataIntoWorkItem(WorkItem workItem) {
workItem.RawData = LoadData(workItem.FilePath);
return workItem;
}
// Simulate loading by just concatenating to path: in real code this could open a real file and return the contents
private string LoadData(string path) => "This is content from file " + path;
private WorkItem ProcessDataForWorkItem(WorkItem workItem)
{
workItem.ProcessedData = ProcessData(workItem.RawData);
return workItem;
}
private string ProcessData(string contents)
{
Thread.SpinWait(11000000); // Use 11,000,000 for ~50ms on Windows .NET Framework. 1,100,000 on Windows .NET Core.
return $"Results of processing file with contents '{contents}' on {ThreadAndTime()}";
}
// Adds a processed WorkItem to its ProcessedWorkItems list. Then checks if the list has as many processed WorkItems as we
// expect to see overall. If so the list is returned to the next block, if not we return an empty array, which passes nothing on.
// This isn't threadsafe for the list, so has to be called with MaxDegreeOfParallelization = 1
private IEnumerable<List<WorkItem>> WaitForWorkItems(WorkItem workItem)
{
List<WorkItem> itemList = workItem.ProcessedWorkItems;
itemList.Add(workItem);
return itemList.Count == GetNumberOfFiles(workItem.Year, workItem.Month) ? new[] { itemList } : new List<WorkItem>[0];
}
private List<WorkItem> MergeWorkItemData(List<WorkItem> processedWorkItems)
{
string finalContents = "";
foreach (WorkItem workItem in processedWorkItems)
{
finalContents = MergeData(finalContents, workItem.ProcessedData);
}
// Should really create a new data structure and return that, but let's cheat a bit
processedWorkItems[0].MergedData = finalContents;
return processedWorkItems;
}
// Just concatenate the output strings, separated by newlines, to merge our data
private string MergeData(string output1, string output2) => output1 != "" ? output1 + "\n" + output2 : output2;
private void SaveWorkItemData(List<WorkItem> workItems)
{
WorkItem result = workItems[0];
SaveData(result.MergedData, result.Year, result.Month);
// Code to show it's worked...
Console.WriteLine($"Saved data block for {DateToString((result.Year, result.Month))} on {ThreadAndTime()}." +
$" File contents:\n{result.MergedData}\n");
}
private void SaveData(string finalContents, int year, int month)
{
// Actually save, although don't really need to in this test code
new DirectoryInfo("Results").Create();
File.WriteAllText(Path.Combine("Results", $"results{year}{Pad(month)}.txt"), finalContents);
}
// Helper methods
private string DateToString((int year, int month) date) => date.year + Pad(date.month);
private string Pad(int number) => number < 10 ? "0" + number : number.ToString();
private string ThreadAndTime() => $"thread {Pad(Thread.CurrentThread.ManagedThreadId)} at {DateTime.Now.ToString("hh:mm:ss.fff")}";
}
public class WorkItem
{
public int Year { get; set; }
public int Month { get; set; }
public string FilePath { get; set; }
public string RawData { get; set; }
public string ProcessedData { get; set; }
public List<WorkItem> ProcessedWorkItems { get; set; }
public string MergedData { get; set; }
}
}
此代码将 WorkItem object 从每个块传递到下一个块,并在每个阶段对其进行丰富。 然后它会创建一个包含一个月内所有 WorkItems 的最终列表,然后对其运行聚合过程并保存结果。
此代码基于使用您使用的名称的每个阶段的虚拟方法。 这些并没有做太多,但希望能证明解决方案。 例如,LoadData 被传递一个文件路径,只是向其中添加一些文本并传递字符串,但显然它可以加载一个真实文件并传递内容字符串,如果磁盘上确实有一个文件。
与在 ProcessData 中模拟工作类似,我们执行 Thread.SpinWait,然后再次向字符串添加一些文本。 这就是延迟的来源,因此如果您希望它运行得更快或更慢,请更改数字。 该代码是在 .NET 框架上编写的,但它在 Core 3.0 以及 Ubuntu 和 OSX 上运行。 唯一的区别是 SpinWait 周期可以明显更长或更短,因此您可能希望使用延迟。
请注意,我们可以在 waitForProcessedDataBlock 中合并,并拥有您所要求的管道。 只是会更混乱一些
该代码最终会在磁盘上创建文件,但也会将结果转储到屏幕上,因此它实际上并不需要。
如果您将并行化设置为 1,您会发现它会减慢您预期的速度。 我的 Windows 机器是四核的,它比慢四倍略差。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.