有比这更快的方法来查找目录及其子目录中的所有文件吗?

44

我正在编写一个程序,需要搜索一个目录及其所有子目录中具有特定扩展名的文件。这将在本地和网络驱动器上使用,因此性能是有点问题的。

这是我现在正在使用的递归方法:

private void GetFileList(string fileSearchPattern, string rootFolderPath, List<FileInfo> files)
{
    DirectoryInfo di = new DirectoryInfo(rootFolderPath);

    FileInfo[] fiArr = di.GetFiles(fileSearchPattern, SearchOption.TopDirectoryOnly);
    files.AddRange(fiArr);

    DirectoryInfo[] diArr = di.GetDirectories();

    foreach (DirectoryInfo info in diArr)
    {
        GetFileList(fileSearchPattern, info.FullName, files);
    }
}
我可以将SearchOption设置为AllDirectories,而不使用递归方法,但将来我想插入一些代码来通知用户当前正在扫描哪个文件夹。
虽然我现在正在创建FileInfo对象的列表,但我真正关心的只是文件的路径。我将有一个现有的文件列表,我想将其与新的文件列表进行比较,以查看添加或删除了哪些文件。是否有更快的方法来生成此文件路径列表?在查询共享网络驱动器上的文件时,有什么可以优化的地方吗?
更新1:
我尝试创建一个非递归方法,通过首先查找所有子目录,然后迭代地扫描每个目录中的文件来执行相同的操作。这是该方法:
public static List<FileInfo> GetFileList(string fileSearchPattern, string rootFolderPath)
{
    DirectoryInfo rootDir = new DirectoryInfo(rootFolderPath);

    List<DirectoryInfo> dirList = new List<DirectoryInfo>(rootDir.GetDirectories("*", SearchOption.AllDirectories));
    dirList.Add(rootDir);

    List<FileInfo> fileList = new List<FileInfo>();

    foreach (DirectoryInfo dir in dirList)
    {
        fileList.AddRange(dir.GetFiles(fileSearchPattern, SearchOption.TopDirectoryOnly));
    }

    return fileList;
}

更新 2

好的,我在一个本地和一个远程文件夹上进行了一些测试,两个文件夹都有很多文件(约1200个)。以下是我运行测试的方法。结果如下。

  • GetFileListA():上面更新中的非递归解决方案。我认为它等同于Jay的解决方案。
  • GetFileListB():原问题中的递归方法。
  • GetFileListC():使用静态Directory.GetDirectories()方法获取所有目录。然后使用静态Directory.GetFiles()方法获取所有文件路径。填充并返回一个列表。
  • GetFileListD():Marc Gravell的解决方案,使用队列并返回IEnumerable。我使用生成的IEnumerable填充了一个列表。
    • DirectoryInfo.GetFiles:未创建任何其他方法。从根文件夹路径实例化了一个DirectoryInfo。使用SearchOption.AllDirectories调用了GetFiles
  • Directory.GetFiles:未创建任何其他方法。使用SearchOption.AllDirectories调用了 Directory 的静态GetFiles 方法。
Method                       Local Folder       Remote Folder
GetFileListA()               00:00.0781235      05:22.9000502
GetFileListB()               00:00.0624988      03:43.5425829
GetFileListC()               00:00.0624988      05:19.7282361
GetFileListD()               00:00.0468741      03:38.1208120
DirectoryInfo.GetFiles       00:00.0468741      03:45.4644210
Directory.GetFiles           00:00.0312494      03:48.0737459

看起来Marc的是最快的。


看起来很不错,我很好奇是否有其他的做法。 - Alastair Pitts
我在想创建FileInfo对象是否实际上花费了任何时间从文件中读取任何内容,或者它只知道所表示的文件的路径,并且仅在我从其属性之一请求其他信息(如大小或修改日期)时查询实际文件。 - Eric Anastas
一个快速的测试,AllDirectories更快,但差别不大。@Eric,我认为它只读取文件元数据,但我可能错了。 - Alastair Pitts
我认为并行编程将是最好的解决方案。你可以使用P-LINQ。 - Jaider
16个回答

55
尝试使用迭代器块版本,避免使用递归和Info对象。
public static IEnumerable<string> GetFileList(string fileSearchPattern, string rootFolderPath)
{
    Queue<string> pending = new Queue<string>();
    pending.Enqueue(rootFolderPath);
    string[] tmp;
    while (pending.Count > 0)
    {
        rootFolderPath = pending.Dequeue();
        try
        {
            tmp = Directory.GetFiles(rootFolderPath, fileSearchPattern);
        }
        catch (UnauthorizedAccessException)
        {
            continue;
        }
        for (int i = 0; i < tmp.Length; i++)
        {
            yield return tmp[i];
        }
        tmp = Directory.GetDirectories(rootFolderPath);
        for (int i = 0; i < tmp.Length; i++)
        {
            pending.Enqueue(tmp[i]);
        }
    }
}

请注意,4.0版本还具有内置的迭代器块版本(EnumerateFilesEnumerateFileSystemEntries),可能会更快(对文件系统的直接访问更多,数组更少)。

这个yield/return的东西对我来说很新。如果我将您的方法的结果保存到IEnumerable<string>变量中,并在变量上运行多个ForEach迭代,那么每个ForEach是否都会从所有值的某个缓存中运行,还是每个ForEach都会重新运行GetFileList()? - Eric Anastas
2
我认为 yield 延迟或推迟执行到以后,例如当你使用 ToList() 或打印全部时... 它只有在你寻找第一个结果或前面的东西时才有用,这样你就可以忽略其余的执行。 - Jaider
1
“EnumerateFiles” 这种技术,我试过了,但由于在中途抛出的异常(未经授权的访问),它是无法使用的。不过,一些 StackOverflow 上的人提供了对其进行包装的方法,如果你搜索这个问题的话。 - v.oddou
1
@MarcGravell - 感谢您提供的代码。我有些修改,以更加稳健地处理访问某些目录时出现的“UnauthorizedAccessException”。如果您对我的更改不满意,请随意修改或回滚。 - LeopardSkinPillBoxHat
1
可能需要在Directory.GetDirectories()周围添加try/catch,不过这是优秀的初始代码。 - joedotnot
显示剩余2条评论

9

我最近(2020)发现了这篇文章,因为需要在慢速连接上计算文件和目录的数量,而这是我能想到的最快的实现方法。相比之下,.NET枚举方法(GetFiles()、GetDirectories())在幕后执行了大量工作,使它们的速度明显变慢。

此解决方案不返回FileInfo对象,但可以修改以返回FileInfo对象或仅返回自定义FileInfo对象所需的相关数据。

此解决方案利用Win32 API和.NET的Parallel.ForEach()来利用线程池最大化性能。

P/Invoke:

/// <summary>
/// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-findfirstfilew
/// </summary>
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr FindFirstFile(
    string lpFileName,
    ref WIN32_FIND_DATA lpFindFileData
    );

/// <summary>
/// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-findnextfilew
/// </summary>
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool FindNextFile(
    IntPtr hFindFile,
    ref WIN32_FIND_DATA lpFindFileData
    );

/// <summary>
/// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-findclose
/// </summary>
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool FindClose(
    IntPtr hFindFile
    );

方法:

public static Tuple<long, long> CountFilesDirectories(
    string path,
    CancellationToken token
    )
{
    if (String.IsNullOrWhiteSpace(path))
        throw new ArgumentNullException("path", "The provided path is NULL or empty.");

    // If the provided path doesn't end in a backslash, append one.
    if (path.Last() != '\\')
        path += '\\';

    IntPtr hFile = IntPtr.Zero;
    Win32.Kernel32.WIN32_FIND_DATA fd = new Win32.Kernel32.WIN32_FIND_DATA();

    long files = 0;
    long dirs = 0;

    try
    {
        hFile = Win32.Kernel32.FindFirstFile(
            path + "*", // Discover all files/folders by ending a directory with "*", e.g. "X:\*".
            ref fd
            );

        // If we encounter an error, or there are no files/directories, we return no entries.
        if (hFile.ToInt64() == -1)
            return Tuple.Create<long, long>(0, 0);

        //
        // Find (and count) each file/directory, then iterate through each directory in parallel to maximize performance.
        //

        List<string> directories = new List<string>();

        do
        {
            // If a directory (and not a Reparse Point), and the name is not "." or ".." which exist as concepts in the file system,
            // count the directory and add it to a list so we can iterate over it in parallel later on to maximize performance.
            if ((fd.dwFileAttributes & FileAttributes.Directory) != 0 &&
                (fd.dwFileAttributes & FileAttributes.ReparsePoint) == 0 &&
                fd.cFileName != "." && fd.cFileName != "..")
            {
                directories.Add(System.IO.Path.Combine(path, fd.cFileName));
                dirs++;
            }
            // Otherwise, if this is a file ("archive"), increment the file count.
            else if ((fd.dwFileAttributes & FileAttributes.Archive) != 0)
            {
                files++;
            }
        }
        while (Win32.Kernel32.FindNextFile(hFile, ref fd));

        // Iterate over each discovered directory in parallel to maximize file/directory counting performance,
        // calling itself recursively to traverse each directory completely.
        Parallel.ForEach(
            directories,
            new ParallelOptions()
            {
                CancellationToken = token
            },
            directory =>
            {
                var count = CountFilesDirectories(
                    directory,
                    token
                    );

                lock (directories)
                {
                    files += count.Item1;
                    dirs += count.Item2;
                }
            });
    }
    catch (Exception)
    {
        // Handle as desired.
    }
    finally
    {
        if (hFile.ToInt64() != 0)
            Win32.Kernel32.FindClose(hFile);
    }

    return Tuple.Create<long, long>(files, dirs);
}

在我的本地系统上,GetFiles()/GetDirectories() 的性能可能接近于这个水平,但是在较慢的连接(如VPN等)上,我发现这个方法非常快——访问一个包含大约4万个文件、大小约40GB的远程目录只需90秒,而不是45分钟。
这种方法也可以很容易地修改以包括其他数据,例如所有文件的总文件大小,或快速递归地删除空目录,从最远的分支开始。

8

很棒的问题。

我进行了一些尝试,通过利用迭代器块和LINQ,我似乎将你修改后的实现改进了约40%。

我很想让你使用你的计时方法在你的网络上测试它,看看差异如何。

这是其中的关键部分。

private static IEnumerable<FileInfo> GetFileList(string searchPattern, string rootFolderPath)
{
    var rootDir = new DirectoryInfo(rootFolderPath);
    var dirList = rootDir.GetDirectories("*", SearchOption.AllDirectories);

    return from directoriesWithFiles in ReturnFiles(dirList, searchPattern).SelectMany(files => files)
           select directoriesWithFiles;
}

private static IEnumerable<FileInfo[]> ReturnFiles(DirectoryInfo[] dirList, string fileSearchPattern)
{
    foreach (DirectoryInfo dir in dirList)
    {
        yield return dir.GetFiles(fileSearchPattern, SearchOption.TopDirectoryOnly);
    }
}

Eric,不要忘记在测试时遍历IEnumerable<FileInfo>集合以使此代码实际执行。你至少应该将文件名回显到控制台以比较这两种方法,否则当迭代器方法返回得如此之快但执行被延迟时,你就会得到一个苹果和橙子的比较。 - Jay
那么只需填充一个List<FileInfo>怎么样?List<FileInfo> myList = new List<FileINfo>(GetFileLIst(some stuff)); - Eric Anastas
Jay是正确的,当然你需要遍历集合来查看性能损失。我故意省略了如何回显到控制台以计算时间,假设您可能正在执行某些特定操作或更精确地计算时间。这就是我所做的(调用.Count将导致遍历):var start = DateTime.Now; IEnumerable<FileInfo> fileList = GetFileList("*.txt", @"C:\Temp"); var end = DateTime.Now; Console.WriteLine(String.Format("找到的文件数:{0}", fileList.Count())); Console.WriteLine(String.Format("时间:{0}", end.Subtract(start).TotalMilliseconds)); - Brad Cunningham
Eric,我已经没有足够的字符来回答你的评论了。但是,将结果为IEnum的内容放入List<T>中也会导致列表遍历。 - Brad Cunningham

7

获取符合筛选条件的200万个文件名只需要30秒。之所以如此快,是因为我只执行了一次枚举操作。多次枚举会影响性能。变量长度是开放解释的,不一定与枚举示例有关。

if (Directory.Exists(path))
{
    files = Directory.EnumerateFiles(path, "*.*", SearchOption.AllDirectories)
    .Where(s => s.EndsWith(".xml") || s.EndsWith(".csv"))
    .Select(s => s.Remove(0, length)).ToList(); // Remove the Dir info.
}

1
“Length”在当前上下文中不存在。它应该包含什么? - Malcolm Salvador
这只是为了删除路径的一部分,并且可以根据需要进行解释。此处使用的示例是比较本地文件列表和云端文件列表的先决条件。 - Kentonbmax
我想知道如何在where子句中使用文件扩展名列表,而不是硬编码地逐个添加它们,例如s.EndsWith(myListOfExtensions)。此外,这种方法如何处理UnauthorizedAccessException? - Ken

7
如何改善代码性能的简短答案是:无法做到。
您所遇到的真正性能问题是磁盘或网络的实际延迟,因此无论如何,您都必须检查和迭代每个文件项,并检索目录和文件列表。(当然,这排除了硬件或驱动程序修改以减少或改善磁盘延迟,但已经有很多人得到了大量的报酬来解决这些问题,因此我们现在将忽略这一方面)
考虑到原始限制,已经发布了几种更加优雅地包装迭代过程的解决方案(然而,由于我假设我正在从单个硬盘读取,因此并行性不会帮助更快地遍历目录树,甚至可能增加时间,因为现在有两个或更多线程在争夺不同部分的驱动器上的数据,因为它试图来回寻找),减少创建的对象数量等。但是,如果我们评估函数将被最终开发者使用的方式,则可以提出一些优化和概括。
首先,我们可以通过返回IEnumerable来延迟执行性能,yield return通过在实现IEnumerable的匿名类中编译状态机枚举器来实现这一点,并在方法执行时返回。LINQ中的大多数方法都是编写为延迟执行,直到进行迭代,因此在选择或SelectMany中的代码不会在IEnumerable被迭代之前执行。延迟执行的最终结果只有在以后需要获取数据子集时才会感受到,例如,如果您只需要前10个结果,则延迟查询的执行不会遍历整个1000个结果,直到您需要超过十个。
现在,考虑到您想要进行子文件夹搜索,我还可以推断出如果您可以指定该深度,则可能会很有用,并且如果我这样做,它也会概括我的问题,但也需要递归解决方案。然后,稍后,当有人决定现在需要搜索两个目录深度,因为我们增加了文件数量并决定添加另一层分类时,您只需进行轻微修改,而不是重新编写该函数。
鉴于所有这些,这是我提出的解决方案,它提供了比上面其他一些解决方案更加通用的解决方案:
public static IEnumerable<FileInfo> BetterFileList(string fileSearchPattern, string rootFolderPath)
{
    return BetterFileList(fileSearchPattern, new DirectoryInfo(rootFolderPath), 1);
}

public static IEnumerable<FileInfo> BetterFileList(string fileSearchPattern, DirectoryInfo directory, int depth)
{
    return depth == 0
        ? directory.GetFiles(fileSearchPattern, SearchOption.TopDirectoryOnly)
        : directory.GetFiles(fileSearchPattern, SearchOption.TopDirectoryOnly).Concat(
            directory.GetDirectories().SelectMany(x => BetterFileList(fileSearchPattern, x, depth - 1)));
}

顺便提一下,到目前为止,还没有人提到文件权限和安全性。目前,代码没有进行检查、处理或权限请求,如果遇到无法访问的目录进行迭代,则会抛出文件权限异常。


3
BCL方法可以说是可移植的。如果要保持100%托管,我认为最好的方法是在检查访问权限时调用GetDirectories / Folders(或者可能不检查权限,并在第一个线程花费太长时间时准备另一个线程 - 这是它即将抛出UnauthorizedAccess异常的迹象 - 使用VB使用异常过滤器或截至今日未发布的c#可能避免此问题)。
如果您需要比GetDirectories更快的速度,则必须调用win32(findsomethingEx等),该方法提供特定标志,允许在遍历MFT结构时忽略可能不必要的IO。如果驱动器是网络共享,则通过类似的方法可以加快速度,但这次还避免了过度的网络往返。
现在,如果您具有管理员权限并使用ntfs,而且要处理数百万个文件并且真的很快,那么穿越它们的绝对最快方法(假设磁盘延迟杀死旋转的锈蚀)是同时使用mft和日志记录,从本质上替换针对您特定需求的索引服务。如果您只需要查找文件名而不是大小(或者大小也是如此,但然后您必须缓存它们并使用日志来注意更改),则该方法实现理想情况下可能允许对数千万个文件夹进行实际上瞬间的搜索。可能有一两个付费软件处理了此问题。在C#中有关MFT(DiscUtils)和日志记录读取(google)的示例。我只有约500万个文件,并且仅使用NTFSSearch就足够了,因为它大约需要10-20秒来搜索它们。添加日志记录读取后,该金额将下降到<3秒。

2

DirectoryInfo似乎提供了比您需要的更多的信息,尝试使用管道符号运行dir命令并从中解析信息。


你可以尝试使用 Process.Start(cmd.exe, "dir c:\ /s>allfiles.txt"); 命令,然后解析 allfiles.txt 文件。 - Fred Smith

2

我有同样的问题。以下是我的解决方案,比调用Directory.EnumerateFiles、Directory.EnumerateDirectories或Directory.EnumerateFileSystemEntries递归要快得多:

public static IEnumerable<string> EnumerateDirectoriesRecursive(string directoryPath)
{
    return EnumerateFileSystemEntries(directoryPath).Where(e => e.isDirectory).Select(e => e.EntryPath);
}

public static IEnumerable<string> EnumerateFilesRecursive(string directoryPath)
{
    return EnumerateFileSystemEntries(directoryPath).Where(e => !e.isDirectory).Select(e => e.EntryPath);
}

public static IEnumerable<(string EntryPath, bool isDirectory)> EnumerateFileSystemEntries(string directoryPath)
{
    Stack<string> directoryStack = new Stack<string>(new[] { directoryPath });

    while (directoryStack.Any())
    {
        foreach (string fileSystemEntry in Directory.EnumerateFileSystemEntries(directoryStack.Pop()))
        {
            bool isDirectory = (File.GetAttributes(fileSystemEntry) & (FileAttributes.Directory | FileAttributes.ReparsePoint)) == FileAttributes.Directory;

            yield return (fileSystemEntry, isDirectory);

            if (isDirectory)
                directoryStack.Push(fileSystemEntry);
        }
    }
}

你可以轻松修改代码以搜索特定的文件或目录。
问候

我更喜欢使用while (directoryStack.Count > 0)而不是while (directoryStack.Any()),以避免在每次循环中分配一个枚举器。 - undefined

1

考虑将更新的方法拆分为两个迭代器:

private static IEnumerable<DirectoryInfo> GetDirs(string rootFolderPath)
{
     DirectoryInfo rootDir = new DirectoryInfo(rootFolderPath);
     yield return rootDir;

     foreach(DirectoryInfo di in rootDir.GetDirectories("*", SearchOption.AllDirectories));
     {
          yield return di;
     }
     yield break;
}

public static IEnumerable<FileInfo> GetFileList(string fileSearchPattern, string rootFolderPath)
{
     var allDirs = GetDirs(rootFolderPath);
     foreach(DirectoryInfo di in allDirs())
     {
          var files = di.GetFiles(fileSearchPattern, SearchOption.TopDirectoryOnly);
          foreach(FileInfo fi in files)
          {
               yield return fi;
          }
     }
     yield break;
}

此外,进一步针对网络特定情况,如果您能在该服务器上安装一个小服务,并从客户端机器调用它,您就可以更接近于您的“本地文件夹”结果,因为搜索可以在服务器上执行并将结果返回给您。这将是您在网络文件夹场景中获得的最大速度提升,但可能在您的情况下不可用。我一直在使用一个包含此选项的文件同步程序 - 一旦我在服务器上安装了该服务,程序在识别新文件、删除文件和不同步文件时变得非常快。

1
你如何处理未经授权的访问异常,又知道在 try catch 中不能使用 yield return? - Fred Smith

1

我需要从C分区获取所有文件,因此结合了Marc和Jaider的答案,得到了一种没有递归且具有并行编程的函数,处理结果为大约370,000个文件在30秒内完成。也许这会对某些人有所帮助:

void DirSearch(string path)
    {
        ConcurrentQueue<string> pendingQueue = new ConcurrentQueue<string>();
        pendingQueue.Enqueue(path);

        ConcurrentBag<string> filesNames = new ConcurrentBag<string>();
        while(pendingQueue.Count > 0)
        {
            try
            {
                pendingQueue.TryDequeue(out path);

                var files = Directory.GetFiles(path);

                Parallel.ForEach(files, x => filesNames.Add(x));

                var directories = Directory.GetDirectories(path);

                Parallel.ForEach(directories, (x) => pendingQueue.Enqueue(x));
            }
            catch (Exception)
            {
                continue;
            }
        }
    }

我喜欢这个版本。你尝试过用不同数量的线程来测试性能吗@hazaaa? - rollsch
@rolls 不是很确定。我需要这个来完成我的小项目,所以我尽可能快地完成了它。你可以试一下并报告结果 :) - Hazaaa
@Hazaaa 我怎样能为获取的文件添加过滤器?我想要获取在一定时间范围内创建的文件。 - Datboydozy
@Datboydozy,我认为你需要将过滤器添加到第一个并行foreach中。这里你可以检查文件元数据以获取其创建时间。 - Hazaaa

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接