26.04.2022

Копирование файлов

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

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

Речь тут идет об использовании каких-либо приложений, поддерживающих постановку задач копирования файлов в очередь. Я пользуюсь Total Commander.

Возможность подобной оптимизации конечно зависит от аппаратной части компьютера: позволяет ли подсистема ввода-вывода параллельные операции на физически разные накопители.

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

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

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

Издержки же на файловый кэш не очень значительны. Во-первых, это память. Но при нынешних ценах на ОЗУ в компьютерах её обычно с избытком, так что это не проблема.
Во-вторых, двойные пересылки память-память. Первый раз при чтении с устройства в буфер системы кэширования, затем второй раз при копировании из кэша в буфер приложения.
При записи те же два этапа сохраняются, только идут в обратной последовательности.
Так как современное ОЗУ весьма производительно, то пересылки память-память не сильно снижают производительность.

Все это была преамбула. Теперь же перейду к амбуле. Решил я посмотреть и проверить, а нельзя ли ускорить процесс копирования файлов?

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

А для самого приложения решил реализовать следующую идею: есть набор потоков-"читателей", по одному на каждое физическое устройство хранения данных, и симметричный набор потоков-"писателей". Еще есть управляющий поток, который формирует задание "читателям", "читатели" в свою очередь читают данные из файла в буфер, это буфер передают соответствующим "писателям".
Идея здесь в том, что пока одно физическое устройство ждет завершения операции ввода/вывода, другое может в параллель тоже что-нибудь прочитать/записать.

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

Как ни обидно, особого выигрыша по сравнению с несколькими копиями copy/xcopy достичь не удалось. 😂 Как, впрочем и проигрыша. У xcopy, кстати, тоже есть опция копирования без кэширования, как раз для больших файлов.
Есть подозрение, что причиной всему небольшие задержки в переключении потоков между "читателям" и "писателями".

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

14 комментариев:

  1. По моему мнению, файловый кэш очень полезная и безальтернативная фича, по крайней мере пока  SSD не сравняются с памятью по скоростью и живучестью:
    1. Запуск ворда требует чтение огромного числа относительно маленьких исполняемых файлов, конфигурации и т.д., чтение их из кэша должно существенно ускорять запуск программы. В том же  Linux новые процессы запускаются сотнями, и без кэша было бы печально.
    2. Издержек на память по сути нет, так как файловый кэш использует только свободную память, по крайней мере в  Linux и macOS и динамически изменятся в размерах.
    3. Без системного кэша не понятно как реализовать кооперативный режим работы программ, скажем открыли видео-редактор, он занял всю доступную память буферами видео файлов, далее переключаемся в другую программу, а ей памяти не хватает. Дабы такого не было, те же базы данных позволяют настроить размер буфера и т.д., но это не реально сложно для обычного пользователя.
    4. Даже Postgres использует системный кэш, во первых это платформо-независимо, а во вторых позволяет оптимизировать запись на физический диск.
    5. Еще интересно что буферизация не приводит к долговременному дублированию в памяти: то что буферизировано, не требует чтения из кэша и как результат со временем вытесняется из кэша.

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

    ОтветитьУдалить
    Ответы
    1. Хотя, нет копировать несколько файлов параллельно между одним и тем же диском думаю не будет быстрее, ОС всегда будет пытаться копировать их одновременно, что думаю увеличит полное время. Тогда остается только асинхронный ввод-вывод вместо множества потоков чтобы исключить синхронизацию потоков, возможно даст крошечный прирост, но не факт.

      Удалить
    2. 1. Ну, первый запуск ворда, что с кэшем, что без кэша будет не очень быстрый. Потому что файлов в кэше еще нет. Правда, если ворд написан без оптимизации дисковых операций, то да, кэш его ускорит и при первом запуске. Существенное ускорение будет, если ворд закрыть, тут же вспомнить, что нужно что-то доделать и запустить его заново. Не знаю, насколько часто встречается такой сценарий.
      2. В виндах тоже также. Проблема начинается, когда какой-то программе нужно много памяти. Соответственно, требуется время, что бы кэш эту память освободил, особенно если в нем есть несохраненные данные. Получаются наоборот тормоза и их можно вживую наблюдать при запуске требовательного к памяти приложения сразу после массированного копирования файлов.
      3. Не знаю даже. По-моему, такая проблема была лет двадцать назад актуальна. Сейчас, когда в систему можно недорого поставить и 64 и 128 ГиБ памяти, трудно найти разумный сценарий, когда памяти не хватит.
      4. Серьезно? Я в шоке! Вроде хорошие, хорошо оптимизированные DBMS как раз использовали собственный, более эффективный механизм кэширования, заточенный именно под особенности работы баз данных? Индексы там, кластеры и вот это вот всё. Всё это уже в прошлом?

      Ты прав, параллельное копирование файлов замедляет процесс, так как потоки (или процессы) начинают конкурировать за ресурсы, растет очередь к физическому диску и производительность падает катастрофически. Но если сделать два процесса/потока, которые параллельно копируют файлы с разных на разные устройства, то вот тут выигрыш будет существенный.

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

      Удалить
    3. 1.  Как часто люди перезапускают ворд честно говоря не знаю, но вот то что я по много раз в день перезапускаю программы которые загружают кучу разных 3rd-party библиотек из отдельных файлов - это точно. Поменял несколько строчек кода, перезапустил.
      2. У меня в основном кэш на чтение, я очень редко изменяю большие файлы.
      3. Возможно, но вот каждые 2-3 года получая новый ноут часто с удвоенной памятью думаю вот теперь точно хватит, а на деле все еще приходится закрывать-открывать приложения в течении дня. Хотелось бы как раз чтобы они по меньше сами буферизировали, но правда без потери производительности :)
      4. Постгрес использует как собственный буфер, так и системный файловый кэш. Вроде работает, как-то, но я не особо в теме.

      При классическом подходе: операции ввода-вывода блокирующие и как результат чтобы их распараллелить приходится использовать множество потоков, но есть более новое API для работы в не блокирующем режиме. Я не особо знаю про Windows, но вот здесь как-то описано: https://docs.microsoft.com/en-us/windows/win32/fileio/synchronous-and-asynchronous-i-o
      https://docs.microsoft.com/en-us/windows/win32/fileio/synchronous-and-asynchronous-i-o

      Удалить
    4. Ну сами по себе асинхронные вызовы стары, как мир. Но крайне неудобны в программировании. Отправить-то асинхронный запрос на выполнение медленной операции мы можем, сложности возникают при одновременном выполнении какой-то полезной работы и отслеживании того момента, когда операция завершится. Такой подход неплох, когда никакой особо полезной работы делать не надо, просто ждем вызова callback и всё.
      Тут же идея в том, что бы по возможности работать, работать и работать.

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

      Удалить
    6. Может, я чего не понимаю, но мне кажется, это мнимая эффективность. Ведь ожидание по селектору означает бесконечный цикл, а значит, придется загрузить этот поток (и одно ядро) на 100 процентов. Либо вставлять туда команду на приостановку потока, тогда мы также теряем эффективность, так как не сразу обработаем срабатывание селектора.
      Кроме того, если почти одновременно завершатся две операции ввода-вывода, то один поток в асинхронном режиме не сможет их обработать параллельно, а вот два потока - легко.

      Удалить
    7. > Ведь ожидание по селектору означает бесконечный цикл

      Нет, селектор использует блокирующий API, в виндовс как я понял это https://docs.microsoft.com/en-us/windows/win32/api/ioapiset/nf-ioapiset-getqueuedcompletionstatus и , но я только мельком глянул, что-то там все совсем не как в Linux :) Как я понимаю аналог селектора - это Completion Port: https://docs.microsoft.com/en-us/windows/win32/fileio/i-o-completion-ports и его как-то можно связать с множеством файлов.

      > если почти одновременно завершатся две операции ввода-вывода, то один поток в асинхронном режиме не сможет их обработать параллельно

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

      Удалить
    8. То ли мы о разных вещах говорим? У меня пока не складывается. Короче, асинхронный вызов всегда предполагает, что мы отправили команду ОС, а сами занялись другим полезным делом, а не ожиданием. Например, считаем там чего-то. После того, как полезное дело закончилось, мы спрашиваем у ОС, не завершила ли она команду, которую какое-то время назад мы ей отправили. Если не завершила, то мы можем еще чего-то полезное поделать, повторяя этот цикл.
      В общем, писать так можно, но сильно заморочно, кроме того, мы не сразу отреагируем на выполнение отправленной команды, а только после завершения очередной порции полезной работы, то есть с задержкой.
      Ну а Completion Port - это просто некая надстройка над асинхронными вызовами, которая просто собирает их всех в одном месте, но, тем не менее, не избавляет от необходимости регулярно проверять соответствующий порт на завершение операции.
      Так что пока что я остаюсь при своем мнении, что асинхронные методы - это немного анахронизм, который нужно использоваться в очень ограниченном количестве ситуаций, тем более что и сама MicroSoft пишет: "However, for relatively fast I/O operations, the overhead of processing kernel I/O requests and kernel signals may make asynchronous I/O less beneficial, particularly if many fast I/O operations need to be made", вот здесь https://docs.microsoft.com/en-us/windows/win32/fileio/synchronous-and-asynchronous-i-o

      Удалить
    9. > То ли мы о разных вещах говорим? У меня пока не складывается.
      Думаю об одном и том же, но я тот еще писатель, а по русски так вообще только с Вами и общаюсь на технические темы.

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

       > а не ожиданием
      Если мультиплексировать нечего - остается только ждать, больше потоку делать нечего.

      > Например, считаем там чего-то
      Да - это тоже возможно, но это замедлит IO, так как пока поток что-то считает, он не может мультиплексировать, поэтому обычно этот поток ничего больше не делает. Хотя бывают исключения, тот же redis вроде целиком однопоточный и это не мешает ему обрабатывать тысячи запросов в секунду.

      > В общем, писать так можно, но сильно заморочно
      100% согласен, но когда нужно мультиплексировать десятки тысяч сокетов особого выбора нету, потоки сильно тяжелые и требуют синхронизации. Тот же nginx построен на не блокирующем вводе-выводе.

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

      > Ну а Completion Port - это просто некая надстройка над асинхронными вызовами
      В linux у системного вызова epoll время выполнения O(1) в не зависимости сколько дескрипторов сгруппировано 10 или 10К, без поддержки на уровне ядра - это невозможно сделать. Completion Port вроде как аналогичный API, только от Microsoft, но я не уверен.

      > асинхронные методы - это немного анахронизм
      Про windows не знаю, в linux системному вызову select действительно сто лет в обед, но у него и время выполнения вроде линейно от числа дескрипторов, а вот epoll относительно новый, кажется 2004 год.

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

      > тем более что и сама MicroSoft пишет
      Вот здесь вообще не знаю, возможно для файлов асинхронный ввод/вывод действительно ничего не дает, но для сокетов async IO на linux - это стандартно. Возможно если в системе 1000 независимых дисков :))), то будет толк и с файлами, не стартовать же 1000 потоков, но в таком случае рейд массив куда как более универсальное решение.

      Удалить
    10. Оооо! Рейд-массив из 1000 дисков!? Это интересное решение!

      Удалить
    11. Да, на самом деле эти 1000 дисков - это тема. Наконец-то я понял, что ты имел в виду. Хороший пример, хоть и не реальный на практике.
      Более правдоподобный сценарий - это одновременная работа с 1000 файлов. Такое в принципе встречается сплошь и рядом. Любой более-менее нагруженный файловый сервис работает в таком режиме. И даже обычный офисный компьютер, ведь сейчас на нем запущено куча процессов, каждый из которых что-то пишет читает с диска. Несколько сотен открытых файлов вполне себе реальность в наше время.
      Вот только это не про мою задачу. Там нет ни 1000 дисков, ни даже 1000 файлов, с которым нужно именно ОДНОВРЕМЕННО работать, поэтому я не мог понять, что ты имеешь в виду.

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

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

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

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

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

      Удалить
    12. > То есть в классической архитектуре все асинхронные вызовы эмулируются отдельным потоком управления.

      А разве DMA + прерывание - это не асинхронный ввод/вывод как раз? По моему на уровне ОС все отличие в том какой поток прерывание пробуждает: при синхронном - поток что вызвал read/write, а при асинхронном - поток ждущий на условном Completion Port. Без DMA - согласен, асинхронный - это похоже надстройка над синхронном.

      > не столько из-за проблем с синхронизацией, сколько из-за сложности для планировщика как-то вменяемо их обработать

      Я не уверен что синхронизация здесь совсем не замедляет, даже при относительно разумном числе потоков, но сложно что-то сказать разумное без тестирования.

      Удалить
    13. > А разве DMA + прерывание - это не асинхронный ввод/вывод как раз?
      Асинхронный, но не совсем. Собственно, прерывание все и портит. То есть в момент прерывания мы должны остановить основной поток управления, как-то прерывание обработать. А в современных ОС на этом дело не заканчивается. Обработчик прерывания чаще всего генерирует событие, которая затем ОС помещает в очередь клиентскому приложению. И вот в клиентском приложении, когда основной поток управления дойдет до обработки событий, приложение наконец-то увидит, что асинхронный вызов наконец-то выполнен. И это - самый лучший и быстрый вариант. Все остальные еще хуже и медленнее.

      Удалить