برنامه‌نویسی

جلوگیری از Memory Leak در برنامه‌‌های Node.js


۳۰ اردیبهشت ۱۴۰۰
جلوگیری از memory leak در برنامه‌‌های node.js

رفع باگ‌های نرم‌افزاری بخشی از فرایند توسعه‌ی نرم‌افزار است بنابراین اگر یک توسعه‌دهنده‌ی نرم‌افزار هستید باید نحوه‌ی رخ دادن یک باگ نرم‌افزاری را برای مقابله با آن درک کنید.

یک وب سرور توسعه داده شده با Node.js را درنظر بگیرید که با یک بار راه‌اندازی آن می‌توانید هر درخواستی را به آن ارسال کنید و این برنامه تا زمانی که برنامه متوقف نشده یا سرور برنامه خاموش نشود به پردازش درخواست‌ها ادامه خواهد داد و داده‌‌های اضافی نیز توسط garbage collector جمع‌آوری و پاک می‌شوند. حال Memory Leak آن زمانی رخ می‌دهد که داده‌ها توسط garbage collector پاکسازی نمی‌شوند و در Memory باقی می‌مانند.

اکنون با دانستن اینکه Memory Leak چگونه رخ می‌دهد، مستعدترین مواردی که باعث رخ دادن این باگ در برنامه‌های Node.js می‌شوند را به شما معرفی خواهیم کرد:

  • Global resources
  • Closures
  • Caching
  • Promises

معرفی ابزارهایی برای شناسایی Memory Leak

ما برای شناسایی و رفع باگ‌ها در ادامه‌ی مقاله از Clinic.js و autocannon استفاده می‌کنیم و شما هم می‌توانید این دو ابزار را با اجرای دستورهای زیر نصب کنید:

npm i autocannon -g
npm i clinic -g

Global Resources

باتوجه به ماهیت زبان برنامه‌نویسی JavaScript که در آن اضافه کردن Global Variables و Global Resources امری بسیار طبیعی میان برنامه‌نویسان است شاهد Memory Leak در برنامه‌های Node.js خواهیم بود. بیایید با یک مثال ساده این موضوع را بهتر درک کنیم:

const http = require("http");

const requestLogs = [];
const server = http.createServer((req, res) => {
    requestLogs.push({ url: req.url, array: new Array(10000).join("*")
    res.end(JSON.stringify(requestLogs));
});

server.listen(3000);
console.log("Server listening to port 3000. Press Ctrl+C to stop it.");

اگر برنامه‌ی فوق را با دستور زیر اجرا کنید:

clinic doctor --on-port 'autocannon -w 300 -c 100 -d 20 localhost:3000' -- node server.js

در مرحله‌ی اول Load testing برنامه توسط autocannon انجام می‌شود و درمرحله‌ی دوم، داده‌های جمع‌آوری شده برای تجزیه و تحلیل به Clinic.js سپرده می‌شوند. درنهایت خروجی تجزیه و تحلیل‌ها پس از ۲۰ ثانیه به شکل زیر در یک صفحه‌ از مرورگر به شما نمایش داده خواهد شد.

نتایج load testing برنامه‌‌ی node.js

همان‌طور که مشاهده می‌کنید، میزان استفاده از Memory و تاخیر Event Loop برای پاسخ به درخواست‌ها مداوما در حال افزایش است. البته این فرایند فقط به افزایش استفاده از Memory منجر نمی‌شود و پایین آمدن سطح عملکرد برنامه را با خود به‌همراه دارد.

با یک تجزیه و تحلیل ساده متوجه می‌شویم که با هر Request یک سری اطلاعات به requestLogs اضافه می‌شود. همچنین ما می‌توانیم Leak فعلی را با ابزار Node Inspector در مرورگر Chrome با گرفتن Heap Dump در زمانی که برنامه برای اولین بار اجرا می‌شود، پیدا کنیم. البته درمورد فرایند انجام این کار توضیحی نخواهیم داد اما شما می‌توانید با خواندن و دنبال کردن مراحل گفته شده در مقاله‌های Memory Leaks Demystified و Finding And Fixing Node.js Memory Leaks به نتیجه زیر دست پیدا کنید.

نتایج heap dump برنامه‌‌ی node.js

حال که متوجه شده‌ایم requestLogs دلیل Memory Leak است می‌توانیم برنامه‌ی خود را به شکل زیر تصحیح کنیم:

const http = require("http");

const server = http.createServer((req, res) => {
    const requestLogs = [];
    requestLogs.push({ url: req.url, array: new Array(10000).join("*")
    res.end(JSON.stringify(requestLogs));
});

server.listen(3000);
console.log("Server listening to port 3000. Press Ctrl+C to stop it.");

این یک راه حل موقت است و اگر واقعا به Logها نیاز دارید باید از یک دیتابیس یا به‌طور کلی از یک فضای ذخیره‌سازی استفاده کنید. با در پیش گرفتن راه حل فوق و اجرا مجدد Load testing به نتایج زیر دست پیدا می‌کنیم که نشان‌دهنده‌ی رفع شدن باگ Memory Leak در برنامه است.

نتایج load testing برنامه‌‌ی node.js

Closures

Closures یکی از موارد بسیار معمول در JavaScript است و می‌تواند دلیل Memory Leak در برنامه‌ی شما باشد. بیایید با عمیق شدن در کدها به‌دنبال پیدا کردن راه حل باشیم:

const http = require("http");

var theThing = null;
var replaceThing = function () {
    var originalThing = theThing;
    var unused = function () {
        if (originalThing) console.log("hi");
    };
    theThing = {
        longStr: new Array(10000).join("*"),
        someMethod: function () {
            console.log(someMessage);
        },
    };
};

const server = http.createServer((req, res) => {
    replaceThing();
    res.writeHead(200);
    res.end("Hello World");
});

server.listen(3000);
console.log("Server listening to port 3000. Press Ctrl+C to stop it.");

مانند مثال قبل از autocannon و Clinic.js برای Load Testing استفاده خواهیم کرد.

نتایج load testing برنامه‌‌ی node.js

همان‌طور که مشخص است میزان استفاده از Memory به‌سرعت در حال افزایش است و اگر می‌خواهید بدانید که مشکل در کجای برنامه است می‌توانیم theThing که با هر API call بازنویسی می‌شود را دلیل این مشکل بدانیم. بیایید نگاهی به Heap Dumps داشته باشیم.

نتایج heap dump برنامه‌‌ی node.js

دلیل افزایش استفاده از Memory به someMethod و متغیر longStr مربوط می‌شود. به این شکل که someMethod یک enclosing scope ایجاد کرده و متغیر unused را نگهداری می‌کند حتی اگر هرگز فراخوانی نشود. درنهایت garbage collector نمی‌تواند originalThing را از حافظه پاکسازی کند.

اکنون می‌توانیم با درک مشکل به کمک یک راه حل ساده از Memory Leak جلوگیری کنیم:

const http = require("http");

var theThing = null;
var replaceThing = function () {
    var originalThing = theThing;
    var unused = function () {
        if (originalThing) console.log("hi");
    };
    theThing = {
        longStr: new Array(10000).join("*"),
        someMethod: function () {
            console.log(someMessage);
        },
    };
    originalThing = null;
};

const server = http.createServer((req, res) => {
    replaceThing();
    res.writeHead(200);
    res.end("Hello World");
});

server.listen(3000);
console.log("Server listening to port 3000. Press Ctrl+C to stop it.");

با گرفتن Load testing می‌توانید بببینید که همه چیز به‌خوبی کار می‌کند.

نتایج load testing برنامه‌‌ی node.js

Caching

Caching یکی دیگر از متداول‌ترین فرایند‌هایی است که باعث Memory Leak در برنامه‌های Node.js می‌شود بنابراین مانند تمام مثال‌های قبل باید با بررسی کدها به‌دنبال پیدا کردن مشکل و ارائه‌ی راه حل برای جلوگیری از Memory Leak باشیم:

const http = require("http");

function computeTerm(term) {
    return computeTerm[term] || (computeTerm[term] = compute());
    function compute() {
        return Buffer.alloc(1e3);
    }
}
const server = http.createServer((req, res) => {
    res.end(computeTerm(Math.random()));
});

server.listen(3000);
console.log("Server listening to port 3000. Press Ctrl+C to stop it.");

برای Load testing برنامه مانند تمام مثال‌های قبل از دستور زیر یعنی:

clinic doctor --on-port 'autocannon -w 300 -c 100 -d 30 localhost:3000' -- node server.js

استفاده خواهیم کرد اما مدت زمان تست را از ۲۰ ثانیه به ۳۰ ثانیه تغییر داده‌ایم.

نتایج load testing برنامه‌‌ی node.js

مانند هر دو مثال قبل که با مشکل Memory Leak روبرو بوده‌اند شاهد افزایش مداوم و سریع‌ استفاده از Memory توسط برنامه هستیم که درنهایت باعث کرش شدن برنامه‌ی ما خواهد شد. اگر کدها را بررسی کنید متوجه می‌شوید که دلیل Memory Leak چیست اما بیایید با Heap Dump این مشکل را بررسی کنیم.

نتایج heap dump برنامه‌‌ی node.js

همان‌طور که مشاهده می‌کنید تمام buffer objectها در computeTerm ذخیره شده و از Memory پاک نشده‌اند. دلیل Memory Leak در Caching به این دلیل است که ما به‌صورت دوره‌ای داده‌های Cache شده را حذف نمی‌کنیم بنابراین با ارائه‌ی راه حل زیر باید مشکل برنامه‌ی ما رفع شود:

const http = require("http");

function computeTerm(term) {
    setTimeout(() => {
        delete computeTerm[term];
    }, 1000);
    return computeTerm[term] || (computeTerm[term] = compute());

    function compute() {
        return Buffer.alloc(1e3);
    }
}
const server = http.createServer((req, res) => {
    res.end(computeTerm(Math.random()));
});

server.listen(3000);
console.log("Server listening to port 3000. Press Ctrl+C to stop it.");

ما در مثال فوق، داده‌ها را برای یک ثانیه در Memory به‌عنوان داده‌ی Cache شده نگه می‌داریم و پس از آن حذف خواهند شد. البته دلیل انتخاب مدت زمان یک ثانیه به این دلیل بوده است که برنامه‌ی ما به‌طور کلی ۳۰ ثانیه اجرا می‌شود.

البته که این راه حل ایده‌آل نیست اما می‌تواند از Memory Leak در برنامه‌ی ما جلوگیری کند و درنهایت نتایج اجرای مجدد Load testing برنامه به‌شکل زیر است.

نتایج load testing برنامه‌‌ی node.js

البته ما می‌توانیم Caching را با مکانیزم‌هایی مانند LRU (Last Recently Used) بهبود ببخشیم که در این مقاله به آن‌ها اشاره‌ای نخواهیم داشت اما یک راه حل بسیار مناسب‌تر برای Caching، استفاده از Caching serverهایی مانند Redis یا Memcached است.

Promises

Promiseها بخش جدایی ناپذیر برنامه‌های JavaScript هستند اما به‌دلیل ماهیتشان می‌توانند باعث Memory Leak در برنامه‌‌های Node.js شوند زیرا تا زمانی که Resolve یا Reject نشوند از Memory حذف نخواهند شد. بیایید به‌سراغ کدها برویم:

const http = require("http");

async function task (ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

function getRndInteger(min, max) {
    return Math.floor(Math.random() * (max - min + 1) ) + min;
}

const server = http.createServer((req, res) => {
    task(getRndInteger(100, 20000));
    res.writeHead(200);
    res.end("Hello World");
});

server.listen(3000);
console.log("Server listening to port 3000. Press Ctrl+C to stop it.");

در مثال فوق یک Promise وجود دارد که پس از مدت زمان مشخصی، Resolve می‌شود. برای مشخص کردن مدت زمان هم از اعداد تصادفی بین ۱۰۰ میلی‌ثانیه تا ۲۰ ثانیه استفاده می‌شود. درنهایت برنامه‌ای خواهیم داشت که برای هر Request یک Promise ایجاد می‌کند و برای Resolve آن به ۱۰۰ میلی‌ثانیه تا ۲۰ ثانیه زمان نیاز دارد. بیایید نگاهی به نتایج Load testing برنامه بیندازیم.

نتایج load testing برنامه‌‌ی node.js

افزایش مداوم میزان استفاده از Memory گویای مشکل Memory Leak در برنامه‌‌ی ما است اما اگر Promiseها پس از یک دوره‌ی زمانی مشخص Resolve می‌شوند پس دلیل افزایش میزان استفاده از Memory چیست؟

این سوال باعث می‌شود تا برای پیدا کردن مشکل از Heap Dump استفاده کنیم.

نتایج heap dump برنامه‌‌ی node.js

در تصویر فوق به‌وضوح می‌توانیم ببینیم که Promiseها هنوز در Memory هستند و پاک نشده‌اند. حتی با اینکه برنامه متوقف شده‌ است اما Promiseهایی وجود دارند که هنوز Resolve نشده‌اند و در Memory باقی مانده‌اند.

راه حل ما برای رفع این مشکل قراردادن یک زمان مشخص برای اجرای Promiseها و توقف آن‌ها بعد از زمان مشخص شده است. بیایید نگاهی به نحوه‌ی پیاده‌سازی راه حل گفته شده در کدها داشته باشیم:

const http = require("http");

async function task (ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

function getRndInteger(min, max) {
    return Math.floor(Math.random() * (max - min + 1) ) + min;
}

function timeout(timer) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve();
        }, timer)
    });
}

const server = http.createServer((req, res) => {
    Promise.race([task(getRndInteger(100, 20000)), timeout(500)]);
    res.writeHead(200);
    res.end("Hello World");
});

server.listen(3000);
console.log("Server listening to port 3000. Press Ctrl+C to stop it.");

در مثال فوق از یک فانکشن با نام Promise.race استفاده کرده‌ایم که مسئولیت آن Reject کردن Promiseهایی است که در مدت زمان مشخص شده، Resolve نشده‌اند.

بیایید Load testing را مجددا اجرا کرده و نگاهی به نتایج آن داشته باشیم.

نتایج load testing برنامه‌‌ی node.js

همان‌طور که مشخص است باگ Memory Leak رفع شده اما از آنجا که یک مدت زمان مشخص را به‌کمک فانکشن timeout برای Reject کردن Promiseها قرار داده‌ایم، Memory پس از مدت زمان مشخصی آزاد می‌شود.

منبع: https://betterprogramming.pub/the-4-types-of-memory-leaks-in-node-js-and-how-to-avoid-them-with-the-help-of-clinic-js-part-1-3f0c0afda268