Сравнение обещаний Node.js
Опубликовано: 2022-03-11Мы живем в дивном новом мире. Мир, наполненный JavaScript. В последние годы JavaScript доминирует в Интернете, штурмуя всю отрасль. После появления Node.js сообщество JavaScript смогло использовать простоту и динамичность языка, чтобы быть единственным языком, способным делать все, обрабатывая серверную и клиентскую стороны, и даже смело заявило о себе как о машинном обучении. Но за последние несколько лет JavaScript сильно изменился как язык. Были введены новые концепции, которых раньше не было, например, стрелочные функции и промисы.
А, обещания. Вся концепция обещания и обратного вызова не имела для меня большого смысла, когда я впервые начал изучать Node.js. Я привык к процедурному способу выполнения кода, но со временем понял, почему это важно.
Это подводит нас к вопросу, почему вообще были введены обратные вызовы и промисы? Почему мы не можем просто написать последовательно исполняемый код на JavaScript?
Ну, технически можно. Но должны ли вы?
В этой статье я дам краткое введение в JavaScript и его среду выполнения и, что более важно, проверю широко распространенное в сообществе JavaScript мнение, что синхронный код имеет низкую производительность и, в некотором смысле, просто зло, и никогда не должен использоваться. Действительно ли этот миф верен?
Прежде чем вы начнете, в этой статье предполагается, что вы уже знакомы с промисами в JavaScript, однако, если вы не знакомы или нуждаетесь в переподготовке, см. Промисы JavaScript: учебник с примерами.
NB. Эта статья была протестирована в среде Node.js, а не в среде чистого JavaScript. Запуск Node.js версии 10.14.2. Все тесты и синтаксис будут сильно зависеть от Node.js. Тесты проводились на MacBook Pro 2018 года с четырехъядерным процессором Intel i5 8-го поколения с базовой тактовой частотой 2,3 ГГц.
Цикл событий
Проблема с написанием JavaScript заключается в том, что сам язык является однопоточным. Это означает, что вы не можете выполнять более одной процедуры одновременно, в отличие от других языков, таких как Go или Ruby, которые имеют возможность создавать потоки и выполнять несколько процедур одновременно, либо в потоках ядра, либо в потоках процесса. .
Для выполнения кода JavaScript полагается на процедуру, называемую циклом событий, которая состоит из нескольких этапов. Процесс JavaScript проходит через каждую стадию и в конце начинается заново. Вы можете прочитать больше о деталях в официальном руководстве node.js здесь.
Но у JavaScript есть кое-что в рукаве, чтобы бороться с проблемой блокировки. Обратные вызовы ввода/вывода.
Большинство реальных случаев использования, которые требуют от нас создания потока, связаны с тем, что мы запрашиваем какое-то действие, за которое язык не отвечает, например, запрашиваем выборку некоторых данных из базы данных. В многопоточных языках поток, создавший запрос, просто зависает или ждет ответа от базы данных. Это просто пустая трата ресурсов. Это также накладывает на разработчика нагрузку при выборе правильного количества потоков в пуле потоков. Это сделано для предотвращения утечек памяти и выделения большого количества ресурсов, когда приложение пользуется большим спросом.
JavaScript превосходит в одном факторе больше, чем любой другой фактор, в обработке операций ввода-вывода. JavaScript позволяет вам вызывать операцию ввода-вывода, такую как запрос данных из базы данных, чтение файла в память, запись файла на диск, выполнение команды оболочки и т. д. Когда операция завершена, вы выполняете обратный вызов. Или в случае обещаний вы разрешаете обещание с результатом или отклоняете его с ошибкой.
Сообщество разработчиков JavaScript всегда советует нам никогда не использовать синхронный код при выполнении операций ввода-вывода. Общеизвестная причина этого заключается в том, что мы НЕ хотим блокировать выполнение других задач нашим кодом. Поскольку он однопоточный, если у нас есть фрагмент кода, который синхронно читает файл, код будет блокировать весь процесс до завершения чтения. Вместо этого, если мы полагаемся на асинхронный код, мы можем выполнять несколько операций ввода-вывода и обрабатывать ответ каждой операции отдельно после ее завершения. Никакой блокировки.
Но, конечно же, в среде, где нас совершенно не волнует обработка большого количества процессов, использование синхронного и асинхронного кода вообще не имеет значения, верно?
Ориентир
Тест, который мы собираемся запустить, будет направлен на то, чтобы предоставить нам контрольные показатели того, насколько быстро выполняется синхронизирующий и асинхронный код и есть ли разница в производительности.
Я решил выбрать чтение файла в качестве операции ввода-вывода для тестирования.
Сначала я написал функцию, которая запишет случайный файл, заполненный случайными байтами, сгенерированными модулем Node.js Crypto.
const fs = require('fs'); const crypto = require('crypto'); fs.writeFileSync( "./test.txt", crypto.randomBytes(2048).toString('base64') )
Этот файл будет действовать как константа для нашего следующего шага, который заключается в чтении файла. Вот код
const fs = require('fs'); process.on('unhandledRejection', (err)=>{ console.error(err); }) function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); await Promise.all([p0]) console.timeEnd("async") } synchronous() asynchronous()
Выполнение предыдущего кода привело к следующим результатам:
Бегать # | Синхронизировать | Асинхронный | Соотношение асинхронности/синхронизации |
---|---|---|---|
1 | 0,278 мс | 3,829 мс | 13.773 |
2 | 0,335 мс | 3,801 мс | 11.346 |
3 | 0,403 мс | 4,498 мс | 11.161 |
Это было неожиданно. Мои первоначальные ожидания заключались в том, что они должны занимать одинаковое время. Ну, а как насчет того, чтобы добавить еще один файл и прочитать 2 файла вместо 1?

Я скопировал сгенерированный файл test.txt и назвал его test2.txt. Вот обновленный код:
function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); await Promise.all([p0,p1]) console.timeEnd("async") }
Я просто добавил по одному чтению для каждого из них, а в промисах ждал промисов чтения, которые должны выполняться параллельно. Это были результаты:
Бегать # | Синхронизировать | Асинхронный | Соотношение асинхронности/синхронизации |
---|---|---|---|
1 | 1,659 мс | 6,895 мс | 4.156 |
2 | 0,323 мс | 4,048 мс | 12.533 |
3 | 0,324 мс | 4,017 мс | 12.398 |
4 | 0,333 мс | 4,271 мс | 12.826 |
Первый имеет совершенно другие значения, чем три последующих прогона. Я предполагаю, что это связано с JIT-компилятором JavaScript, который оптимизирует код при каждом запуске.
Итак, с асинхронными функциями дела обстоят не так хорошо. Возможно, если мы сделаем вещи более динамичными и, возможно, немного больше нагрузим приложение, мы могли бы получить другой результат.
Итак, мой следующий тест включает в себя запись 100 разных файлов и последующее чтение их всех.
Во-первых, я изменил код, чтобы записать 100 файлов перед выполнением теста. Файлы различаются при каждом запуске, хотя и сохраняют почти одинаковый размер, поэтому мы удаляем старые файлы перед каждым запуском.
Вот обновленный код:
let filePaths = []; function writeFile() { let filePath = `./files/${crypto.randomBytes(6).toString('hex')}.txt` fs.writeFileSync( filePath, crypto.randomBytes(2048).toString('base64') ) filePaths.push(filePath); } function synchronous() { console.time("sync"); /* fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") */ filePaths.forEach((filePath)=>{ fs.readFileSync(filePath) }) console.timeEnd("sync") } async function asynchronous() { console.time("async"); /* let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); */ // await Promise.all([p0,p1]) let promiseArray = []; filePaths.forEach((filePath)=>{ promiseArray.push(fs.promises.readFile(filePath)) }) await Promise.all(promiseArray) console.timeEnd("async") }
И для очистки и выполнения:
let oldFiles = fs.readdirSync("./files") oldFiles.forEach((file)=>{ fs.unlinkSync("./files/"+file) }) if (!fs.existsSync("./files")){ fs.mkdirSync("./files") } for (let index = 0; index < 100; index++) { writeFile() } synchronous() asynchronous()
И бежим.
Вот таблица результатов:
Бегать # | Синхронизировать | Асинхронный | Соотношение асинхронности/синхронизации |
---|---|---|---|
1 | 4,999 мс | 12,890 мс | 2,579 |
2 | 5,077 мс | 16,267 мс | 3.204 |
3 | 5,241 мс | 14,571 мс | 2.780 |
4 | 5,086 мс | 16,334 мс | 3.213 |
Эти результаты начинают делать вывод здесь. Это указывает на то, что с увеличением спроса или параллелизма обещания накладных расходов начинают обретать смысл. Для уточнения, если мы используем веб-сервер, который должен выполнять сотни или, может быть, тысячи запросов в секунду на сервер, выполнение операций ввода-вывода с использованием синхронизации начнет довольно быстро терять свое преимущество.
Просто ради эксперимента давайте посмотрим, действительно ли это проблема с самими промисами или что-то еще. Для этого я написал функцию, которая будет вычислять время для разрешения одного промиса, который абсолютно ничего не делает, и другого, который разрешает 100 пустых промисов.
Вот код:
function promiseRun() { console.time("promise run"); return new Promise((resolve)=>resolve()) .then(()=>console.timeEnd("promise run")) } function hunderedPromiseRuns() { let promiseArray = []; console.time("100 promises") for(let i = 0; i < 100; i++) { promiseArray.push(new Promise((resolve)=>resolve())) } return Promise.all(promiseArray).then(()=>console.timeEnd("100 promises")) } promiseRun() hunderedPromiseRuns()
Бегать # | Одно обещание | 100 обещаний |
---|---|---|
1 | 1,651 мс | 3,293 мс |
2 | 0,758 мс | 2,575 мс |
3 | 0,814 мс | 3,127 мс |
4 | 0,788 мс | 2,623 мс |
Интересно. Похоже, что обещания не являются основной причиной задержки, что заставляет меня предположить, что источником задержки являются потоки ядра, выполняющие фактическое чтение. Возможно, потребуется немного больше экспериментов, чтобы прийти к окончательному выводу об основной причине задержки.
Последнее слово
Так следует ли использовать промисы или нет? Мое мнение будет следующим:
Если вы пишете сценарий, который будет выполняться на одном компьютере с определенным потоком, запускаемым конвейером или одним пользователем, используйте код синхронизации. Если вы пишете веб-сервер, который будет отвечать за обработку большого количества трафика и запросов, то накладные расходы, связанные с асинхронным выполнением, превзойдут производительность кода синхронизации.
Вы можете найти код всех функций в этой статье в репозитории.
Следующим логическим шагом на пути разработчика JavaScript от обещаний является синтаксис async/await. Если вы хотите узнать больше об этом и о том, как мы к этому пришли, см. Асинхронный JavaScript: от ада обратного вызова к асинхронному и ожидающему .