使用并行在 C# 中进行文件搜索优化

File search optimisation in C# using Parallel

我有一个文件夹,里面有很多 CSV 文件,每个大约 3MB。

一个 CSV 的内容示例:

afkla890sdfa9f8sadfkljsdfjas98sdf098,-1dskjdl4kjff;
afkla890sdfa9f8sadfkljsdfjas98sdf099,-1kskjd11kjsj;
afkla890sdfa9f8sadfkljsdfjas98sdf100,-1asfjdl1kjgf;
etc...

现在我有一个用 C# 编写的控制台应用程序,它在每个 CSV 文件中搜索特定字符串。 那些要搜索的字符串在一个 txt 文件中。

搜索 txt 文件的示例:

-1gnmjdl5dghs
-17kn3mskjfj4
-1plo3nds3ddd

然后我调用方法在给定文件夹的所有文件中搜索每个搜索字符串:

private static object _lockObject = new object();
public static IEnumerable<string> SearchContentListInFiles(string searchFolder, List<string> searchList)
{
    var result = new List<string>();

    var files = Directory.EnumerateFiles(searchFolder);
    Parallel.ForEach(files, (file) =>
    {
        var fileContent = File.ReadLines(file);

        if (fileContent.Any(x => searchList.Any(y => x.ToLower().Contains(y))))
        {
            lock (_lockObject)
            {
                foreach (string searchFound in fileContent.Where(x => searchList.Any(y => x.ToLower().Contains(y))))
                {
                    result.Add(searchFound);
                }
            }
        }
    });

    return result;
}

现在的问题是,我能否以任何方式提高此操作的性能? 我有大约 100GB 的文件要搜索。 在 SSD 磁盘和良好的 i7 CPU.

上,使用大约 25 个搜索字符串搜索所有 ~30.000 个文件大约需要 1 小时

拥有更大的 CSV 文件或拥有更小的 CSV 文件会有不同吗?我只是希望这个搜索尽可能快。

更新

我已经尝试了你写的每一个建议,现在这对我来说是最好的(从 LINQ 中删除 ToLower 产生了最好的性能提升。从 1 小时开始的搜索时间现在是 16 分钟!):

public static IEnumerable<string> SearchContentListInFiles(string searchFolder, HashSet<string> searchList)
{
    var result = new BlockingCollection<string>();

    var files = Directory.EnumerateFiles(searchFolder);
    Parallel.ForEach(files, (file) =>
    {
        var fileContent = File.ReadLines(file); //.Select(x => x.ToLower());

        if (fileContent.Any(x => searchList.Any(y => x.Contains(y))))
        {
            foreach (string searchFound in fileContent.Where(x => searchList.Any(y => x.Contains(y))))
            {
                result.Add(searchFound);
            }
        }
    });

    return result;
}

此操作首先是磁盘绑定。磁盘绑定操作不会从多线程中受益。事实上,你要做的就是同时用大量冲突请求淹没磁盘控制器,像 NCQ 这样的功能必须再次被删除。

如果您先将所有文件加载到内存中,您的操作将是内存绑定。并且内存绑定操作也不会从多线程中受益(通常;它会在此处详细介绍 CPU 和内存架构)。

虽然一定数量的多任务在编程中是强制性的,但真正的多线程仅有助于CPU 绑定操作。那里看起来没有任何内容 CPU 绑定。所以多线程taht搜索(每个文件一个线程)不会让它更快。由于所有线程切换和同步开销,确实可能会使它变慢。

可能 Lucene 之类的东西可以提高性能:为什么不索引数据以便轻松搜索?

看看Lucene .NET

您将避免按顺序搜索数据。此外,您可以基于相同的数据对多个索引进行建模,以便能够以光速获得某些结果。

尝试:

  1. 对一行执行 .ToLower 一次,而不是对 searchList 中的每个元素执行 .ToLower。

  2. 对文件进行一次扫描,而不是通过任意位置扫描两次。获取列表,如果找到则添加锁。在您的示例中,您在搜索和添加时浪费了两次传递的时间并阻塞了所有线程。

  3. 如果您知道要查找的位置(在您的示例中您知道),您可以从位置扫描,而不是在所有字符串中扫描

  4. 使用生产者消费者模式例如使用:BlockingCollection<T>,所以不需要使用锁

  5. 如果需要在字段中严格搜索,构建searchList的HashSet并执行searchHash.Contains(fieldValue)这将显着增加进程

所以这里有一个示例(未测试):

using(var searcher = new FilesSearcher(
    searchFolder: "path", 
    searchList: toLookFor))
{
    searcher.SearchContentListInFiles();
}

这里是搜索者:

public class FilesSearcher : IDisposable
{
    private readonly BlockingCollection<string[]> filesInMemory;
    private readonly string searchFolder;
    private readonly string[] searchList;

    public FilesSearcher(string searchFolder, string[] searchList)
    {
        // reader thread stores lines here
        this.filesInMemory = new BlockingCollection<string[]>(
            // limit count of files stored in memory, so if processing threads not so fast, reader will take a break and wait
            boundedCapacity: 100);

        this.searchFolder = searchFolder;
        this.searchList = searchList;
    }

    public IEnumerable<string> SearchContentListInFiles()
    {

        // start read,
        // we not need many threads here, probably 1 thread by 1 storage device is the optimum
        var filesReaderTask = Task.Factory.StartNew(ReadFiles, TaskCreationOptions.LongRunning);

        // at least one proccessing thread, because reader thread is IO bound
        var taskCount = Math.Max(1, Environment.ProcessorCount - 1);

        // start search threads
        var tasks = Enumerable
            .Range(0, taskCount)
            .Select(x => Task<string[]>.Factory.StartNew(Search, TaskCreationOptions.LongRunning))
            .ToArray();

        // await for results
        Task.WaitAll(tasks);

        // combine results
        return tasks
            .SelectMany(t => t.Result)
            .ToArray();
    }

    private string[] Search()
    {
        // if you always get unique results use list
        var results = new List<string>();
        //var results = new HashSet<string>();

        foreach (var content in this.filesInMemory.GetConsumingEnumerable())
        {
            // one pass by a file
            var currentFileMatches = content
                .Where(sourceLine =>
                {
                    // to lower one time for a line, and we don't need to make lowerd copy of file
                    var lower = sourceLine.ToLower();

                    return this.searchList.Any(sourceLine.Contains);
                });

            // store current file matches
            foreach (var currentMatch in currentFileMatches)
            {
                results.Add(currentMatch);
            }                
        }

        return results.ToArray();
    }

    private void ReadFiles()
    {
        var files = Directory.EnumerateFiles(this.searchFolder);

        try
        {
            foreach (var file in files)
            {
                var fileContent = File.ReadLines(file);

                // add file, or wait if filesInMemory are full
                this.filesInMemory.Add(fileContent.ToArray());
            }
        }
        finally
        {
            this.filesInMemory.CompleteAdding();
        }
    }

    public void Dispose()
    {
        if (filesInMemory != null)
            filesInMemory.Dispose();
    }
}