【问题标题】:Running a CPU-intensive method inside multiple tasks only using a small portion of CPU?仅使用一小部分 CPU 在多个任务中运行 CPU 密集型方法?
【发布时间】:2022-08-06 19:28:44
【问题描述】:

我正在运行一个具有 24 个线程 (5900X) 的 CPU,启动 20 个任务来执行一个完全受 CPU 限制的操作,但 CPU 负载最高达到 10%。试图看看是否有人可以阐明这是否是我误解了任务如何线程化,或者执行处理的库(HtmlAgilityPack)是否有问题?

这是一个有点复杂的例子:

public async static Task TestHtmlAgilityPack(bool loadHtml = true)
{
    // \"basePath\" is a folder has approx 20 folders each containing approx 3000 files (20 tasks * 3,000 files = 60k overall)
    var dirs = Directory.GetDirectories(basePath);
    List<Task> tasks = new();
    var strs = new ConcurrentBag<string>();
    foreach (var dir in dirs)
    {
        tasks.Add(Task.Run(() =>
        {
            foreach (var file in Directory.GetFiles(dir, \"*.html\")) // Each of the 20 tasks processes approx 3000 files
            {
                var html = File.ReadAllText(file);
                strs.Add(html.Substring(1, 1000));
                if (loadHtml)
                {
                    var doc = new HtmlDocument();
                    doc.LoadHtml(html);
                }
            }
        }));
    }
    await Task.WhenAll(tasks);
    Console.WriteLine(strs.Last());
}

如果我在没有 LoadHtml 的情况下运行它,它会在 15 秒内完成,所以 IO 访问时间是微不足道的。使用 LoadHtml 现在需要 20 分钟,我知道将 HTML 解析为可查询的表单需要时间,这很好/预期,但令人困惑的是它(应该?)是一个纯粹的 CPU 密集型操作,它\'不等待任何东西。为什么 CPU 在 10% 处达到峰值,而不是使用接近 80% 的值,因为在 24 线程 CPU 上通过 CPU 密集型操作加载 20 个线程?

这是否表明 LoadHtml 方法或其他方法效率低下?

  • 您没有使用“ASYNC”,因此每个任务都会阻塞,直到任务完成。
  • @jdweng 不在哪里使用异步? 20 个任务中的每一个都应该在自己的线程上执行一个长时间的 CPU 密集型操作(处理 3000 个文件)。我不明白为什么他们会在跑步过程中互相阻挡,只有在我等待他们全部完成时才结束,这就是我想要的?
  • 任务不会自动异步运行。任务是单独的线程,但在下一个线程启动之前运行完成。请参阅以下内容:stackoverflow.com/questions/20304258/…
  • @HansPassant 谢谢!!我的google-fu一定很弱,没有发现这个问题。不确定此更改的全部后果是什么,但我将垃圾收集器从默认设置切换到服务器,现在它的速度提高了 10-15 倍。我每天运行的线程化 HTML 分析任务过去需要 37 分钟,现在需要 3 分钟,我希望在问这个问题时能提高一点速度,但没想到会接近这个!再次感谢

标签: c# multithreading task-parallel-library html-agility-pack


【解决方案1】:

这段代码有几个问题限制了它的可扩展性。

  • 它在同一任务中执行 IO 和 CPU 工作,而不使用异步方法。您可以同时执行的 CPU 密集型任务的数量受内核数量的限制。您可以执行比这更多的异步任务。
  • IO 是阻塞的,不是异步的。这意味着任务(或者更确切地说它的线程)在等待操作系统检索数据时什么也不做。
  • 代码读取了太多数据,生成了太多临时对象。 ReadAllText 在只需要 1000 个字符时读取整个文件。字符串是不可变的,所以html.Substring(1,1000) 生成一个新的子串。所有这些都会占用内存,并且必须在某个时候进行垃圾收集。
  • ConcurrentBag 不是像 ConcurrentQueue 或 ConcurrentDictionary 这样的通用并发集合。它使用线程本地存储来确保创建项目的线程可以比其他线程更快地检索它。

.NET 提供了几个高级类,可用于构建解析管道,这比加载、解析和导入文件要复杂得多。这些包括Dataflow blocksChannelsAsync Streams/IAsyncEnumerable

改进问题代码的一种方法是使用 Dataflow 块来枚举根文件夹,加载文件内容并将其解析为具有不同并行度的不同块。

首先,可以将爬取、加载和解析代码提取到单独的方法中:

record Search(DirectoryInfo root,string pattern);
record Sample(FileInfo file,string sample);
record SampleHtml(FileInfo file,HtmlDocument html);

IEnumerable<FileInfo> Crawl(Search search)
{
    var (root,pattern)=search;
    var searchOptions=new EnumerationOptions { 
        RecurseSubdirectories=true,
        IgnoreInaccessible=true
    };
    return root.EnumerateFiles(pattern,searchOptions);
}

async Task<Sample> ReadSample(FileInfo file, int length)
{
    var buffer=new char[1001];
    using var reader=file.OpenText();
    await reader.ReadAsync(buffer,0,1001);
    var count=await reader.ReadBlockAsync(buffer,0,1000);
    var length=Math.Min(count,1000);
    var html= new String(buffer,1,length);
    return new Sample(file,html);
}

SampleHtml ParseSample(Sample sample)
{
    var html=new HtmlDocument();
    html.LoadHtml(sample.sample);
    return new SampleHtml(sample.file,html);
}

数据流块可用于创建以下管道:

  1. 单线程文件搜索块
  2. 加载程序块一次加载 2 个文件
  3. 解析器块一次解析 4 个样本
  4. 一个结果BufferBlock收集解析器的输出
    var loadOptions=new ExecutionDataflowBlockOptions{ 
        MaxDegreeOfParallelism=2,
        BoundedCapacity=1
    };
    var parseOptions=new ExecutionDataflowBlockOptions{ 
        MaxDegreeOfParallelism=4,
        BoundedCapacity=1
    };
    
    var crawler=new TransformManyBlock<Search,FileInfo>(search=>
        Crawl(search);
    
    var loader =  new TransformBlock<FileInfo,Sample>(file=> 
        ReadSample(file),loadOptions);
    
    var parserBlock=new TransformBlock<Sample,SampleHtml>(sample=>
        ParseHtml(sample),parseOptions);
    
    var results=new BufferBlock<SampleHtml>();
    
    var linkOptions=new DataflowLinkOptions {
        PropagateCompletion = true
    };
    crawler.LinkTo(loader,linkOptions);
    loader.LinkTo(parser,linkOptions);
    //Don't propagate completion, we just cache results here
    parser.Linkto(results);
    

    要使用管道,我们将搜索规范发布到头块crawler 并等待直到最后一个块解析器完成所有处理

    var root=new DirectoryInfo(path_to_root);
    var pattern="*.html";
    await crawler.SendAsync(new Search(root,pattern));
    crawler.Complete();
    await parser.Completion;
    

    A this point results 包含所有结果。我们可以使用TryReceive 一个一个地弹出项目或TryReceiveAll 将所有内容读入容器:

    if(results.TryReceiveAll(out var docs)
    {
        var last=docs[^1];
    }
    

    loaderparser 块的 BoundedCapacity 为 1。这意味着它们的输入缓冲区将只接受正在处理的项目之外的单个项目。在发布新项目之前,任何上游块都必须等待,一直到爬虫。这可以防止无法足够快地处理的对象填充内存。

    重用缓冲区

    ArrayPool 类可以提供可重用的缓冲区,从而避免为每个文件创建一个新的char[1001] 缓冲区。加载器 DOP 为 4,这意味着我们只需要 4 个缓冲区而不是 3000 个缓冲区:

    async Task<Sample> ReadSample(FileInfo file, int length)
    {
        var buffer=ArrayPool<char>.Shared.Rent(1001);
        try
        {
            using var reader=file.OpenText();
            await reader.ReadAsync(buffer,0,1001);
            var count=await reader.ReadBlockAsync(buffer,0,1000);
            var length=Math.Min(count,1000);
            var html= new String(buffer,1,length);
            return new Sample(file,html);
        }
        finally
        {
            ArrayPool<char>.Shared.Return(buffer);
        }
    }
    

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2020-06-16
    • 2020-10-11
    • 1970-01-01
    • 1970-01-01
    • 2022-01-18
    • 2017-04-10
    相关资源
    最近更新 更多