3

Как распараллелить процесс поиска по папкам?

public  IEnumerable<string> GetDirectoryFiles(string rootPath, string patternMatch, SearchOption searchOption)
{
var foundFiles = Enumerable.Empty&lt;string&gt;();

if (searchOption == SearchOption.AllDirectories)
{
    try
    {
        IEnumerable&lt;string&gt; subDirs = Directory.EnumerateDirectories(rootPath);
        foreach (string dir in subDirs)
        {
            foundFiles = foundFiles.Concat(GetDirectoryFiles(dir, patternMatch, searchOption)); // Add files in subdirectories recursively to the list
        }
    }
    catch (UnauthorizedAccessException ex) { LoggingExtensions.WriteDebug(ex.Message); }
    catch (PathTooLongException ex) { LoggingExtensions.WriteDebug(ex.Message); }
}

try
{
    var files = Directory.EnumerateFiles(rootPath, patternMatch);
    foundFiles = foundFiles.Concat(files); 
    var z = foundFiles.Distinct().ToList();
}
catch (UnauthorizedAccessException ex) { LoggingExtensions.WriteDebug(ex.Message); }
return foundFiles;

}

Radzhab
  • 3,772
  • 2
    Вообще конечно можно, ведь это обычный поиск в ширину\глубину. Но 1) зачем параллелить именно рекурсивный вариант? 2) Надо ли вообще параллелить работу с жестким диском? 3) Собираетесь ли вы контроллировать уровень параллелизма? – tym32167 Jan 22 '21 at 17:43
  • @EvgeniyZ чет не похоже. Там больше про producer\consumer, тут про многопоточность – tym32167 Jan 22 '21 at 17:48
  • @tym32167 мне в принципе без разницы хоть рекурсивно - хоть нет. У меня 20к папок и в ней туева куча подпапок. Хочется просто через Parallel For запустить по максимуму и всё – Radzhab Jan 22 '21 at 17:57
  • 7
    Доступ к накопителю (не важно - HDD, SSD, CD/DVD) всё равно последовательный, поэтому от параллелизации толку практически не будет. Параллелить нужно не IO-доступ, а CPU-задачи. – Alexander Petrov Jan 22 '21 at 18:08
  • 1
    @AlexanderPetrov а для SSD это точно без разницы? Пошел гуглить, вот в ответе кто-то измерения проводил на скорость чтения. SSD показал лучшие значения. – Артём Оконечников Jan 22 '21 at 18:21
  • 1
    При многократном поиске можно прикрутить кеширование и какой-нибудь FileSystemWatcher в фоне – SmorcIRL Jan 22 '21 at 18:44
  • А ещё лучше будет заменить здесь рекурсию на цикл, не факт что значительно быстрее будет, но какая-никакая оптимизация – SmorcIRL Jan 22 '21 at 18:59
  • @SmorcIRL замена рекурсии на цикл мало что ускорит, так как основная потеря времени тут на работу с жестврим диском. Но если хочется улучшить, то можно, например, учитывать семантические ссылки при поиске, чтобы не уйти в StackOverflow – tym32167 Jan 22 '21 at 19:04
  • 3
    Я проверил - убираете строчку var z = foundFiles.Distinct().ToList(); и оно начинает раз в 5 работать быстрее. – aepot Jan 22 '21 at 21:02
  • @AlexanderPetrov поиск по имени файла в случае NTFS - это поиск по B-Tree, которое с большой вероятностью будет лежать в кэше. Это CPU задача? :) –  Jan 23 '21 at 09:21
  • 2
    @PashaPash - насколько я понимаю, эту операцию - поиск по B-Tree - выполнит драйвер и распараллеливание никак её не улучшит. – Alexander Petrov Jan 23 '21 at 10:20

4 Answers4

7

Фух, придётся дать свой ответ.

@aepot дал комментарий, что при удалении строки var z = foundFiles.Distinct().ToList(); код начинает раз в 5 работать быстрее.

Почему так получается, откуда ускорение работы? Дело в том, что пока процессор выполняет эту строку кода, нет новых обращений к диску за новой порцией данных. То есть накопитель простаивает.

Удаляем эту строку - получаем более частые IO-запросы.

Именно в этом случае распараллеливание может помочь: один поток прочитал порцию данных и какое-то время обрабатывает их, в это время другой поток читает данные. Потом второй поток обрабатывает данные, а первый снова читает. Соответственно, чем больше длится обработка данных, тем больше смысла в увеличении количества потоков (распараллеливании).

Но не стоит путать это с прямым распараллеливанием доступа к накопителю! Если несколько потоков будут вхолостую читать данные (и никак не использовать их), то они просто будут ждать в очереди.


Полагаю, шаблон producer-consumer является классическим решением проблемы долгой обработки данных: один-единственный поток читает данные с накопителя и складывает их в некую коллекцию, другой(ие) поток(и) берут данные из этой коллекции и обрабатывают. При этом ни один поток не ждёт своей очереди поработать с диском.

6

У меня достаточно быстрый SSD. Как бы я не пытался распараллелить выполнение поиска, оно все равно съедает только один процессор, то есть вся работа ведется где-то драйвером в одном потоке, а мои потоки в приложении просто висят и ждут результатов этой работы. Но так как оверхед на асинхронность и спавн потоков получается весьма существенный, так как я выполнял поиск по всему диску C:\, то и выполнение метода затягивается и получается дольше.

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

Поэтому ответ на ваш вопрос: Распараллеливание здесь бесполезно из-за особенностей работы драйвера файловой системы.

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

Основная проблема вашего метода из вопроса - это то, что он сначала полностью отрабатывает, а затем только возвращает IEnumerable вызывающему методу. То есть сначала отработают все рекурсии, а потом только вы сможете проитерировать результаты. Если быть точнее, то возврат из метода произойдет только тогда, когда отработают все .Concat. Это можно исправить и получать результаты по мере их возвращения файловой системой.

Вторая проблема - баг, неиспользуемая переменная z, вычисления значения которой надо сгенерировать массив, но вы далее ничего с этой переменной не делаете, так что можно просто удалить строчку var z = foundFiles.Distinct().ToList();.

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

После оптимизации рекурсивный метод выглядит вот так:

public IEnumerable<string> GetDirectoryFiles(string rootPath, string patternMatch, SearchOption searchOption)
{
    bool dirSuccess = true;
    if (searchOption == SearchOption.AllDirectories)
    {
        dirSuccess = false;
        IEnumerable<string> subDirs = Enumerable.Empty<string>();
        try
        {
            subDirs = Directory.EnumerateDirectories(rootPath);
            dirSuccess = true;
        }
        catch (UnauthorizedAccessException ex) { LoggingExtensions.WriteDebug(ex.Message); }
        catch (PathTooLongException ex) { LoggingExtensions.WriteDebug(ex.Message); }
    foreach (string dir in subDirs)
    {
        foreach (string path in GetDirectoryFiles(dir, patternMatch, searchOption))
        {
            yield return path;
        }
    }
}

// нет смысла пытаться запрашивать файлы, если нет доступа к каталогу или возникла другая ошибка
if (dirSuccess)
{
    foreach (string path in Directory.EnumerateFiles(rootPath, patternMatch))
    {
        yield return path;
    }
}

}

Что можно оптимизировать дальше? То же что и всегда, когда я сталкиваюсь с рекурсивными методами - избавиться от рекурсии. Я попробовал механизмы с массивами и итерированием по большому массиву - особого прироста не получил, а получил его только от метода Directory.EnumerateDirectories с непосредственной передачей параметра SearchOption в него. То есть задачу поиска переложил наполовину на .NET, и вот тогда прирост стал ощутимым, хоть и не фантастическим.

Реализация без рекурсии:

public IEnumerable<string> GetDirectoryFilesFast(string rootPath, string patternMatch, SearchOption searchOption)
{
    foreach (string file in Directory.EnumerateFiles(rootPath, patternMatch))
    {
        yield return file;
    }
    if (searchOption == SearchOption.AllDirectories)
    {
        IEnumerator<string> enumarator = Directory.EnumerateDirectories(rootPath, string.Empty, searchOption).GetEnumerator();
        while (true)
        {
            bool skip = true;
            try
            {
                if (!enumarator.MoveNext())
                    break;
                skip = false;
            }
            catch (UnauthorizedAccessException ex) { LoggingExtensions.WriteDebug(ex.Message); }
            catch (PathTooLongException ex) { LoggingExtensions.WriteDebug(ex.Message); }
        if (skip)
            continue;

        foreach (string file in Directory.EnumerateFiles(enumarator.Current, patternMatch))
        {
            yield return file;
        }
    }
}

}

Да и сам метод стал выглядеть проще.

Ну и измерил производительность на релизном билде.

Завел класс логирования. Так как мне нужна только статистика ошибок, то я просто их посчитаю, и не буду никуда выводить.

public static class LoggingExtensions
{
    public static int ErrorsCount { get; set; }
    public static void WriteDebug(string text) { ErrorsCount++; }
}
static void Main(string[] args)
{
    Console.WriteLine("GetDirectoryFiles");
    DateTime date = DateTime.Now;
    int i = 0;
    LoggingExtensions.ErrorsCount = 0;
    foreach (string path in GetDirectoryFiles(@"C:\", "*.cs", SearchOption.AllDirectories)) { i++; }
    Console.WriteLine($"Found {i} files");
    Console.WriteLine($"{LoggingExtensions.ErrorsCount} exceptions thrown");
    TimeSpan elapsed = DateTime.Now - date;
    Console.WriteLine($"Elapsed {elapsed.TotalSeconds}s");
Console.WriteLine(&quot;GetDirectoryFilesOriginal&quot;);
date = DateTime.Now;
i = 0;
LoggingExtensions.ErrorsCount = 0;
foreach (string path in GetDirectoryFilesOriginal(@&quot;C:\&quot;, &quot;*.cs&quot;, SearchOption.AllDirectories)) { i++; }
Console.WriteLine($&quot;Found {i} files&quot;);
Console.WriteLine($&quot;{LoggingExtensions.ErrorsCount} exceptions thrown&quot;);
elapsed = DateTime.Now - date;
Console.WriteLine($&quot;Elapsed {elapsed.TotalSeconds}s&quot;);

Console.WriteLine(&quot;GetDirectoryFilesFast&quot;);
date = DateTime.Now;
i = 0;
LoggingExtensions.ErrorsCount = 0;
foreach (string path in GetDirectoryFilesFast(@&quot;C:\&quot;, &quot;*.cs&quot;, SearchOption.AllDirectories)) { i++; }
Console.WriteLine($&quot;Found {i} files&quot;);
Console.WriteLine($&quot;{LoggingExtensions.ErrorsCount} exceptions thrown&quot;);
elapsed = DateTime.Now - date;
Console.WriteLine($&quot;Elapsed {elapsed.TotalSeconds}s&quot;);
Console.ReadKey();

}

Ваш метод из вопроса назван здесь GetDirectoryFilesOriginal и для чистоты эксперимента я воткнул его в неизменном виде в середину, ну чтобы не казалось, что он отработал медленнее из-за того что первое обращение к директориям еще не кешировано и работает медленнее. В моем конкретном случае это не так.

И получил вот такой вывод (релизный билд с оптимизацией кода):

GetDirectoryFiles
Found 2612 files
287 exceptions thrown
Elapsed 89,225523s
GetDirectoryFilesOriginal
Found 2612 files
574 exceptions thrown
Elapsed 440,9526969s
GetDirectoryFilesFast
Found 2612 files
287 exceptions thrown
Elapsed 69,9758348s

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

Так что решение вашей проблемы - не распараллеливание, а оптимизация.

aepot
  • 49,560
  • 1
    ssd m2 или sata? (плюсик за проделанную работу, было интересно) – Andrew Stop_RU_war_in_UA Jan 23 '21 at 20:50
  • @Andrew SATAII, спасибо, я еще пару багов подправил – aepot Jan 23 '21 at 21:00
  • Жаль, лучше такой тест проводить на м2 где меньше ограничение по шине. – Andrew Stop_RU_war_in_UA Jan 23 '21 at 21:16
  • @Andrew оно в любом случае не нагружает у меня SSD, а упирается в процессор. Так что при прочих равных на моей машине будет примерно фиолетово, M2 там у меня или SATAII. – aepot Jan 23 '21 at 21:18
5

(Не совсем ответ / Или совсем не ответ)

Распаралеливание работы с HDD бесполезное занятие. В любом случае ты упираешся в скорость винчестера. А при множестве потоков общая скорость задач даже упадет. Это связано с тем что несколько операций чтения и записи имеют одинаковый приоритет и постоянно нужно перемещать головку для каждого потока на другое место. А перемещение головки = утраты времени на лишнюю работу.

В случае с SSD все не так однозначно. Теоретический прирост может быть, а может быть краткострочный прирост скорости, а может и вовсе не будет прироста. Нельзя сказать просто "на ССД это будет работать быстрее" ибо ссд ссд рознь. Взять китайский низкоклассовый ссд - там операция записи будет 70-80 мегабайт (честная, а не по CrystalDiskMark, который лжет как дышит). Операция чтения - как повезет. Но около 300-350 (надеюсь).

Если брать SSD средней ценовой категории - там залепливают дыры отвратного железа добавлением кеша. Поэтому скорость записи будет хорошая пока не забьется кеш. А дальше будет печаль.

Если брать топовый ссд вроде самсунг Эво - там будет +- честная скорость на всем ссд.

Уверен, там есть еще какие-то особенности про которые я не знаю.

Еще могут быть разные виды RAID-ов. Там изменения скорости паралельной записи будет зависеть еще и от вида рейда.


Оффтоп:

Не пользуйтесь CrystalDiskMark вообще. Это отвратный бенчмарк который не показывает обьективную оценку работы SSD от слова "вообще".


Резюме:

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

А что бы наверняка понять стоит ли это делать - нужно провести множество тестов с РАЗНЫМИ SSD разных ценовых категорий на разных задачах. И только потом уже будет понятно есть ли смысл в это ввязыватся при конкретной задаче и какие костыли писать что бы это не приводило к замедлениям у конечного пользователя.


Лично я бы вообще не паралелил бы I/O задачи в принципе.

  • 1
    поиск файлов в папках != работа с SSD / HDD. Там вполне может быть какая-то часть CPU нагрузки. И к тому же, это ведь поиск по MFT, а не по содержимому файлов. В случае NTFS - это поиск по B-Tree, которое скорее всего будет в кэше лежать. Поиск по нему - это CPU-задача, и ее можно параллелить. Тот же SQL Server, в котором индексы - это лежащие на диске / в кэше B-Tree, и в нем параллелизм запросов внезапно работает. Так что не все так однозначно. Нужно взять и померять. –  Jan 23 '21 at 09:19
  • В MFT данные тоже лежать не последовательно, на сколько я понимаю. То есть если в паралели это делать, то вероятно, тоже приведет к замедлению потому что нужно будет заново проверять по списку какие именно файлы относятся к проверяемому пути или нет... А если это несколько потоков - то нужно проверять те же подпути по N раз.... Не уверен как именно это там работает, это теоретически. Что до кеширования - тоже тот еще вопрос. Думаю тут мы можем еще упереться в какие-то настройки винды дополнительно.... – Andrew Stop_RU_war_in_UA Jan 23 '21 at 09:33
  • Короче) Суть моего ответа все равно сводилась к тому что нужны тесты. Еще и с разным железом. А теперь еще и с разными настройками винды ахахах. (Хотя на самом деле я не уверен что MFT кешируется... Это должно быть и так максимально оптимально реализовано из коробки... В теории.). А еще это может быть не только NTFS. Это тоже нужно учитывать - может быть под виндой и FAT и REFS, например. И хрен его знает как это работает там. Особенно учитывая что в REFS еще и поддержка дедупликации файлов есть...Что практически наверняка как-то сказалось на особенностях MFT и поиска.... – Andrew Stop_RU_war_in_UA Jan 23 '21 at 09:38
  • Имхо, это совсем не ответ. Автор спрашивает как распараллелить. – Alexander Petrov Jan 23 '21 at 10:21
  • Гм, а какая разница, дешманский или элитный SSD: в любом случае он будет показывать свою максимальную скорость при доступе в один поток. И добавление новых потоков ничего не изменит. – Alexander Petrov Jan 23 '21 at 10:25
  • @AlexanderPetrov иногда на SO принимают ответ который не отвечает на заданный вопрос в буквальном смысле) Никогда такого не видел? – Andrew Stop_RU_war_in_UA Jan 23 '21 at 10:26
  • @AlexanderPetrov смотря на каком количестве данных замерять))) Дешманский без кеша будет показывать реальную скорость. средней ценовой категории будет показывать на небольшом количестве данных скорость кеша(а не памяти ССД), а потом начнется график в виде гребня потому что кеш то освобождается то занимается полностью и ссд начинает с данными работать напрямую. Короче, проверь сам и увидишь разницу) – Andrew Stop_RU_war_in_UA Jan 23 '21 at 10:31
  • Да, конечно. И я считаю вполне оправданным дать ответ, который в корне отличается от того, что просит автор (например, автор спрашивает "как распарсить html регулярками", а ему приводят решение с парсером). Но вас понесло совсем не в ту степь. Причём тут классовость ssd и обличение CrystalMark? – Alexander Petrov Jan 23 '21 at 10:32
  • OK, дешёвый ssd отдаёт 50 МБ/с. Добавляем второй поток - в каждом получаем по 25 МБ/с. Суммарно ничего не изменилось. Берём дорогой ssd - 500 Мб/с. Добавляем второй поток - получаем по 250 Мб/с. Суммарно - то же самое. Попытка распараллеливания не зависит от того, к какому накопителю - хорошему или плохому - мы обращаемся. – Alexander Petrov Jan 23 '21 at 10:33
  • @AlexanderPetrov Возможно, ты и прав. Я не проверял и не могу ни согласится ни опровергнуть это со сколь-нибуть весомыми доводами. Что до того что меня "понесло не в ту степь" - это были обьяснения почему с SSD не все так однозначно как в случае с HDD. Написал бы я только эту фразу - хрен кто бы понял что я имею ввиду. – Andrew Stop_RU_war_in_UA Jan 23 '21 at 10:37
  • Это я понимаю. Меня тоже постоянно заносит и я "растекаюсь мыслию по древу". – Alexander Petrov Jan 23 '21 at 10:38
  • @AlexanderPetrov если ты прав, то распаралеливание не повлияет на скорость. Если прав я - это в отдельных случаях замедлит работу а на отдельных даст прирост (что нужно подтвердить разными тестами на разном железе). Но при этом мы еще имеем случаи когда программисты говорят про убыстрение на отдельных задачах IO работы с SSD при распаралеливании ( https://stackoverflow.com/questions/13421767/multithread-read-from-disk/13422318#13422318 ). Что уже противоречит твоим теоретическим словам. Как они меряли и почему у них так вышло я, например, говорить не берусь - знаний не хватает. – Andrew Stop_RU_war_in_UA Jan 23 '21 at 10:51
  • И при этом еще могут быть нюансы еще и с RAID-ами и SSD RAID-ами). Например тот же Raid-0 может дать прирост скорости при распаралеливании записи(в отдельных ситуациях - как HDD так и SSD). Еще раз: тема СЛОЖНАЯ. Что бы что-то говорить наверняка нужны тесты. В идеале на том железе, под которое пишется программа. А в теоретические утверждения я не поверю. Автора вопроса я предупредил именно об этом своим ответом -- в том что код который он напишет может иметь противоположный эфект к желаемому. – Andrew Stop_RU_war_in_UA Jan 23 '21 at 11:02
  • Вот мне тоже очень интересно было-бы послушать, почему доступ к SSD не может быть параллельным? Прямо хочется технического обоснования. В случае с HDD, CD/DVD или кассетой в магнитофоне все понятно интуитивно. Сколько потоков не запусти, головка-то одна. А с SSD что не так? Почему доступ к данным, лежащим на SSD не может быть параллельным? Но ответ пока такой — нет и всё. Может, @avp заглянет в вопрос... – Артём Оконечников Jan 23 '21 at 11:29
  • @АртёмОконечников Допустим это и вправду приведет к приросту скорости по какой-то причине. На топовых SSD, например. И здесь мы уже упремся в ограничение шины) Если ссд подключен по SATA - мы не получим убыстрения процесса т.к. топовые ссд и так работают на максимальной скорости SATA шины. А если по m2 - то хз что будет. Но снова таки - не у всех стоят топовые ссд и не у всех они на m2. – Andrew Stop_RU_war_in_UA Jan 23 '21 at 14:12
  • А еще мне кажется что если бы это реально приводило к увеличению скорости, то это реализовали бы уже давно на апаратном уровне или на уровне драйвера. А это значит что распаралеливание на высоком програмном уровне было бы бесполезным в таком случае. – Andrew Stop_RU_war_in_UA Jan 23 '21 at 14:20
  • @АртёмОконечников ну вот, аэпот подтердил мою догадку на практике - вероятно или драйвер или железо ставит ограничение и все равно делает в одном потоке) – Andrew Stop_RU_war_in_UA Jan 23 '21 at 20:50
  • Я уже туда плюс поставил, ага. Но мне было интересно именно со стороны железки ограничения понять. Потому что сейчас особых отличий SSD от RAM я не вижу. В принципе, можно предположить почему так в драйвере сделано. Все-таки у нас R/W, а файлы ресурс разделяемый и т.д. – Артём Оконечников Jan 23 '21 at 21:18
  • отличия есть и весьма приличные. Это же разные виды памяти в принципе. Flash Memory в любом случае будет медленнее за DRAM. Современная оператива может иметь скорость и в 20GB/s, которая просто недоступна SSD. Хотя когда-то практиковали SSD на основе плашек оперативки, но туда приходилось впихивать еще и аккумулятор. Кроме того нормальные материнки еще и разделяют оперативку по каналам, а это значит что максимальная скорость растет еще более т.к. общая ширина канала менее ограничена. Это можно увидеть когда шины под оперативку разделены по цветам слотов - один цвет - один канал. – Andrew Stop_RU_war_in_UA Jan 23 '21 at 21:26
  • Я возможно криво мысль свою описал. RAM просто память с произвольным доступом. С произвольным за одинаковое время. В HDD логика понятная — одна головка — параллельно не почитаешь. Время доступа к файлам тоже разное в зависимости от их расположения на диске (физически). Я полез читать вики, там и про RAM SSD нашел и вот это нашел: «количество произвольных операций ввода-вывода в секунду выше... за счёт возможности одновременного запуска множества операций». – Артём Оконечников Jan 23 '21 at 21:59
4

Просто скину как один из вариантов

if (searchOption == SearchOption.AllDirectories)
{
    IEnumerable<string> subDirs = Directory.EnumerateDirectories(rootPath);
    return subDirs.Concat(subDirs.AsParallel().SelectMany(dir => GetDirectoryFiles(dir, patternMatch, searchOption)));
}   

Код не запускал, так что пробуйте сами.

tym32167
  • 32,857
  • В этом коде проблема в том, что в случае ошибки (неавторизованный доступ к одной из папок/файлов) не будет получен ни один файл. – Alexander Petrov Jan 23 '21 at 10:23
  • Я показал принцип, конструкции try-catch уже есть в самом вопросе. – tym32167 Jan 23 '21 at 13:06