Прочитав статью 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 раз
Загрузка процессора при чтении 6.5 MB файла 100 раз
Как видим 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)
mayorovp
12.09.2017 16:33Почему
Multithread loop readFileSync
дает такой низкий результат? Это очень странно. Возможно, тесту для исполнения попросту не хватало памяти? Или антивирус шалил?
Надо было попробовать создавать процессы заранее, и через
fork
вместоexecFile
, а в тесте выдавать процессам задания.
bohdan4ik
12.09.2017 17:09+1Данный бенчмарк, как верно заметили выше, измеряет скорость взятия одного файла с одного диска.
Преимущество асинхронных IO запросов раскроется только если одновременно читать/писать файлы с разных дисков, разных сетевых подключений и так далее.
mayorovp
12.09.2017 17:14-1На самом деле все еще хуже. Нет смысла читать один и тот же файл с диска много раз: надо прочесть его 1 раз и использовать. Это будет еще быстрее.
Если же файл нельзя прочитать и запомнить — значит, он слишком большой. Но тогда и читать его в память целиком тоже нельзя — нужно использовать потоковое чтение. А у него синхронной версии просто нет.
mickvav
12.09.2017 19:52Что-то мне подсказывает, что для реально больших (100мб и больше) файлов ещё сильно взыграет hdd/ssd эффект — грубо говоря multithread read на hdd должен загнуться по отношению к single thread, а на ssd — наоборот, вырасти слегка.
mayorovp
12.09.2017 19:58multithread read на hdd тоже может вырасти от одновременности, благодаря появлению у дискового драйвера возможности выбирать какой блок читать первее. Но это только при условии достаточного размера файловых буферов.
vintage
12.09.2017 20:53Ну и поделюсь своей болью:
Клиент запрашивает 4 файла. Генерация каждого из них занимает секунду. Из-за асинхронной выдачи файла получается, что пока не сгенерятся все 4 — клиент не сможет загрузить даже первый сгенеренный.
Как это выглядело бы в синхронном виде: сгенерили первый файл за секунду — отдали, второй ещё через секунду и тд. Но такой опции в ноде нет. :-(
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); });
По идее, клиент будет видеть контент по мере готовности, если я правильно понял задачу.
vintage
13.09.2017 08:07+1Не, тут проблема в том, что первый файл уже начался отдаваться, но не может закончиться, так как сервер тут же берётся за генерацию следующего файла. Ну и никакого списка файлов нет — клиент их запрашивает отдельными запросами.
justboris
13.09.2017 10:56а почему первый файл не может до конца отдаться?
файлы генерируются синхронно и блокируют ввод/вывод?vintage
13.09.2017 11:29Да, генерятся синхронно, а отдаются асинхронно по частям.
justboris
13.09.2017 11:46самый очевидный ответ: генерируйте в отдельном процессе, не блокируя основной.
Или с этим тоже не так просто?vintage
13.09.2017 11:58О том и речь, что нужно креативить поднятие генератора в отдельном процессе и гонять между ними данные. А это уже ничем не отличается от того же синхронного php спрятанного за веб-сервером.
justboris
13.09.2017 12:01Если у вас задача "сделать не как у php", то это сложно, да.
А в целом, будет же нормально работать. Возможно понадобится использовать что-то типа worker pool, чтобы процессов было не слишком много.vintage
13.09.2017 12:12+1Моя задача сделать хорошо и в концепции ноды это не так просто. Мне ещё и кешировать надо промежуточные результаты, так что в воркер нужно выносить фактически всё приложение, оставляя в основном процессе голый экспресс.
Alternator
13.09.2017 14:49Исходная задача(синхронные операции длительностью в 1 секунду) — уже не в концепции ноды
Это решается либо переносом в воркер, либо превращением синхронного кода в асинхронный.
В последнем случае, начиная с корневой функции вашего генератора превращаете ее в async-функцию, сдабривая await-ами nextTick-а и await-ами дочерних вызовов
Затем делаете тоже самое с дочерними функциями
Продолжаем до тех пор пока синхронный код между двумя await-ами не будет занимать приемлемое время(например меньше 50мс)
В итоге во время генерации нового файла предыдущие файлы прекрасно могут отдаваться
Более того, если генерацию запросили одновременно два клиента, то второй не будет ждать первого, а их файлы будут генериться параллельно(но дольше). Если нужно это предотвратить — оберните внешнюю функцию в однопоточный семафорvintage
13.09.2017 15:20-1Боюсь я не готов ещё перелопачивать несколько мегабайт исходников компилятора тайпскрипта :-)
justboris
13.09.2017 17:26А может все-таки лучше попробовать Webpack и его Dev-server?
Будет вам и пересборка на лету и кеширование не изменявшихся файловvintage
13.09.2017 17:52Он разве умеет собирать бандлы исходя из урла (а не только те, для которых прописаны конфиги), автоматически детектировать зависимости (а не писать портянки импортов), подключать директории целиком (а не каждый файл по отдельности), выдавать всегда актуальный результат (а не результат последней пересборки в n процентах случаев), адекватно разрешать циклические зависимости (с учётом их приоритета)?
justboris
13.09.2017 23:09Ядро вебпака умеет только файлики клеить по импортам. Все ваши хотелки могут быть плагинами:
собирать бандлы исходя из урла
вот тут не очень понятно. Как он будет собирать то, для чего конфиги не написаны? Нужен более конкретный пример
автоматически детектировать зависимости
напишите свой лоадер, вызывайте в нем addDependency по своим правилам
подключать директории целиком
выдавать всегда актуальный результат
webpack-dev-server придерживает запрос и резолвит его только по окончании пересборки.
адекватно разрешать циклические зависимости
видимо это к автоматической детекции зависимостей. Напишите свой лоадер, обрабатывайте там приоритеты по своим правилам.
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 } }
Напишите свой лоадер, обрабатывайте там приоритеты по своим правилам.
Столько своего надо написать… Вот и получается, что от вебпака остаётся лишь:
только файлики клеить
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 и другие ништяки подключаются в пару строк.
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 у нас тоже работает.
и другие ништяки
Какие?
mayorovp
14.09.2017 16:06Насколько я знаю, webpack собирает все зависимости в одном процессе. И если генерация файла останавливает цикл обработки сообщений на секунду — то и в дев-сервере вебпака будет происходить то же самое.
justboris
14.09.2017 16:10Подобные оптимизации мне кажутся экономией на спичках. Если у вас такая большая кодовая база, что пересборка уже запущенного проект занимает ощутимое время, то вы явно делаете что-то не то.
Надо подумать об уменьшении кодовой базы, разнесения ее на части через DllPlugin, а не пытаться разогнать сборку.
mayorovp
14.09.2017 16:11Причем тут "разгон сборки"? Проблема же не в медленной сборке, а в том что это получилась stop-the-world сборка.
justboris
14.09.2017 16:14О каком world вы говорите? Dev-сервер запускается локально и отдает файлы только вам, никого другого он не блокирует.
mayorovp
14.09.2017 16:16Читайте внимательнее:
То, что для одного "прод", для другого — "окружение разработчика" :-)
Я, правда, так и не понял это это означает — то ли кто-то на проде разрабатывает, то ли кто-то на чужой дев-сервер ходит...
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!!
а к топикстартеру вопросов больше не имею. Надеюсь, он скоро потрясет наш мир новым удивительным способом разработки и деплоя, с пересборкой тайпскрипта на живом продашене
vintage
14.09.2017 17:48Для разработчика инструмента "прод" — машина разработчика приложений.
mayorovp
15.09.2017 06:45У вас разработчик инструмента ковыряется прямо в машине разработчика приложений?
Или разработчик приложений зачем-то каждый раз пересобирает свои инструменты?
vintage
15.09.2017 08:39Разработчик приложения пересобирает приложение моим инструментом. Что тут не понятно?
mayorovp
15.09.2017 08:44В таком случае при чем тут прод? Обычная ситуация с дев-сервером.
Чем же в таком случае вас не устраивает стандартный подход — фоновая пересборка проекта при изменениях?
Которая запускается не в момент запроса страницы, а в момент изменения исходника?
vintage
15.09.2017 09:07В таком случае при чем тут прод?
А что такое "прод" по вашему?
Чем же в таком случае вас не устраивает стандартный подход — фоновая пересборка проекта при изменениях?
vintage
14.09.2017 17:27TS не быстро компилирует и ему нужны все файлы, чтобы протрекать все типы. У меня есть мысль сделать пофайловую компиляцию с подсовыванием нагенеренных d.ts от зависимостей. Тогда горячая пересборка думаю будет почти мгновенной.
vvadzim
13.09.2017 15:27Вешать ноду на всю секунду генерируя файл — это в ноде антипаттерн. На такой режим она не рассчитана.
Варианта 2:
1) тот что уже подсказали выше — отдельный процесс/поток для генерации.
2) использовать асинхронный код (async function ...
) для генерации и расставлять местами в циклах yield без параметров а-ля Windows 3.x. Костыльнее и неочевиднее код, как следствие можно не понять откуда баги. Но если есть навыки — почему бы и нет.
AndreyRubankov
14.09.2017 00:08Можно попробовать следующий подход:
— быстро сгенерировать список уникальных имен для файлов и отдать клиенту
— параллельно запустить процесс генерации файлов
Клиент получает список file_ids – и запрашивает их на скачивание, получает каждый по готовности.
ps: в целом, это даже не проблема ноды, это довольно распространенная задача при генерации контента.vintage
14.09.2017 11:04На Go, D, PHP эта задача решается тривиально, и только в ноде нужно плясать с отдельными процессами. Но нет, это не проблема ноды :-)
AndreyRubankov
14.09.2017 11:35-1Возможно я не до конца понимаю проблему, но в php это было бы примерно так же как я описал. Или основной консерн – отдельные инстансы ноды или кластер?
ps: да и вообще, нода не предназначена для генерации / парсинга и любых других CPU-bound задач. Ее удел – взять темплейт, подставить значения и отдать наружу. Или сходить на другие сервисы, получить контент и связать его воедино.vintage
14.09.2017 15:28К сожалению TS компилятор только под ноду завезли :(
mayorovp
14.09.2017 15:50Компиляторы других языков, как правило, требуется запускать в отдельном процессе — загрузка компилятора как модуля зачастую вовсе не предусмотрена.
Так что если в общем процессе компилятор ведет себя как-то неправильно — всегда есть возможность откатиться на unix-way, и недостатком ноды такое не является.
AndreyRubankov
14.09.2017 00:05Если я правильно понимаю, это работать не будет по двум причинам:
1. res.end(200) вы отдаете после того, как сгенерировали все 4 файла, а это уже через 4 сек. и если это так – ответ не уйдет принимающей стороне все это время.
2. разве мульти-парт контент может быть отдан для скачивания?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);
x512
1) Мне думается, что вы здесь намеряли не скорость чтения файла, а скорость взятия файла из кэша. Нужно читать разные файлы, а не один и тот же
2) Какой смысл теста без указания ОС, типа носителя и файловой системы? Там тоже результат может отличаться в разы!
Shvab Автор
Спасибо за комментарий, исправил.
x512
Отлично! и выводы из статьи стали гораздо точнее!