Программные приложения работают в основной памяти компьютера, которую мы называем оперативной памятью (ОЗУ). JavaScript, особенно NodeJS (Server Side JS), позволяет нам писать небольшие и крупные программные проекты для конечных пользователей. Память обработчика всегда является сложной проблемой, так как плохая реализация может заблокировать все другие приложения, работающие на данном сервере или в системе. Программисты на C и C++ действительно заботятся об управлении памятью, потому что потенциально ужасные утечки памяти кроются в каждом уголке кода. Но для JS-разработчиков, вас это действительно волнует?
Поскольку разработчики JS обычно программируют веб-серверы на выделенных серверах большого объема, они могут не заметить задержку многозадачности. Допустим, в случае разработки веб-сервера мы также запускаем несколько приложений, таких как сервер базы данных (MySQL), сервер кэширования (Redis) и другие приложения по мере необходимости. Нам нужно знать, что они также потребляют доступную основную память. Если мы напишем наше приложение бессистемно, очень вероятно, что мы замедлим производительность других процессов или даже полностью лишим их памяти. В этой статье мы решаем задачу, чтобы понять структуры NodeJS, такие как потоки, буферы и каналы, и посмотреть, как каждая из них поддерживает написание приложений с эффективным использованием памяти.
Для запуска этих программ мы используем NodeJS v8.12.0, все примеры кода размещены здесь:
narenaryan/node-backpressure-internals
Оригинальная ссылка:Writing memory efficient software applications in Node.js
Проблема: копия большого файла
Если бы кого-то попросили написать программу копирования файлов на NodeJS, он бы быстро написал следующий код:
const fs = require('fs');
let fileName = process.argv[2];
let destPath = process.argv[3];
fs.readFile(fileName, (err, data) => {
if (err) throw err;
fs.writeFile(destPath || 'output', data, (err) => {
if (err) throw err;
});
console.log('New file has been created!');
});
Этот код просто записывает файл в путь назначения после попытки его чтения на основе входного имени файла и пути, что не является проблемой для небольших файлов.
Теперь предположим, что у нас есть большой файл (более 4 ГБ), резервную копию которого необходимо создать с помощью этой программы. В качестве примера возьмем один из моих фильмов сверхвысокой четкости 4K до 7,4G.Я использую приведенный выше программный код, чтобы скопировать его из текущего каталога в другой каталог.
$ node basic_copy.js cartoonMovie.mkv ~/Documents/bigMovie.mkv
Затем в системе Ubuntu (Linux) я получил эту ошибку:
/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)
Как видите, поскольку NodeJS позволяет записывать в свой буфер только до 2 ГБ данных, ошибка произошла при чтении файла. Чтобы решить эту проблему, когда вы выполняете операции с интенсивным вводом-выводом (копирование, обработка, сжатие и т. д.), лучше всего подумать о ситуации с памятью.
Потоки и буферы в NodeJS
Чтобы решить вышеуказанные проблемы, нам нужен способ разрезать большие файлы на множество файловых блоков, и нам нужна структура данных для хранения этих файловых блоков. Буфер — это структура, используемая для хранения двоичных данных. Затем нам нужен способ чтения и записи фрагментов файлов, и Streams это предоставляет.
Буферы
Мы можем легко создать буфер, используя объект Buffer.
let buffer = new Buffer(10); # 10 为 buffer 的体积
console.log(buffer); # prints <Buffer 00 00 00 00 00 00 00 00 00 00>
В более новых версиях NodeJS (> 8) вы также можете написать это.
let buffer = new Buffer.alloc(10);
console.log(buffer); # prints <Buffer 00 00 00 00 00 00 00 00 00 00>
Если у нас уже есть какие-то данные, например массивы или другие наборы данных, мы можем создать для них буфер.
let name = 'Node JS DEV';
let buffer = Buffer.from(name);
console.log(buffer) # prints <Buffer 4e 6f 64 65 20 4a 53 20 44 45 5>
Буферы имеют некоторые, такие какbuffer.toString()
а такжеbuffer.toJSON()
Такие важные методы могут проникать глубоко в данные, которые он хранит.
Мы не создаем необработанные буферы напрямую, чтобы оптимизировать код. Движки NodeJS и V8 уже реализуют это при создании внутренних буферов (очередей) при работе с потоками и сетевыми сокетами.
Потоки
Проще говоря, потоки похожи на произвольные ворота для объектов NodeJS. В компьютерной сети вход — это входное действие, а выход — выходное действие. Далее мы будем продолжать использовать эти термины.
Существует четыре типа потоков:
- Читаемый поток (для чтения данных)
- Доступный для записи поток (для записи данных)
- Дуплексный поток (может использоваться как для чтения, так и для записи)
- Поток преобразования (настраиваемый дуплексный поток для обработки данных, таких как сжатие, проверка данных и т. д.)
Следующее предложение проясняет, почему мы должны использовать потоки.
Stream API (особенно
stream.pipe()
метод) заключается в ограничении буферизации данных до приемлемого уровня, чтобы источники и получатели с разной скоростью не блокировали доступную память.
Нам нужен какой-то способ добиться цели, не перегружая систему. Об этом мы уже упоминали в начале статьи.
На приведенной выше диаграмме у нас есть два типа потоков: потоки для чтения и потоки для записи..pipe()
метод — это очень простой метод соединения потоков, доступных для чтения и записи. Если вы не понимаете приведенную выше схему, ничего страшного, после прочтения нашего примера вы можете вернуться к схеме, и все будет восприниматься как должное. Конвейеры — это убедительный механизм, и мы проиллюстрируем его двумя примерами ниже.
Решение 1 (просто используйте потоки для копирования файлов)
Давайте придумаем решение проблемы копирования больших файлов из предыдущей статьи. Сначала мы создадим два потока, а затем выполним следующие несколько шагов.
- Слушайте куски из читаемого потока
- Записать блок данных в доступный для записи поток
- Отслеживайте ход копирования файлов
Мы назвали этот код какstreams_copy_basic.js
/*
A file copy with streams and events - Author: Naren Arya
*/
const stream = require('stream');
const fs = require('fs');
let fileName = process.argv[2];
let destPath = process.argv[3];
const readabale = 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:`);
readabale.on('data', (chunk)=> {
let percentageCopied = ((chunk.length * this.counter) / this.fileSize) * 100;
process.stdout.clearLine(); // clear current text
process.stdout.cursorTo(0);
process.stdout.write(`${Math.round(percentageCopied)}%`);
writeable.write(chunk);
this.counter += 1;
});
readabale.on('end', (e) => {
process.stdout.clearLine(); // clear current text
process.stdout.cursorTo(0);
process.stdout.write("Successfully finished the operation");
return;
});
readabale.on('error', (e) => {
console.log("Some error occured: ", 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
Однако, когда мы наблюдаем за состоянием памяти программы во время работы через диспетчер задач, проблема все же возникает.
4,6 ГБ? Объем памяти, который наша программа потребляет во время выполнения, здесь не имеет смысла, и это может привести к зависанию других приложений.
что случилось?
Если вы внимательно изучили скорости чтения и записи на приведенном выше рисунке, вы найдете некоторые подсказки.
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 (копирование файла на основе потока и автоматического обратного давления)
Чтобы преодолеть вышеуказанные проблемы, мы можем изменить программу, чтобы она автоматически регулировала скорость чтения и записи диска. Этот механизм обратного давления. Нам не нужно делать многого, просто импортируйте поток для чтения в поток для записи, и NodeJS позаботится об обратном давлении.
Назовем эту программу какstreams_copy_efficient.js
/*
A file copy with streams and piping - Author: Naren Arya
*/
const stream = require('stream');
const fs = require('fs');
let fileName = process.argv[2];
let destPath = process.argv[3];
const readabale = 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:`);
readabale.on('data', (chunk) => {
let percentageCopied = ((chunk.length * this.counter) / this.fileSize) * 100;
process.stdout.clearLine(); // clear current text
process.stdout.cursorTo(0);
process.stdout.write(`${Math.round(percentageCopied)}%`);
this.counter += 1;
});
readabale.pipe(writeable); // Auto pilot ON!
// In case if we have an interruption while copying
writeable.on('unpipe', (e) => {
process.stdout.write("Copy has failed!");
});
});
В этом примере мы заменяем предыдущую операцию записи блока одной строкой кода.
readabale.pipe(writeable); // Auto pilot ON!
здесьpipeВот почему все волшебство происходит. Он контролирует скорость чтения и записи на диск, чтобы не забивать память (ОЗУ).
Запустить его.
$ time node streams_copy_efficient.js cartoonMovie.mkv ~/Documents/4kdemo.mkv
Мы скопировали тот же большой файл (7,4 ГБ), давайте посмотрим на использование памяти.
Шок! Теперь программа Node занимает всего 61,9 МБ памяти. Если вы наблюдаете скорость чтения и записи:
Disk Read: 35.5 MiB/s
Disk Write: 35.5 MiB/s
В любой момент времени скорости чтения и записи остаются постоянными из-за противодавления. Что еще более удивительно, так это то, что этот оптимизированный программный код на 13 секунд быстрее предыдущего.
12.13s user 28.50s system 22% cpu 3:03.35 total
Благодаря потоковой передаче и конвейерам NodeJS нагрузка на память снизилась на 98,68%, а также сократилось время выполнения. Вот почему трубы являются мощным присутствием.
61,9 МБ — это размер буфера, созданного читаемым потоком. Мы также можем выделить произвольный размер блока буфера, используя метод чтения в читаемом потоке.
const readabale = fs.createReadStream(fileName);
readable.read(no_of_bytes_size);
В дополнение к локальному копированию файлов этот метод можно использовать для оптимизации многих операций ввода-вывода:
- Обработка потока данных из Kafka в базу данных
- Обработка потока данных из файловой системы, сжатие и запись на диск на лету
- Более……
Исходный код (Git)
Вы можете найти все примеры в моем репозитории и протестировать их на своей машине.narenaryan/node-backpressure-internals
В заключение
Моя мотивация для написания этого поста в основном состоит в том, чтобы показать, что, несмотря на то, что NodeJS предоставляет отличный API, мы можем случайно написать код с низкой производительностью. Если мы сможем уделить больше внимания встроенным инструментам, мы сможем лучше оптимизировать работу программы.
Вы можете найти больше информации о «обратном давлении» здесь: backpressuring-in-streams
над.