Прочитав статью Episode 8: Interview with Ryan Dahl, Creator of Node.js и комментарии к переводу, я решил протестировать эффективность блокирующей и неблокирующей операции чтения файла в Node.js, под катом таблицы и графики.


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


UPD2: Статья отредактирована, бенчмарк исправлен, результаты добавлены.


Блокирующая операция (fs.readFileSync одна из таких) предполагает что исполнение всего приложения будет приостановлено пока не связанные с JS на прямую операции не будут выполнены.


Неблокирующие опрации позволяют выполнять не связанные с JS операции асинхронно в параллельных потоках (например, fs.readFile).


Больше об blocking vs non-blocking здесь.


Хоть Node.js исполняется в одном потоке, с помощью child_process или cluster можно распределить исполнение кода на несколько потоков.


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


Все тесты проходили на одном компьютере, с одним HDD, а иммено:


OS: Ubuntu 16.04
Node.js version: 8.4.0
Processor: AMD Phenom(tm) 9750 Quad-Core Processor
Physical cores: 4
HDD: 2TB 7200rpm 64MB
File system type: ext4
file.txt size: 3.3 kB
bigFile.txt size: 6.5 MB

Результаты для закэшированого файла.


При чтении 3.3 kB файла 10 000 раз


Symbol Name ops/sec Percents
A Loop readFileSync 7.4 100%
B Promise chain readFileSync 4.47 60%
C Promise chain readFile 1.09 15%
D Promise.all readFileSync 4.58 62%
E Promise.all readFile 1.69 23%
F Multithread loop readFileSync 20.05 271%
G Multithread promise.all readFile 4.98 67%

При чтении 3.3 kB файла 100 раз


Symbol Name ops/sec Percents
A Loop readFileSync 747 100%
B Promise chain readFileSync 641 86%
C Promise chain readFile 120 16%
D Promise.all readFileSync 664 89%
E Promise.all readFile 238 32%
F Multithread loop readFileSync 1050 140%
G Multithread promise.all readFile 372 50%

При чтении 6.5 MB файла 100 раз


Symbol Name ops/sec Percents
A Loop readFileSync 0.63 83%
B Promise chain readFileSync 0.66 87%
C Promise chain readFile 0.61 80%
D Promise.all readFileSync 0.66 87%
E Promise.all readFile 0.76 100%
F Multithread loop readFileSync 0.83 109%
G Multithread promise.all readFile 0.81 107%

Загрузка процессора при чтении 3.3 kB файла 10 000 раз
file.txt, reading 10000 times


Загрузка процессора при чтении 6.5 MB файла 100 раз
bigFile.txt, reading 100 times


Как видим fs.readFileSync всегда исполняется в одном потоке на одном ядре. fs.readFile в своей работе использует несколько потоков, но ядра при этом загружены не на полную мощность. Для небольшого файла fs.readFileSync работает быстрее чем fs.readFile, и только при чтении большого файла при ноде запущенной в одном потоке fs.readFile исполняется быстрее чем fs.readFileSync.


Следовательно, чтение небольших файлов лучше проводить с помощью fs.readFileSync, а больших файлов с помощью fs.readFile (насколько файл должен быть большой зависит от компьютера и софта).


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


UPD2
Ниже данные полученные для при чтении множества незакешированых файлов одинакового размера (3,3kB).


При чтении по 1000 файлов


Symbol Name ops/sec Percents
A Loop readFileSync 8.47 74%
B Promise chain readFileSync 6.28 55%
C Promise chain readFile 5.49 48%
D Promise.all readFileSync 8.06 70%
E Promise.all readFile 11.05 100%
F Multithread loop readFileSync 3.71 32%
G Multithread promise.all readFile 5.11 44%

При чтении по 100 файлов


Symbol Name ops/sec Percents
A Loop readFileSync 79.19 85%
B Promise chain readFileSync 50.17 54%
C Promise chain readFile 48.46 52%
D Promise.all readFileSync 54.7 58%
E Promise.all readFile 92.87 100%
F Multithread loop readFileSync 80.46 86%
G Multithread promise.all readFile 92.19 99%

Загрузка процессора при чтении незакешированых файлов небольшая, порядка 20%. Результаты варьируются ± 30%.
По результатам видно что использовать неблокирующий fs.readFile выгоднее.


Пример ситуации чтения файла.


Допустим у нас крутится веб сервер на ноде в одном потоке T1. На сервер одновременно приходит два запроса (P1 и P2) чтения и обработки небольших файлов (по одному на запрос). При использовании fs.readFileSync последовательность исполнения кода в потоке Т1 будет выглядеть так:


P1 -> P2


При использовании fs.readFile последовательность исполнения кода в потоке Т1 будет выглядеть так:


P1-1 -> P2-1 -> P1-2 -> P2-2


Где P1-1, P2-1 — делегирование чтения в другой поток, P1-2, P2-2 — получение результатов чтения и обработка данных.

Комментарии (57)


  1. x512
    12.09.2017 16:15
    +2

    1) Мне думается, что вы здесь намеряли не скорость чтения файла, а скорость взятия файла из кэша. Нужно читать разные файлы, а не один и тот же
    2) Какой смысл теста без указания ОС, типа носителя и файловой системы? Там тоже результат может отличаться в разы!


    1. Shvab Автор
      13.09.2017 06:30
      +1

      Спасибо за комментарий, исправил.


      1. x512
        15.09.2017 11:50

        Отлично! и выводы из статьи стали гораздо точнее!


  1. mayorovp
    12.09.2017 16:33

    Почему Multithread loop readFileSync дает такой низкий результат? Это очень странно. Возможно, тесту для исполнения попросту не хватало памяти? Или антивирус шалил?


    Надо было попробовать создавать процессы заранее, и через fork вместо execFile, а в тесте выдавать процессам задания.


    1. Shvab Автор
      13.09.2017 06:33

      Бенчмарк исправил, результаты добавил.


  1. k12th
    12.09.2017 17:07

    Вроде как суть в том, что при readFile мы можем запустить другие операции (реагировать на http-запросы, посылать запрос в БД или там считать факториал), а при readFileAsync — нет.


    1. Suvitruf
      12.09.2017 17:43

      А не наоборот?


    1. RidgeA
      12.09.2017 17:44
      +2

      readFileAsync
      => readFileSync


  1. bohdan4ik
    12.09.2017 17:09
    +1

    Данный бенчмарк, как верно заметили выше, измеряет скорость взятия одного файла с одного диска.


    Преимущество асинхронных IO запросов раскроется только если одновременно читать/писать файлы с разных дисков, разных сетевых подключений и так далее.


    1. mayorovp
      12.09.2017 17:14
      -1

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


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


  1. mickvav
    12.09.2017 19:52

    Что-то мне подсказывает, что для реально больших (100мб и больше) файлов ещё сильно взыграет hdd/ssd эффект — грубо говоря multithread read на hdd должен загнуться по отношению к single thread, а на ssd — наоборот, вырасти слегка.


    1. mayorovp
      12.09.2017 19:58

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


  1. vintage
    12.09.2017 20:53

    Ну и поделюсь своей болью:


    Клиент запрашивает 4 файла. Генерация каждого из них занимает секунду. Из-за асинхронной выдачи файла получается, что пока не сгенерятся все 4 — клиент не сможет загрузить даже первый сгенеренный.


    Как это выглядело бы в синхронном виде: сгенерили первый файл за секунду — отдали, второй ещё через секунду и тд. Но такой опции в ноде нет. :-(


    1. justboris
      12.09.2017 23:26
      -3

      попробуйте так:


      app.get('/files', async (req, res) => {
         for(const file of files) {
            const content = await generateFile(file);
            res.write(content);
         }
         res.end(200);
      });

      По идее, клиент будет видеть контент по мере готовности, если я правильно понял задачу.


      1. vintage
        13.09.2017 08:07
        +1

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


        1. justboris
          13.09.2017 10:56

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


          1. vintage
            13.09.2017 11:29

            Да, генерятся синхронно, а отдаются асинхронно по частям.


            1. justboris
              13.09.2017 11:46

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


              1. vintage
                13.09.2017 11:58

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


                1. justboris
                  13.09.2017 12:01

                  Если у вас задача "сделать не как у php", то это сложно, да.
                  А в целом, будет же нормально работать. Возможно понадобится использовать что-то типа worker pool, чтобы процессов было не слишком много.


                  1. vintage
                    13.09.2017 12:12
                    +1

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


                    1. Alternator
                      13.09.2017 14:49

                      Исходная задача(синхронные операции длительностью в 1 секунду) — уже не в концепции ноды
                      Это решается либо переносом в воркер, либо превращением синхронного кода в асинхронный.

                      В последнем случае, начиная с корневой функции вашего генератора превращаете ее в async-функцию, сдабривая await-ами nextTick-а и await-ами дочерних вызовов
                      Затем делаете тоже самое с дочерними функциями
                      Продолжаем до тех пор пока синхронный код между двумя await-ами не будет занимать приемлемое время(например меньше 50мс)
                      В итоге во время генерации нового файла предыдущие файлы прекрасно могут отдаваться
                      Более того, если генерацию запросили одновременно два клиента, то второй не будет ждать первого, а их файлы будут генериться параллельно(но дольше). Если нужно это предотвратить — оберните внешнюю функцию в однопоточный семафор


                      1. vintage
                        13.09.2017 15:20
                        -1

                        Боюсь я не готов ещё перелопачивать несколько мегабайт исходников компилятора тайпскрипта :-)


                        1. mayorovp
                          13.09.2017 15:31

                          А он-то тут каким боком? Вы что, на проде компилируете скрипты на лету?


                          1. vintage
                            13.09.2017 15:43
                            -1

                            То, что для одного "прод", для другого — "окружение разработчика" :-)


                            1. mayorovp
                              13.09.2017 15:48

                              Ужас.


                              1. vintage
                                13.09.2017 15:55

                                Ужас-то в чём?


                        1. vvadzim
                          13.09.2017 16:18
                          -1

                          Ну тогда — воркер. Исходники даже делить не нужно.
                          if (cluster.isMaster) { запускаем express и воркер } else { обрабатываем запросы на генерацию файлов }


                          1. mayorovp
                            13.09.2017 17:27

                            Рабочие процессы кластера уже заняты express — так что пул воркеров придется самому писать.


                        1. justboris
                          13.09.2017 17:26

                          А может все-таки лучше попробовать Webpack и его Dev-server?
                          Будет вам и пересборка на лету и кеширование не изменявшихся файлов


                          1. vintage
                            13.09.2017 17:52

                            Он разве умеет собирать бандлы исходя из урла (а не только те, для которых прописаны конфиги), автоматически детектировать зависимости (а не писать портянки импортов), подключать директории целиком (а не каждый файл по отдельности), выдавать всегда актуальный результат (а не результат последней пересборки в n процентах случаев), адекватно разрешать циклические зависимости (с учётом их приоритета)?


                            1. justboris
                              13.09.2017 23:09

                              Ядро вебпака умеет только файлики клеить по импортам. Все ваши хотелки могут быть плагинами:


                              собирать бандлы исходя из урла

                              вот тут не очень понятно. Как он будет собирать то, для чего конфиги не написаны? Нужен более конкретный пример


                              автоматически детектировать зависимости

                              напишите свой лоадер, вызывайте в нем addDependency по своим правилам


                              подключать директории целиком

                              require.context()


                              выдавать всегда актуальный результат

                              webpack-dev-server придерживает запрос и резолвит его только по окончании пересборки.


                              адекватно разрешать циклические зависимости

                              видимо это к автоматической детекции зависимостей. Напишите свой лоадер, обрабатывайте там приоритеты по своим правилам.


                              1. vintage
                                14.09.2017 10:53

                                Как он будет собирать то, для чего конфиги не написаны? Нужен более конкретный пример

                                Запросил клиент /mol/app/files/-/web.js — собрался бандл, включающий как собственно содержимое /mol/app/files, так и всех необходимых этому приложению зависимостей. Всего приложений в одной кодовой базе десятки, а модулей, которые можно скомпилировать в независимые библиотеки — сотни. Аналогично и с web.test.js, web.view.tree, web.css и ку чей других типов бандлов.


                                напишите свой лоадер, вызывайте в нем addDependency по своим правилам

                                Ему можно скормить директорию, чтобы он сам подключил все файлы из неё в правильном порядке?


                                require.context()

                                Речь про директории как зависимости, а не про резолв путей.


                                webpack-dev-server придерживает запрос и резолвит его только по окончании пересборки.

                                И тут мы возвращаемся к началу ветки. Если нужно сгенерить 4 файла, то первый будет отдан, когда будут все 4 файла сгенерены или они реализовали вынос TS компилятора в отдельный воркер?


                                видимо это к автоматической детекции зависимостей.

                                Нет, это к разрешению циклических зависимостей.


                                class Logger extends Object {}

                                class Object {
                                    logger() {
                                        return new Logger
                                    }
                                }

                                Напишите свой лоадер, обрабатывайте там приоритеты по своим правилам.

                                Столько своего надо написать… Вот и получается, что от вебпака остаётся лишь:


                                только файлики клеить


                                1. justboris
                                  14.09.2017 15:58
                                  +1

                                  Запросил клиент /mol/app/files/-/web.js — собрался бандл, включающий как собственно содержимое /mol/app/files

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


                                  Ему можно скормить директорию, чтобы он сам подключил все файлы из неё в правильном порядке?

                                  Ваш лоадер, ваши правила: fs.readdir().forEach(() => this.addDependency()). Будет как вам надо


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

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


                                  Разделить сборку разных страниц по отдельности можно. webpack([configForPage1, configForPage2, ...]). Вебпак будет собирать несколько бандлов параллельно, загрузку друг друга они не блокируют


                                  Нет, это к разрешению циклических зависимостей.

                                  В спецификации CommonJS определено, что делать в этом случае. Webpack ей следует. Ваш пример будет работать как надо.


                                  Столько своего надо написать… Вот и получается, что от вебпака остаётся лишь:

                                  А также вам дается готовая экосистема уже готовых плагинов. Autoprefixer, инлайн картинок, babel/typerscript и другие ништяки подключаются в пару строк.


                                  1. vintage
                                    14.09.2017 17:40
                                    -1

                                    И скорее всего потому, что большинству пользователей и без нее хорошо.

                                    Стокгольмский синдром.


                                    Если очень нужно, можете запилить свой dev-сервер на основе существующей миддлвары

                                    А чем express не угодил?


                                    Ваш лоадер, ваши правила: fs.readdir().forEach(() => this.addDependency()). Будет как вам надо

                                    Я пробовал реализовывать похожую схему. У неё 2 недостатка: медленно при большом числе файлов и в банд файлы из одного пакета попадают не рядом а вразнобой.


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

                                    2 бандла необходимы сразу, ещё 3 подгружаются уже после. Генерация web.js — 6 секунд, генерация web.test.js — ещё 6. Появление страницы через 6 секунд лучше, чем через 12.


                                    В спецификации CommonJS определено, что делать в этом случае. Webpack ей следует.

                                    CommonJS предполагает явное объявление зависимостей, а у нас они трекаются автоматически.


                                    А также вам дается готовая экосистема уже готовых плагинов.

                                    Раздутая экосистема, настройка которой сравнима с написанием того же руками.


                                    Autoprefixer

                                    postCSS у нас и так подключен.


                                    инлайн картинок

                                    нафиг надо


                                    babel/typerscript

                                    TS у нас тоже работает.


                                    и другие ништяки

                                    Какие?


                          1. mayorovp
                            14.09.2017 16:06

                            Насколько я знаю, webpack собирает все зависимости в одном процессе. И если генерация файла останавливает цикл обработки сообщений на секунду — то и в дев-сервере вебпака будет происходить то же самое.


                            1. justboris
                              14.09.2017 16:10

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


                              Надо подумать об уменьшении кодовой базы, разнесения ее на части через DllPlugin, а не пытаться разогнать сборку.


                              1. mayorovp
                                14.09.2017 16:11

                                Причем тут "разгон сборки"? Проблема же не в медленной сборке, а в том что это получилась stop-the-world сборка.


                                1. justboris
                                  14.09.2017 16:14

                                  О каком world вы говорите? Dev-сервер запускается локально и отдает файлы только вам, никого другого он не блокирует.


                                  1. mayorovp
                                    14.09.2017 16:16

                                    Читайте внимательнее:


                                    То, что для одного "прод", для другого — "окружение разработчика" :-)

                                    Я, правда, так и не понял это это означает — то ли кто-то на проде разрабатывает, то ли кто-то на чужой дев-сервер ходит...


                                    1. justboris
                                      14.09.2017 16:20

                                      В документации webpack-dev-server прямым текстом написано


                                      The tools in this guide are only meant for development, please avoid using them in production!!

                                      а к топикстартеру вопросов больше не имею. Надеюсь, он скоро потрясет наш мир новым удивительным способом разработки и деплоя, с пересборкой тайпскрипта на живом продашене


                                    1. vintage
                                      14.09.2017 17:48

                                      Для разработчика инструмента "прод" — машина разработчика приложений.


                                      1. mayorovp
                                        15.09.2017 06:45

                                        У вас разработчик инструмента ковыряется прямо в машине разработчика приложений?


                                        Или разработчик приложений зачем-то каждый раз пересобирает свои инструменты?


                                        1. vintage
                                          15.09.2017 08:39

                                          Разработчик приложения пересобирает приложение моим инструментом. Что тут не понятно?


                                          1. mayorovp
                                            15.09.2017 08:44

                                            В таком случае при чем тут прод? Обычная ситуация с дев-сервером.


                                            Чем же в таком случае вас не устраивает стандартный подход — фоновая пересборка проекта при изменениях?


                                            Которая запускается не в момент запроса страницы, а в момент изменения исходника?


                                            1. vintage
                                              15.09.2017 09:07

                                              В таком случае при чем тут прод?

                                              А что такое "прод" по вашему?


                                              Чем же в таком случае вас не устраивает стандартный подход — фоновая пересборка проекта при изменениях?

                                              https://github.com/eigenmethod/mol/issues/254


                              1. vintage
                                14.09.2017 17:27

                                TS не быстро компилирует и ему нужны все файлы, чтобы протрекать все типы. У меня есть мысль сделать пофайловую компиляцию с подсовыванием нагенеренных d.ts от зависимостей. Тогда горячая пересборка думаю будет почти мгновенной.


                    1. vvadzim
                      13.09.2017 15:27

                      Вешать ноду на всю секунду генерируя файл — это в ноде антипаттерн. На такой режим она не рассчитана.
                      Варианта 2:
                      1) тот что уже подсказали выше — отдельный процесс/поток для генерации.
                      2) использовать асинхронный код (async function ...) для генерации и расставлять местами в циклах yield без параметров а-ля Windows 3.x. Костыльнее и неочевиднее код, как следствие можно не понять откуда баги. Но если есть навыки — почему бы и нет.


                      1. vvadzim
                        13.09.2017 16:12

                        await конечно, не yield. Хотя выше лучше расписали.


        1. AndreyRubankov
          14.09.2017 00:08

          Можно попробовать следующий подход:
          — быстро сгенерировать список уникальных имен для файлов и отдать клиенту
          — параллельно запустить процесс генерации файлов

          Клиент получает список file_ids – и запрашивает их на скачивание, получает каждый по готовности.

          ps: в целом, это даже не проблема ноды, это довольно распространенная задача при генерации контента.


          1. vintage
            14.09.2017 11:04

            На Go, D, PHP эта задача решается тривиально, и только в ноде нужно плясать с отдельными процессами. Но нет, это не проблема ноды :-)


            1. AndreyRubankov
              14.09.2017 11:35
              -1

              Возможно я не до конца понимаю проблему, но в php это было бы примерно так же как я описал. Или основной консерн – отдельные инстансы ноды или кластер?

              ps: да и вообще, нода не предназначена для генерации / парсинга и любых других CPU-bound задач. Ее удел – взять темплейт, подставить значения и отдать наружу. Или сходить на другие сервисы, получить контент и связать его воедино.


              1. vintage
                14.09.2017 15:28

                К сожалению TS компилятор только под ноду завезли :(


                1. mayorovp
                  14.09.2017 15:50

                  Компиляторы других языков, как правило, требуется запускать в отдельном процессе — загрузка компилятора как модуля зачастую вовсе не предусмотрена.


                  Так что если в общем процессе компилятор ведет себя как-то неправильно — всегда есть возможность откатиться на unix-way, и недостатком ноды такое не является.


                  1. vintage
                    14.09.2017 15:56

                    Конечно же нода идеальна :-)


      1. AndreyRubankov
        14.09.2017 00:05

        Если я правильно понимаю, это работать не будет по двум причинам:
        1. res.end(200) вы отдаете после того, как сгенерировали все 4 файла, а это уже через 4 сек. и если это так – ответ не уйдет принимающей стороне все это время.
        2. разве мульти-парт контент может быть отдан для скачивания?


        1. justboris
          14.09.2017 00:29

          Ну да, немного напутал в правильном вызове методов. Вот работающее демо:


          Код примера
          const http = require('http');
          const {promisify} = require('util');
          
          const delayed = promisify(setTimeout);
          
          const files = ['first', 'second', 'third', 'forth'];
          
          http.createServer(async (request, response) => {
              response.setHeader('Content-Type', 'text/html; charset=UTF-8');
              response.setHeader('Transfer-Encoding', 'chunked');
          
              response.write('<h1>Chunked transfer encoding test</h1><ul>');
              for(const file of files) {
                  await delayed(2000);
                  response.write(`<li>${file}</li>`);
              }
              response.end('</ul><h5>Done</h5>');
          }).listen(8080);