Хабрахабр

[Перевод] Эффективная работа с памятью в Node.js

Программы, в ходе работы, пользуются оперативной памятью компьютеров. На JavaScript, в среде Node.js, можно писать серверные проекты самых разных масштабов. Организация работы с памятью — это всегда непростая и ответственная задача. При этом, если в таких языках, как C и C++, программисты довольно плотно занимаются управлением памятью, в JS имеются автоматические механизмы, которые, как может показаться, полностью снимают с программиста ответственность за эффективную работу с памятью. Однако на самом деле это не так. Плохо написанный код для Node.js может помешать нормальной работе всего сервера, на котором он выполняется.

В частности, здесь будут рассмотрены такие концепции, как потоки, буферы и метод потоков pipe(). В материале, перевод которого мы сегодня публикуем, речь пойдёт об эффективной работе с памятью в среде Node.js. 12. В экспериментах будет использован Node.js v8. Репозиторий с кодом примеров можно найти здесь.
0.

Задача: копирование огромного файла

Если кого-нибудь попросят создать в среде Node.js программу для копирования файлов, то он, вероятнее всего, тут же напишет примерно то, что показано ниже. Назовём файл, содержащий этот код, basic_copy.js.

const fs = require('fs'); let fileName = process.argv[2];
let destPath = process.argv[3]; fs.readFile(fileName, (err, data) => ); console.log('New file has been created!');
});

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

У меня, например, есть видеофайл размером 7. Предположим, что нашему приложению надо скопировать огромный файл (будем считать «огромными» файлы, размер которых превышает 4 Гб) в ходе процесса резервного копирования данных. Вот команда для запуска копирования: 4 Гб, который я, с помощью вышеописанной программы, попробую скопировать из моей текущей директории в директорию Documents.

$ node basic_copy.js cartoonMovie.mkv ~/Documents/bigMovie.mkv

В Ubuntu, после выполнения этой команды, было выведено сообщение об ошибке, связанной с переполнением буфера:

/home/shobarani/Workspace/basic_copy.js:7 if (err) throw err; ^
RangeError: File size is greater than possible Buffer: 0x7fffffff bytes at FSReqWrap.readFileAfterStat [as oncomplete] (fs.js:453:11)

Как видно, операция чтения файла не удалась из-за того, что Node.js позволяет считывать в буфер лишь 2 Гб данных. Как преодолеть это ограничение? Выполняя операции, интенсивно использующие подсистему ввода-вывода (копирование файлов, их обработка, сжатие), нужно учитывать возможности систем и ограничения, связанные с памятью.

Потоки и буферы в Node.js

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

▍Буферы

Буфер можно создать, инициализировав объект Buffer.

let buffer = new Buffer(10); // 10 - это размер буфера
console.log(buffer); // выводит <Buffer 00 00 00 00 00 00 00 00 00 00>

В версиях Node.js, новее 8-й, лучше всего, для создания буферов, пользоваться следующей конструкцией:

let buffer = new Buffer.alloc(10);
console.log(buffer); // выводит <Buffer 00 00 00 00 00 00 00 00 00 00>

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

let name = 'Node JS DEV';
let buffer = Buffer.from(name);
console.log(buffer) // выводит <Buffer 4e 6f 64 65 20 4a 53 20 44 45 5>

У буферов есть методы, которые позволяют «заглядывать» в них и узнавать о том, какие данные там находятся — это методы toString() и toJSON().

Node.js создаёт эти структуры данных автоматически, при работе с потоками или сетевыми сокетами. Мы, в процессе оптимизации кода, не будем создавать буферы самостоятельно.

▍Потоки

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

  • Поток для чтения (из него можно читать данные).
  • Поток для записи (в него можно отправлять данные).
  • Дуплексный поток (он открыт и для чтения из него данных, и для отправки данных в него).
  • Трансформирующий поток (особый дуплексный поток, позволяющий обрабатывать данные, например — сжимать их или проверять их корректность).

Потоки нам нужны из-за того, что жизненно важной целью API потоков в Node.js, и, в частности, метода stream.pipe(), является ограничение буферизации данных до приемлемых уровней. Делается это для того чтобы работа с источниками и получателями данных, отличающимися разными скоростями обработки данных, не приводила бы к переполнению имеющейся памяти.

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

Потоки и буферы (по материалам документации Node.js)

Метод pipe() — это очень простой механизм, позволяющий прикреплять потоки для чтения к потокам для записи. На предыдущей схеме показаны два типа потоков — потоки для чтения (Readable Streams) и потоки для записи (Writable Streams). После разбора нижеследующих примеров вы легко с ней разберётесь. Если вам пока вышеприведённая схема не особенно понятна — ничего страшного. В частности, сейчас мы рассмотрим примеры обработки данных с использованием метода pipe().

Решение 1. Копирование файлов с использованием потоков

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

  • Мы ожидаем появления очередного фрагмента данных в потоке для чтения.
  • Записываем полученные данные в поток для записи.
  • Отслеживаем ход операции копирования.

Назовём программу, реализующую эту идею, streams_copy_basic.js. Вот её код:

/* Копирование файлов с использованием потоков и событий. Автор: Naren Arya
*/ const stream = require('stream');
const fs = require('fs'); let fileName = process.argv[2];
let destPath = process.argv[3]; const readable = fs.createReadStream(fileName);
const writeable = fs.createWriteStream(destPath || "output"); fs.stat(fileName, (err, stats) => { this.fileSize = stats.size; this.counter = 1; this.fileArray = fileName.split('.'); try { this.duplicate = destPath + "/" + this.fileArray[0] + '_Copy.' + this.fileArray[1]; } catch(e) { console.exception('File name is invalid! please pass the proper one'); } process.stdout.write(`File: ${this.duplicate} is being created:`); readable.on('data', (chunk)=> { let percentageCopied = ((chunk.length * this.counter) / this.fileSize) * 100; process.stdout.clearLine(); // очистить текущий текст process.stdout.cursorTo(0); process.stdout.write(`${Math.round(percentageCopied)}%`); writeable.write(chunk); this.counter += 1; }); readable.on('end', (e) => { process.stdout.clearLine(); // очистить текущий текст process.stdout.cursorTo(0); process.stdout.write("Successfully finished the operation"); return; }); readable.on('error', (e) => { console.log("Some error occurred: ", e); }); writeable.on('finish', () => { console.log("Successfully created the file copy!"); }); });

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

Мы пользуемся здесь механизмом событий, в частности, речь идёт о подписке на следующие события:

  • data — вызывается при чтении фрагмента данных.
  • end — вызывается при окончании чтения данных из потока для чтения.
  • error — вызывается в случае возникновения ошибки в процессе чтения данных.

С помощью этой программы файл размером 7.4 Гб копируется без сообщений об ошибках.

$ time node streams_copy_basic.js cartoonMovie.mkv ~/Documents/4kdemo.mkv

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

Данные об использовании системных ресурсов

6 Гб памяти. Обратите внимание на то, что процесс node, выполнив копирование 88% файла, занимает 4. Это очень много, такое обращение с памятью способно помешать работе других программ.

▍Причины чрезмерного потребления памяти

Обратите внимание на скорости чтения данных с диска и записи данных на диск с предыдущей иллюстрации (колонки Disk Read и Disk Write). А именно, тут можно видеть следующие показатели:

Disk Read: 53.4 MiB/s
Disk Write: 14.8 MiB/s

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

Вот сведения о ходе её выполнения: На моём компьютере эта программа выполнялась 3 минуты 16 секунд.

17.16s user 25.06s system 21% cpu 3:16.61 total

Решение 2. Копирование файлов с использованием потоков и с автоматической настройкой скорости чтения и записи данных

Для того чтобы справиться с вышеописанной проблемой, мы можем модифицировать программу так, чтобы в ходе копирования файлов скорости чтения и записи данных настраивались бы автоматически. Этот механизм называют back pressure. Для того чтобы его задействовать, нам не нужно делать ничего особенного. Достаточно, с помощью метода pipe(), подключить поток для чтения к потоку для записи, а Node.js автоматически настроит скорости передачи данных.

Вот её код: Назовём эту программу streams_copy_efficient.js.

/* Копирование файлов с использованием потоков и метода pipe(). Автор: Naren Arya
*/ const stream = require('stream');
const fs = require('fs'); let fileName = process.argv[2];
let destPath = process.argv[3]; const readable = fs.createReadStream(fileName);
const writeable = fs.createWriteStream(destPath || "output"); fs.stat(fileName, (err, stats) => { this.fileSize = stats.size; this.counter = 1; this.fileArray = fileName.split('.'); try { this.duplicate = destPath + "/" + this.fileArray[0] + '_Copy.' + this.fileArray[1]; } catch(e) { console.exception('File name is invalid! please pass the proper one'); } process.stdout.write(`File: ${this.duplicate} is being created:`); readable.on('data', (chunk) => { let percentageCopied = ((chunk.length * this.counter) / this.fileSize) * 100; process.stdout.clearLine(); // очистить текущий текст process.stdout.cursorTo(0); process.stdout.write(`${Math.round(percentageCopied)}%`); this.counter += 1; }); readable.on('error', (e) => { console.log("Some error occurred: ", e); }); writeable.on('finish', () => { process.stdout.clearLine(); // очистить текущий текст process.stdout.cursorTo(0); process.stdout.write("Successfully created the file copy!"); }); readable.pipe(writeable); // Включаем автопилот! });

Основное отличие этой программы от предыдущей заключается в том, что код для копирования фрагментов данных заменён на следующую строку:

readable.pipe(writeable); // Включаем автопилот!

В основе всего того, что тут происходит, лежит метод pipe(). Он контролирует скорости чтения и записи, что приводит к тому, что память теперь не оказывается перегруженной.

Запустим программу.

$ time node streams_copy_efficient.js cartoonMovie.mkv ~/Documents/4kdemo.mkv

Мы копируем тот же самый огромный файл. Теперь посмотрим на то, как выглядит работа с памятью и с диском.

Благодаря использованию pipe() скорости чтения и записи настраиваются автоматически

9 Мб памяти. Теперь мы видим, что процесс node потребляет всего 61. Если же взглянуть на данные по использованию диска, то можно увидеть следующее:

Disk Read: 35.5 MiB/s
Disk Write: 35.5 MiB/s

Благодаря механизму back pressure скорости чтения и записи теперь всегда равны друг другу. Кроме того, новая программа выполняется на 13 секунд быстрее старой.

12.13s user 28.50s system 22% cpu 3:03.35 total

Благодаря использованию метода pipe() нам удалось уменьшить время выполнения программы и снизить потребление памяти на 98.68%.

9 Мб — это размер буфера, создаваемый потоком чтения данных. В данном случае 61. Мы вполне можем задать этот размер самостоятельно, воспользовавшись методом read() потока для чтения данных:

const readable = fs.createReadStream(fileName);
readable.read(no_of_bytes_size);

Здесь мы копировали файл в локальной файловой системе, однако тот же подход можно использовать и для оптимизации многих других задач ввода-вывода данных. Например — это работа с потоками данных, источником которых является Kafka, а приёмником — база данных. По такой же схеме можно организовать чтение данных с диска, их сжатие, что называется, «на лету», и запись обратно на диск уже в сжатом виде. На самом деле, можно найти и множество других вариантов применения описанной здесь технологии.

Итоги

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

Уважаемые читатели! Как вы работаете с буферами и потоками в Node.js?

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть