Выжимка, переосмысление и личные замечания на основе серии статей:
Терминология:
- ram (Random-access memory)
- memory management - процесс контроля и координации способа доступа приложения к памяти компьютера (ram)
Для чего программой используется ram ? Для того чтобы:
- Положить в него Bytecode, который должен исполниться
- Хранить значения данных и структуры данных
- Загрузка сред выполнения (runtime), необходимых программе
Кроме пространства, используемого для загрузки байткода, программой используются еще две области памяти: Stack (стек) и Heap
Видео, отлично иллюстрирующее содержимое и связь между стеком и кучей. Единственный минус - на видео фреймы добавляются в стек сверху вниз, хотя везде объясняется, что выполнение стека происходит снизу вверх, это немного сбивает (но понятное дело, в памяти компуктера нет никаких верх
и низ
).
Вкратце, что происходит:
- Есть
Global frame
, который хранит ссылки на все объекты: функции, объект кеша (применительно к видео, там есть объектcache
) - По мере выполнения кода и вызова функций на стек кладутся стек фреймы с информацией о вызываемых функциях, их аргументах и локальных переменных
- По мере того как функции завершают свое выполнение со стека очищается вся перечисленная выше информация
Когда мы говорим про управление памятью, то прежде всего речь идет об управлении Heap memory
Почему важно знать про то, как устроено управление памятью?
Как я описал тут - довольно часто приходится сталкиваться с утечками памяти (OOM - Out of memory), на моем опыте это были приложения на NodeJS, которые уже несколько лет работали в продакшене, но после очередных изменений в коде, почему-то начинались проблемы с освобождением памяти.
Проблема очень неприятная и зачастую неочевидная, поэтому важно понимать, как происходит управление памятью, чтобы успешно справляться с такими ситуациями и хотя бы понимать, какая механика стоит за утечками и, что собственно делать, чтобы память очищалась корректно.
Чаще всего языки программирования (JVM:Java/Scala/Groovy/Kotlin
,JavaScript
, C#
, Golang
, OCaml
,Ruby
) предоставляют готовые автоматические решения по управлению памятью в виде сборщиков мусора, которые по тем или иным правилам удаляют неиспользуемые объекты из памяти.
Хотя есть и языки (C
, C++
), которые требуют от разработчика брать на себя выделение памяти и ее высвобождение.
А например в Rust
управление памятью реализуется за счет системы владения
Пример приложения на express c утечкой памяти
const express = require('express');
const app = express();
/*
* Сборщик мусора никогда не очистит память, которую занимает этот объект,
* потому что requests - глобальный объект, ссылка на который всегда
* доступна сборщику мусора - он всегда будет помечен сборщиком, как используемый
*/
const requests = new Map();
app.get( "/", (req,res) => {
/*
* На каждый запрос мы кладем в мапу
* текущий объект запроса (req)
* requests будет увеличиваться в размерах,
* пока не исчерпает всю доступную память
*/
requests.set(req.id, req);
res.status(200).send("Hello World");
});
Приведенный выше пример довольно наивен и в реальных приложениях происходят утечки с гораздо более запутанной механикой и выявить их простым пристальным взглядом на код скорее всего не получится.
Подробный гайд по выявлению причин утечек памяти в NodeJS
Реальный пример утечки через открытые файловые дескрипторы
Как это может произойти? Например, у вас в приложении пишутся логи в файл, предположим - это файл log.txt
в корне проекта, предположим вы реализовали это через переопределение метода process.stdout.write
как-то так:
const getStream = () => fs.createWriteStream('log.txt', 'utf-8');
let stream = getStream();
process.stdout.write = (log) => {
stream.write(log + '\n')
}
Теперь все, что мы пишем в console.log
попадает в файл log.txt
. Все вроде бы хорошо, но что делать, если файл становится очень большим?
Нужна ротация логов - предположим, что в нашем случае ротацией занимается logrotate, который есть в большинстве unix систем и умеет в ротацию по объему файла или по расписанию.
Но во время ротации файл переименовывается и создается новый log.txt
и мы уже не можем писать в наш файловый стрим, так как он уже неактуален после ротации. В таком случае мы используем директиру postrotate
чтобы отправить процессу сигнал SIGHUP
и проинформировать о том, что надо переотркрыть файловый стрим, конфиг для logrotate может выглядеть как-то так:
/path/to/log/file.log {
size 100M
rotate 7
daily
compress
dateext
missingok
notifempty
postrotate
kill -HUP 12345
endscript
}
А чтобы переоткрыть стрим в nodeJS мы добавляем такой код:
process.on('SIGHUP', () => {
stream = getStream();
});
Внимание! Если удалить этот обработчик, то поведение nodejs по умолчанию, когда получаем этот сигнал - просто умереть 😄. Так что, если случайно удалить из кода этот обрабтчик, но продолжить отправлять этот сигнал из logrotate ваше nodejs приложение будет перезагружаться (если конечно вы настроили что-то вроде pm2 😁)
Еще один выход - использовать пользовательский сигнал
SIGUSR2
- если у нас в коде нет обработчика для него, то ничего страшного не случится
В результате полный фрагмент кода, отвечающий за логирование в файл, выглядит так:
const getStream = () => fs.createWriteStream('log.txt', 'utf-8');
let stream = getStream();
process.stdout.write = (log) => {
stream.write(log + '\n')
};
process.on('SIGHUP', () => {
stream = getStream();
});
Что не так с этим кодом ?
И казалось бы, что не так с этим кодом? Ведь ссылка на объект стрима заменяется другой ссылкой в момент обработки SIGHUP
и сборщик мусора должен убрать все ненужное, так?
А нет, не так 😁 Если стрим не закрыть, то у нас будет накапливаться кол-во открытых файловых дескрипторов, наличие большого количества открытых файловых дескрипторов может оказать значительное влияние на производительность системы.
Каждый открытый файловый дескриптор использует небольшой объем системной памяти и процессорного времени для обслуживания. Если существует большое количество открытых файловых дескрипторов, у системы может закончиться память или она замедлиться из-за накладных расходов на управление всеми этими дескрипторами.
Так что же делать?
Решение - Закрывать файловый стрим, при создании нового, вот так:
process.on('SIGHUP', () => {
stream.end(); // <<<===== Вот так
stream = getStream();
});
А как проверить что это работает ?
Я написал небольшую обертку для получения кол-ва открытых файловых дескрипторов процессом nodejs:
/**
* Promise wrapper for child_process.exec
* @param {string} command
* @returns
*/
async function runCommand(command) {
return new Promise((resolve, reject) => {
exec(command, (err, stdout, stderr) => {
if (err) {
return reject(err);
}
if (stderr) {
return reject(new Error(stderr));
}
resolve(stdout);
});
});
}
/**
* Will return the number of open file descriptors for a given process ID.
* @param {number} pid
* @returns {number} number of open file descriptors
*/
async function getOpenedFdByProcess(pid = process.pid) {
const lsofRes = await runCommand(`lsof -p ${pid} | wc -l`);
/**
* -1 subtraction at the end is used to subtract the header line
* from the count of open file descriptors.
*/
return parseInt(parseInt(lsofRes, 10) - 1);
}
lsof
- команда, которая должна работать во всех unix like операционных системах в отличии от чтения директории /proc/self/fd
- это не работает на Mac. Для проверки можно с какой-то периодичностью писать в файл кол-во открытых файлов и если не использовать stream.end()
- то это кол-во будет постоянно увеличиваться во время отправки сигнала SIGHUP
kill -SIGHUP 15865
Кстати, чтобы узнать id процесса, нужно выполнить в терминале следующую команду:
ps -e | grep node
Заключение
Тут я намеренно больше уделил внимания сборке мусора, нежели другим техникам управления памяти, так как по роду своей деятельности (решаю разные инфраструктурные задачи) имею дело с JS