Есть ли более быстрый способ рекурсивного сканирования каталога в.NET?

Я пишу сканер каталогов в.NET.

Для каждого файла / каталога мне нужна следующая информация.

   class Info {
        public bool IsDirectory;
        public string Path;
        public DateTime ModifiedDate;
        public DateTime CreatedDate;
    }

У меня есть эта функция:

      static List<Info> RecursiveMovieFolderScan(string path){

        var info = new List<Info>();
        var dirInfo = new DirectoryInfo(path);
        foreach (var dir in dirInfo.GetDirectories()) {
            info.Add(new Info() {
                IsDirectory = true,
                CreatedDate = dir.CreationTimeUtc,
                ModifiedDate = dir.LastWriteTimeUtc,
                Path = dir.FullName
            });

            info.AddRange(RecursiveMovieFolderScan(dir.FullName));
        }

        foreach (var file in dirInfo.GetFiles()) {
            info.Add(new Info()
            {
                IsDirectory = false,
                CreatedDate = file.CreationTimeUtc,
                ModifiedDate = file.LastWriteTimeUtc,
                Path = file.FullName
            });
        }

        return info; 
    }

Оказывается, эта реализация довольно медленная. Есть ли способ ускорить это? Я думаю о ручном кодировании этого с FindFirstFileW, но хотел бы избежать этого, если есть встроенный способ, который быстрее.

8 ответов

Решение

Эта реализация, которая требует небольшой настройки, в 5-10 раз быстрее.

    static List<Info> RecursiveScan2(string directory) {
        IntPtr INVALID_HANDLE_VALUE = new IntPtr(-1);
        WIN32_FIND_DATAW findData;
        IntPtr findHandle = INVALID_HANDLE_VALUE;

        var info = new List<Info>();
        try {
            findHandle = FindFirstFileW(directory + @"\*", out findData);
            if (findHandle != INVALID_HANDLE_VALUE) {

                do {
                    if (findData.cFileName == "." || findData.cFileName == "..") continue;

                    string fullpath = directory + (directory.EndsWith("\\") ? "" : "\\") + findData.cFileName;

                    bool isDir = false;

                    if ((findData.dwFileAttributes & FileAttributes.Directory) != 0) {
                        isDir = true;
                        info.AddRange(RecursiveScan2(fullpath));
                    }

                    info.Add(new Info()
                    {
                        CreatedDate = findData.ftCreationTime.ToDateTime(),
                        ModifiedDate = findData.ftLastWriteTime.ToDateTime(),
                        IsDirectory = isDir,
                        Path = fullpath
                    });
                }
                while (FindNextFile(findHandle, out findData));

            }
        } finally {
            if (findHandle != INVALID_HANDLE_VALUE) FindClose(findHandle);
        }
        return info;
    }

метод расширения:

 public static class FILETIMEExtensions {
        public static DateTime ToDateTime(this System.Runtime.InteropServices.ComTypes.FILETIME filetime ) {
            long highBits = filetime.dwHighDateTime;
            highBits = highBits << 32;
            return DateTime.FromFileTimeUtc(highBits + (long)filetime.dwLowDateTime);
        }
    }

определения взаимодействия:

    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    public static extern IntPtr FindFirstFileW(string lpFileName, out WIN32_FIND_DATAW lpFindFileData);

    [DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
    public static extern bool FindNextFile(IntPtr hFindFile, out WIN32_FIND_DATAW lpFindFileData);

    [DllImport("kernel32.dll")]
    public static extern bool FindClose(IntPtr hFindFile);

    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
    public struct WIN32_FIND_DATAW {
        public FileAttributes dwFileAttributes;
        internal System.Runtime.InteropServices.ComTypes.FILETIME ftCreationTime;
        internal System.Runtime.InteropServices.ComTypes.FILETIME ftLastAccessTime;
        internal System.Runtime.InteropServices.ComTypes.FILETIME ftLastWriteTime;
        public int nFileSizeHigh;
        public int nFileSizeLow;
        public int dwReserved0;
        public int dwReserved1;
        [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
        public string cFileName;
        [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)]
        public string cAlternateFileName;
    }

Существует долгая история медленных методов перечисления файлов.NET. Проблема в том, что нет мгновенного способа перечисления больших структур каталогов. Даже принятый здесь ответ имеет проблемы с распределением GC.

Лучшее, что я смог сделать, - это обернуть его в моей библиотеке и представить как класс FileFile ( источник) в пространстве имен CSharpTest.Net.IO. Этот класс может перечислять файлы и папки без ненужного выделения GC и маршалинга строк.

Использование достаточно простое, а свойство RaiseOnAccessDenied пропустит каталоги и файлы, к которым у пользователя нет доступа:

    private static long SizeOf(string directory)
    {
        var fcounter = new CSharpTest.Net.IO.FindFile(directory, "*", true, true, true);
        fcounter.RaiseOnAccessDenied = false;

        long size = 0, total = 0;
        fcounter.FileFound +=
            (o, e) =>
            {
                if (!e.IsDirectory)
                {
                    Interlocked.Increment(ref total);
                    size += e.Length;
                }
            };

        Stopwatch sw = Stopwatch.StartNew();
        fcounter.Find();
        Console.WriteLine("Enumerated {0:n0} files totaling {1:n0} bytes in {2:n3} seconds.",
                          total, size, sw.Elapsed.TotalSeconds);
        return size;
    }

Для моего локального диска C:\ это выводит следующее:

Перечислили 810 046 файлов общим объемом 307 707 792 662 байта за 232 876 секунд.

Ваш пробег может варьироваться в зависимости от скорости диска, но это самый быстрый метод перечисления файлов в управляемом коде. Параметр события является мутирующим классом типа FindFile.FileFoundEventArgs, поэтому убедитесь, что вы не сохраняете ссылку на него, так как его значения будут изменяться для каждого вызванного события.

Вы также можете заметить, что дата и время выставлены только в UTC. Причина в том, что перевод на местное время стоит недорого. Вы можете использовать время UTC для повышения производительности, а не преобразовывать их в местное время.

В зависимости от того, сколько времени вы пытаетесь сбрить функцию, может быть, стоит потратить время на непосредственное обращение к функциям Win32 API, поскольку существующий API выполняет много дополнительной обработки для проверки того, что вас может не заинтересовать.

Если вы еще этого не сделали и, если не собираетесь участвовать в проекте Mono, я настоятельно рекомендую загрузить Reflector и посмотреть, как Microsoft реализовала вызовы API, которые вы используете в данный момент. Это даст вам представление о том, что вам нужно позвонить и что вы можете пропустить.

Вы можете, например, выбрать создание итератора, который yields имена каталогов вместо функции, которая возвращает список, таким образом, вы не будете повторять один и тот же список имен два или три раза по всем различным уровням кода.

Я только что столкнулся с этим. Хорошая реализация родной версии.

Эта версия, хотя все еще медленнее, чем версия, которая использует FindFirst а также FindNext, это немного быстрее, чем ваша оригинальная версия.NET.

    static List<Info> RecursiveMovieFolderScan(string path)
    {
        var info = new List<Info>();
        var dirInfo = new DirectoryInfo(path);
        foreach (var entry in dirInfo.GetFileSystemInfos())
        {
            bool isDir = (entry.Attributes & FileAttributes.Directory) != 0;
            if (isDir)
            {
                info.AddRange(RecursiveMovieFolderScan(entry.FullName));
            }
            info.Add(new Info()
            {
                IsDirectory = isDir,
                CreatedDate = entry.CreationTimeUtc,
                ModifiedDate = entry.LastWriteTimeUtc,
                Path = entry.FullName
            });
        }
        return info;
    }

Он должен выдавать тот же вывод, что и ваша нативная версия. Мои тесты показывают, что эта версия занимает примерно в 1,7 раза больше, чем версия, которая использует FindFirst а также FindNext, Время получено в режиме релиза без отладчика.

Любопытно, меняя GetFileSystemInfos в EnumerateFileSystemInfos добавляет около 5% времени выполнения в моих тестах. Я скорее ожидал, что он будет работать с той же скоростью или, возможно, быстрее, потому что он не должен был создавать массив FileSystemInfo объекты.

Следующий код еще короче, потому что он позволяет Framework заботиться о рекурсии. Но это на 15-20% медленнее, чем версия выше.

    static List<Info> RecursiveScan3(string path)
    {
        var info = new List<Info>();

        var dirInfo = new DirectoryInfo(path);
        foreach (var entry in dirInfo.EnumerateFileSystemInfos("*", SearchOption.AllDirectories))
        {
            info.Add(new Info()
            {
                IsDirectory = (entry.Attributes & FileAttributes.Directory) != 0,
                CreatedDate = entry.CreationTimeUtc,
                ModifiedDate = entry.LastWriteTimeUtc,
                Path = entry.FullName
            });
        }
        return info;
    }

Опять же, если вы измените это на GetFileSystemInfos, это будет немного (но только немного) быстрее.

Для моих целей первое решение, приведенное выше, достаточно быстрое. Нативная версия запускается примерно за 1,6 секунды. Версия, которая использует DirectoryInfo работает примерно за 2,9 секунды. Полагаю, если бы я часто проводил эти сканы, я бы передумал.

Я недавно (2020) обнаружил этот пост из-за необходимости подсчитывать файлы и каталоги по медленным соединениям, и это была самая быстрая реализация, которую я мог придумать. Методы перечисления.NET (GetFiles(), GetDirectories()) выполняют много внутренней работы, которая по сравнению с ними значительно замедляет их.

Это решение использует Win32 API и.NET Parallel.ForEach(), чтобы использовать пул потоков для максимизации производительности.

P / Invoke:

/// <summary>
/// https://docs.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://docs.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://docs.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 и т. Д.) Я обнаружил, что это намного быстрее - 45 минут против 90 секунд для доступа к удаленному каталогу. из ~40к файлов, размером ~40 ГБ.

Это также довольно легко можно изменить, чтобы включить другие данные, такие как общий размер всех подсчитанных файлов, или быстрое повторение и удаление пустых каталогов, начиная с самой дальней ветви.

Это довольно мелкий, 371 dirs со средним числом 10 файлов в каждой директории. некоторые dirs содержат другие sub dirs

Это всего лишь комментарий, но ваши цифры кажутся довольно высокими. Я запустил ниже, используя, по сути, тот же рекурсивный метод, который вы используете, и мое время намного меньше, несмотря на создание вывода строки.

    public void RecurseTest(DirectoryInfo dirInfo, 
                            StringBuilder sb, 
                            int depth)
    {
        _dirCounter++;
        if (depth > _maxDepth)
            _maxDepth = depth;

        var array = dirInfo.GetFileSystemInfos();
        foreach (var item in array)
        {
            sb.Append(item.FullName);
            if (item is DirectoryInfo)
            {
                sb.Append(" (D)");
                sb.AppendLine();

                RecurseTest(item as DirectoryInfo, sb, depth+1);
            }
            else
            { _fileCounter++; }

            sb.AppendLine();
        }
    }

Я запустил приведенный выше код в нескольких разных каталогах. На моей машине второй вызов для сканирования дерева каталогов обычно выполнялся быстрее из-за кэширования как во время выполнения, так и в файловой системе. Обратите внимание, что эта система не является чем-то особенным, это всего лишь 1-летняя рабочая станция для разработки.

// кэшированный вызов
Dirs = 150, файлы = 420, максимальная глубина = 5
Время взято = 53 миллисекунды

// кэшированный вызов
Dirs = 1117, файлы = 9076, максимальная глубина = 11
Время выполнения = 433 миллисекунды

// первый звонок
Dirs = 1052, файлы = 5903, максимальная глубина = 12
Время выполнения = 11921 миллисекунд

// первый звонок
Dirs = 793, файлы = 10748, максимальная глубина = 10
Время выполнения = 5433 миллисекунды (второй прогон 363 миллисекунды)

Обеспокоенный тем, что я не получил дату создания и изменения, код был изменен, чтобы вывести это также в следующие разы.

// теперь захватывает время последнего обновления и создания.
Dirs = 150, файлы = 420, максимальная глубина = 5
Время выполнения = 103 миллисекунды (второй прогон 93 миллисекунды)

Dirs = 1117, файлы = 9076, максимальная глубина = 11
Время выполнения = 992 миллисекунды (второй прогон 984 миллисекунды)

Dirs = 793, файлы = 10748, максимальная глубина = 10
Требуемое время = 1382 миллисекунды (второй запуск 735 миллисекунд)

Dirs = 1052, файлы = 5903, максимальная глубина = 12
Время выполнения = 936 миллисекунд (второй запуск 595 миллисекунд)

Примечание. Класс System.Diagnostics.StopWatch используется для синхронизации.

Я бы использовал или основывал себя на этой многопоточной библиотеке: http://www.codeproject.com/KB/files/FileFind.aspx

В последнее время у меня возник тот же вопрос, я думаю, что это также хорошо, чтобы вывести все папки и файлы в текстовый файл, а затем использовать потоковую программу для чтения текстового файла, делать то, что вы хотите обработать с многопоточностью.

cmd.exe /u /c dir "M:\" /s /b >"c:\flist1.txt"

[обновление] Привет, Моби, ты прав. Мой подход медленнее из-за накладных расходов на чтение выходного текстового файла. На самом деле я потратил некоторое время, чтобы проверить топ-ответ и cmd.exe с 2 миллионами файлов.

The top answer: 2010100 files, time: 53023
cmd.exe method: 2010100 files, cmd time: 64907, scan output file time: 19832.

Метод верхнего ответа (53023) работает быстрее, чем cmd.exe(64907), не говоря уже о том, как улучшить чтение выходного текстового файла. Хотя моя первоначальная цель заключается в том, чтобы дать неплохой ответ, мне все равно жаль, ха.

Попробуйте это (т.е. сначала выполните инициализацию, а затем повторно используйте ваш список и ваши объекты directoryInfo):

  static List<Info> RecursiveMovieFolderScan1() {
      var info = new List<Info>();
      var dirInfo = new DirectoryInfo(path);
      RecursiveMovieFolderScan(dirInfo, info);
      return info;
  } 

  static List<Info> RecursiveMovieFolderScan(DirectoryInfo dirInfo, List<Info> info){

    foreach (var dir in dirInfo.GetDirectories()) {

        info.Add(new Info() {
            IsDirectory = true,
            CreatedDate = dir.CreationTimeUtc,
            ModifiedDate = dir.LastWriteTimeUtc,
            Path = dir.FullName
        });

        RecursiveMovieFolderScan(dir, info);
    }

    foreach (var file in dirInfo.GetFiles()) {
        info.Add(new Info()
        {
            IsDirectory = false,
            CreatedDate = file.CreationTimeUtc,
            ModifiedDate = file.LastWriteTimeUtc,
            Path = file.FullName
        });
    }

    return info; 
}
Другие вопросы по тегам