تصحيح أخطاء الذاكرة في تطبيقات Node.js
نشرت: 2022-03-11لقد قمت ذات مرة بقيادة سيارة أودي بمحرك V8 مزدوج التوربو بالداخل ، وكان أداؤها مذهلاً. كنت أقود سيارتي في حوالي 140 ميل في الساعة على الطريق السريع IL-80 بالقرب من شيكاغو في الساعة 3 صباحًا عندما لم يكن هناك أحد على الطريق. منذ ذلك الحين ، أصبح مصطلح "V8" مرتبطًا بالأداء العالي بالنسبة لي.
على الرغم من أن محرك V8 من Audi قوي للغاية ، إلا أنك لا تزال محدودًا في سعة خزان الغاز لديك. الأمر نفسه ينطبق على Google's V8 - محرك JavaScript وراء Node.js. أداءه مذهل وهناك العديد من الأسباب التي تجعل Node.js يعمل جيدًا للعديد من حالات الاستخدام ، لكنك دائمًا مقيد بحجم الكومة. عندما تحتاج إلى معالجة المزيد من الطلبات في تطبيق Node.js لديك خياران: إما القياس رأسيًا أو القياس أفقيًا. يعني القياس الأفقي أنه يجب عليك تشغيل المزيد من مثيلات التطبيق المتزامنة. عندما يتم ذلك بشكل صحيح ، ينتهي بك الأمر إلى أن تكون قادرًا على خدمة المزيد من الطلبات. يعني التحجيم الرأسي أنه يجب عليك تحسين استخدام ذاكرة التطبيق وأدائها أو زيادة الموارد المتاحة لمثيل التطبيق الخاص بك.
طُلب مني مؤخرًا العمل على تطبيق Node.js لأحد عملاء Toptal لإصلاح مشكلة تسرب الذاكرة. كان الهدف من التطبيق ، خادم واجهة برمجة التطبيقات (API) ، هو أن يكون قادرًا على معالجة مئات الآلاف من الطلبات كل دقيقة. شغل التطبيق الأصلي ما يقرب من 600 ميغا بايت من ذاكرة الوصول العشوائي ، وبالتالي قررنا أخذ نقاط نهاية API الساخنة وإعادة تطبيقها. تصبح النفقات العامة باهظة الثمن عندما تحتاج إلى خدمة العديد من الطلبات.
بالنسبة لواجهة برمجة التطبيقات الجديدة ، اخترنا إعادة التحديد باستخدام برنامج تشغيل MongoDB الأصلي و Kue لوظائف الخلفية. يبدو وكأنه مكدس خفيف الوزن للغاية ، أليس كذلك؟ ليس تماما. أثناء ذروة التحميل ، يمكن أن تستهلك طبعة تطبيق جديدة ما يصل إلى 270 ميجابايت من ذاكرة الوصول العشوائي. لذلك ، فإن حلمي بالحصول على نسختين للتطبيق لكل 1X Heroku Dyno تلاشى.
Node.js تسرب الذاكرة تصحيح أخطاء ارسنال
Memwatch
إذا كنت تبحث عن "كيفية العثور على تسرب في العقدة" فإن الأداة الأولى التي من المحتمل أن تجدها هي memwatch . تم التخلي عن الحزمة الأصلية منذ فترة طويلة ولم تعد قيد الصيانة. ومع ذلك ، يمكنك بسهولة العثور على إصدارات أحدث منه في قائمة مفترقات GitHub للمستودع. هذه الوحدة مفيدة لأنها يمكن أن تصدر أحداث تسريب إذا رأت أن الكومة تكبر أكثر من 5 مجموعات قمامة متتالية.
كومة
أداة رائعة تسمح لمطوري Node.js بأخذ لقطة من الكومة وفحصها لاحقًا باستخدام Chrome Developer Tools.
مفتش العقدة
حتى أنه بديل أكثر إفادة لـ heapdump ، لأنه يسمح لك بالاتصال بتطبيق قيد التشغيل ، وأخذ تفريغ كومة الذاكرة المؤقتة وحتى تصحيحه وإعادة تجميعه بسرعة.
أخذ "مفتش العقدة" من أجل Spin
لسوء الحظ ، لن تتمكن من الاتصال بتطبيقات الإنتاج التي تعمل على Heroku ، لأنها لا تسمح بإرسال الإشارات إلى العمليات الجارية. ومع ذلك ، فإن Heroku ليست منصة الاستضافة الوحيدة.
لتجربة مفتش العقدة في العمل ، سنكتب تطبيق Node.js بسيطًا باستخدام restify ونضع فيه مصدرًا صغيرًا لتسرب الذاكرة. تم إجراء جميع التجارب هنا باستخدام Node.js v0.12.7 ، والذي تم تجميعه مقابل V8 v3.28.71.19.
var restify = require('restify'); var server = restify.createServer(); var tasks = []; server.pre(function(req, res, next) { tasks.push(function() { return req.headers; }); // Synchronously get user from session, maybe jwt token req.user = { id: 1, username: 'Leaky Master', }; return next(); }); server.get('/', function(req, res, next) { res.send('Hi ' + req.user.username); return next(); }); server.listen(3000, function() { console.log('%s listening at %s', server.name, server.url); });
التطبيق هنا بسيط للغاية وله تسريب واضح جدًا. ستنمو مهام المصفوفة على مدى عمر التطبيق مما يؤدي إلى إبطاءها وتعطلها في النهاية. تكمن المشكلة في أننا لا نقوم فقط بتسريب الإغلاق ولكننا نقوم أيضًا بتسريب عناصر الطلب بالكامل.
يستخدم GC في V8 إستراتيجية stop-the-world ، وبالتالي فهذا يعني المزيد من العناصر الموجودة في الذاكرة كلما استغرق الأمر وقتًا أطول لجمع القمامة. في السجل أدناه ، يمكنك أن ترى بوضوح أنه في بداية عمر التطبيق ، سيستغرق الأمر 20 مللي ثانية في المتوسط لجمع القمامة ، لكن بضع مئات الآلاف من الطلبات في وقت لاحق يستغرق حوالي 230 مللي ثانية. سيتعين على الأشخاص الذين يحاولون الوصول إلى تطبيقنا الانتظار 230 مللي ثانية لفترة أطول الآن بسبب GC. يمكنك أيضًا أن ترى أن GC يتم استدعاؤها كل بضع ثوانٍ مما يعني أن المستخدمين سيواجهون كل بضع ثوانٍ مشاكل في الوصول إلى تطبيقنا. وسيزداد التأخير حتى يتعطل التطبيق.
[28093] 7644 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 25.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7717 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 18.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7866 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 23.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 8001 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 18.4 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. ... [28093] 633891 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.3 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 635672 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 331.5 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 637508 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].
تتم طباعة سطور السجل هذه عند بدء تشغيل تطبيق Node.js بعلامة –trace_gc :
node --trace_gc app.js
لنفترض أننا بدأنا بالفعل تطبيق Node.js الخاص بنا بهذه العلامة. قبل توصيل التطبيق بمفتش العقدة ، نحتاج إلى إرسال إشارة SIGUSR1 إلى عملية التشغيل. إذا قمت بتشغيل Node.js في نظام المجموعة ، فتأكد من الاتصال بإحدى العمليات التابعة.
kill -SIGUSR1 $pid # Replace $pid with the actual process ID
من خلال القيام بذلك ، نجعل تطبيق Node.js (V8 على وجه الدقة) يدخل في وضع التصحيح. في هذا الوضع ، يفتح التطبيق تلقائيًا المنفذ 5858 مع بروتوكول التصحيح V8.
خطوتنا التالية هي تشغيل node-inspector الذي سيتصل بواجهة تصحيح الأخطاء للتطبيق قيد التشغيل ويفتح واجهة ويب أخرى على المنفذ 8080.
$ node-inspector Node Inspector v0.12.2 Visit http://127.0.0.1:8080/?ws=127.0.0.1:8080&port=5858 to start debugging.
في حالة تشغيل التطبيق أثناء الإنتاج وكان لديك جدار ناري في مكانه ، يمكننا تحويل المنفذ البعيد 8080 إلى المضيف المحلي:
ssh -L 8080:localhost:8080 [email protected]
يمكنك الآن فتح متصفح الويب Chrome والحصول على وصول كامل إلى Chrome Development Tools المرفقة بتطبيق الإنتاج عن بُعد. لسوء الحظ ، لن تعمل Chrome Developer Tools في المتصفحات الأخرى.
دعونا نجد تسرب!
تسريبات الذاكرة في V8 ليست تسريبات ذاكرة حقيقية كما نعرفها من تطبيقات C / C ++. في JavaScript لا تختفي المتغيرات في الفراغ ، بل يتم "نسيانها". هدفنا هو العثور على هذه المتغيرات المنسية وتذكيرهم بأن دوبي مجاني.
داخل أدوات مطوري Chrome ، لدينا إمكانية الوصول إلى العديد من أدوات التعريف. نحن مهتمون بشكل خاص بتخصيصات كومة السجلات التي يتم تشغيلها وتأخذ لقطات كومة متعددة بمرور الوقت. هذا يعطينا نظرة خاطفة واضحة على الأشياء التي تتسرب.
ابدأ في تسجيل تخصيصات الكومة ودعنا نحاكي 50 مستخدمًا متزامنًا على صفحتنا الرئيسية باستخدام Apache Benchmark.
ab -c 50 -n 1000000 -k http://example.com/
قبل أخذ لقطات جديدة ، كان V8 يقوم بجمع البيانات المهملة ، لذلك نحن نعلم بالتأكيد أنه لا توجد قمامة قديمة في اللقطة.
إصلاح التسرب أثناء الطيران
بعد جمع لقطات تخصيص الكومة على مدار 3 دقائق ، ينتهي بنا الأمر بشيء مثل ما يلي:
يمكننا أن نرى بوضوح أن هناك بعض المصفوفات العملاقة ، والكثير من كائنات IncomingMessage و ReadableState و ServerResponse و Domain أيضًا في الكومة. دعنا نحاول تحليل مصدر التسرب.
عند تحديد فرق الكومة على الرسم البياني من 20 إلى 40 ثانية ، سنرى فقط الكائنات التي تمت إضافتها بعد 20 ثانية من وقت بدء المحلل. بهذه الطريقة يمكنك استبعاد جميع البيانات العادية.
مع ملاحظة عدد الكائنات من كل نوع في النظام ، نقوم بتوسيع المرشح من 20 ثانية إلى دقيقة واحدة. يمكننا أن نرى أن المصفوفات ، الضخمة بالفعل ، تستمر في النمو. تحت "(مجموعة)" يمكننا أن نرى أن هناك الكثير من الكائنات "(خصائص الكائن)" بمسافة متساوية. هذه الأشياء هي مصدر تسرب ذاكرتنا.
كما يمكننا أن نرى أن كائنات "(الإغلاق)" تنمو بسرعة أيضًا.
قد يكون من المفيد النظر إلى الأوتار أيضًا. يوجد تحت قائمة السلاسل الكثير من عبارات "Hi Leaky Master". قد تعطينا هذه بعض الأدلة أيضا.

في حالتنا ، نعلم أن السلسلة "Hi Leaky Master" لا يمكن تجميعها إلا ضمن مسار "GET /".
إذا قمت بفتح مسار الخدم ، فسترى أن هذه السلسلة تتم الإشارة إليها بطريقة ما عبر req ، ثم هناك سياق تم إنشاؤه وكل هذا مضاف إلى مجموعة كبيرة من الإغلاق.
في هذه المرحلة ، نعلم أن لدينا نوعًا من مجموعة هائلة من عمليات الإغلاق. دعنا في الواقع نذهب ونطلق اسمًا على جميع عمليات الإغلاق في الوقت الفعلي ضمن علامة تبويب المصادر.
بعد أن ننتهي من تحرير الكود ، يمكننا الضغط على CTRL + S لحفظ الكود وإعادة تجميعه سريعًا!
الآن دعنا نسجل لقطة أخرى لتخصيص الكومة ونرى الإغلاق الذي يشغل الذاكرة.
من الواضح أن SomeKindOfClojure () هو الشرير لدينا. الآن يمكننا أن نرى أن إغلاق SomeKindOfClojure () يتم إضافته إلى بعض المهام المسماة بالمصفوفة في المساحة العامة.
من السهل أن ترى أن هذه المجموعة غير مجدية. يمكننا التعليق عليها. لكن كيف نحرر الذاكرة الذاكرة المشغولة بالفعل؟ من السهل جدًا ، نقوم فقط بتعيين مصفوفة فارغة للمهام ومع الطلب التالي سيتم تجاوزها وسيتم تحرير الذاكرة بعد حدث GC التالي.
دوبي حر!
حياة القمامة في V8
يتم تقسيم V8 heap إلى عدة مساحات مختلفة:
- مساحة جديدة : هذه المساحة صغيرة نسبيًا ويتراوح حجمها بين 1 ميغا بايت و 8 ميغا بايت. يتم تخصيص معظم الأشياء هنا.
- مساحة المؤشر القديمة : بها كائنات قد تحتوي على مؤشرات إلى كائنات أخرى. إذا نجا الكائن لفترة كافية في New Space ، فسيتم ترقيته إلى Old Pointer Space.
- مساحة البيانات القديمة : تحتوي فقط على بيانات أولية مثل السلاسل والأرقام المعبأة ومصفوفات المضاعفات غير المعبأة. يتم هنا أيضًا نقل الكائنات التي نجت من GC في New Space لفترة كافية.
- مساحة الكائنات الكبيرة : يتم إنشاء الكائنات التي تكون أكبر من أن تتناسب مع المساحات الأخرى في هذا الفضاء. كل كائن له منطقة
mmap
الخاصة به في الذاكرة - مساحة التعليمات البرمجية : تحتوي على رمز التجميع الذي تم إنشاؤه بواسطة مترجم JIT.
- مساحة الخلية ومساحة
Cell
PropertyCell
ومساحة الخريطةMap
تحتوي هذه المساحة على خلايا وخلايا خصائص وخرائط. يستخدم هذا لتبسيط عملية جمع القمامة.
كل مساحة تتكون من صفحات. الصفحة هي منطقة الذاكرة المخصصة من نظام التشغيل باستخدام mmap. يبلغ حجم كل صفحة دائمًا 1 ميغا بايت باستثناء الصفحات ذات مساحة الكائن الكبيرة.
يحتوي V8 على آليتين مدمجتين لجمع البيانات المهملة: Scavenge و Mark-Sweep و Mark-Compact.
Scavenge هي تقنية سريعة جدًا لجمع القمامة وتعمل مع الكائنات في New Space . Scavenge هو تطبيق خوارزمية تشيني. الفكرة بسيطة للغاية ، فضاء جديد مقسم إلى مسافتين متساويتين: إلى الفضاء ومن الفضاء. يحدث Scavenge GC عندما يكون To-Space ممتلئًا. إنه ببساطة يقوم بالتبديل من وإلى المساحات ونسخ جميع الكائنات الحية إلى الفضاء أو ترقيها إلى أحد المساحات القديمة إذا نجوا من عمليتي مسح ، ثم تم محوها بالكامل من الفضاء. تعتبر عمليات المسح سريعة جدًا ولكنها تحمل عبء الاحتفاظ بكومة مزدوجة الحجم ونسخ الكائنات باستمرار في الذاكرة. سبب استخدام الكسب هو أن معظم الأشياء تموت في سن مبكرة.
يعد Mark-Sweep & Mark-Compact نوعًا آخر من مجمعات القمامة المستخدمة في V8. الاسم الآخر هو جامع القمامة الكامل. يقوم بتمييز جميع العقد الحية ، ثم يكتسح جميع العقد الميتة ويزيل تجزئة الذاكرة.
أداء GC ونصائح التصحيح
في حين أن الأداء العالي لتطبيقات الويب قد لا يمثل مشكلة كبيرة ، ستظل ترغب في تجنب التسريبات بأي ثمن. أثناء مرحلة وضع العلامات في GC الكامل ، يتم إيقاف التطبيق مؤقتًا فعليًا حتى يتم الانتهاء من جمع البيانات المهملة. هذا يعني أنه كلما زاد عدد العناصر الموجودة في الكومة ، كلما استغرق أداء GC وقتًا أطول وسيتعين على المستخدمين الانتظار لفترة أطول.
دائما إعطاء أسماء للإغلاق والوظائف
من الأسهل بكثير فحص آثار المكدس والأكوام عندما يكون لجميع عمليات الإغلاق والوظائف أسماء.
db.query('GIVE THEM ALL', function GiveThemAllAName(error, data) { ... })
تجنب الأشياء الكبيرة في الوظائف الساخنة
من الناحية المثالية ، تريد تجنب الكائنات الكبيرة داخل الوظائف الساخنة بحيث يتم احتواء جميع البيانات في New Space . يجب تنفيذ جميع العمليات المرتبطة بوحدة المعالجة المركزية والذاكرة في الخلفية. تجنب أيضًا مشغلات deoptimization للوظائف الساخنة ، تستخدم الوظيفة الساخنة المحسّنة ذاكرة أقل من تلك غير المحسّنة.
يجب تحسين الوظائف الساخنة
تؤدي الوظائف الساخنة التي تعمل بشكل أسرع ولكنها تستهلك ذاكرة أقل أيضًا إلى تشغيل GC بشكل أقل. يوفر V8 بعض أدوات التصحيح المفيدة لاكتشاف الوظائف غير المحسّنة أو الوظائف المحسّنة.
تجنب تعدد الأشكال لـ IC في الوظائف الساخنة
تستخدم ذاكرة التخزين المؤقت المضمنة (IC) لتسريع تنفيذ بعض أجزاء التعليمات البرمجية ، إما عن طريق التخزين المؤقت لمفتاح الوصول obj.key
الكائن أو بعض الوظائف البسيطة.
function x(a, b) { return a + b; } x(1, 2); // monomorphic x(1, “string”); // polymorphic, level 2 x(3.14, 1); // polymorphic, level 3
عندما يتم تشغيل x (a، b) للمرة الأولى ، يقوم V8 بإنشاء دائرة متكاملة أحادية الشكل. عند استدعاء x
مرة ثانية ، يمحو V8 رمز IC القديم ويخلق IC متعدد الأشكال جديدًا يدعم نوعي الأعداد الصحيحة والسلسلة. عندما تتصل بـ IC للمرة الثالثة ، يكرر V8 نفس الإجراء ويخلق IC متعدد الأشكال آخر من المستوى 3.
ومع ذلك ، هناك قيود. بعد أن يصل مستوى IC إلى 5 (يمكن تغييره بعلامة –max_inlining_levels ) ، تصبح الوظيفة ضخمة الشكل ولا تعتبر قابلة للتحسين.
من المفهوم بشكل بديهي أن الوظائف أحادية الشكل تعمل بشكل أسرع ولها أيضًا بصمة ذاكرة أصغر.
لا تقم بإضافة ملفات كبيرة إلى الذاكرة
هذا واضح ومعروف. إذا كانت لديك ملفات كبيرة تريد معالجتها ، على سبيل المثال ملف CSV كبير ، فاقرأه سطراً بسطر وقم بمعالجة أجزاء صغيرة بدلاً من تحميل الملف بأكمله على الذاكرة. هناك حالات نادرة حيث يكون سطر واحد من csv أكبر من 1 ميغا بايت ، مما يسمح لك بتلائمه في New Space .
لا تحجب موضوع الخادم الرئيسي
إذا كان لديك بعض واجهة برمجة التطبيقات الساخنة التي تستغرق بعض الوقت للمعالجة ، مثل واجهة برمجة التطبيقات لتغيير حجم الصور ، فقم بنقلها إلى سلسلة منفصلة أو تحويلها إلى وظيفة في الخلفية. ستؤدي العمليات المكثفة لوحدة المعالجة المركزية إلى حظر الخيط الرئيسي مما يجبر جميع العملاء الآخرين على الانتظار والاستمرار في إرسال الطلبات. قد تتكدس بيانات الطلب غير المعالجة في الذاكرة ، مما يجبر GC الكامل على قضاء وقت أطول للانتهاء.
لا تقم بإنشاء بيانات غير ضرورية
مررت مرة بتجربة غريبة مع ريستيفاي. إذا أرسلت بضع مئات الآلاف من الطلبات إلى عنوان URL غير صالح ، فإن ذاكرة التطبيق ستنمو بسرعة تصل إلى مائة ميغا بايت حتى يبدأ تشغيل GC الكامل في غضون بضع ثوانٍ بعد ذلك ، وهو الوقت الذي سيعود فيه كل شيء إلى طبيعته. تبين أنه بالنسبة لكل عنوان URL غير صالح ، تؤدي إعادة التهيئة إلى إنشاء كائن خطأ جديد يتضمن تتبعات مكدس طويلة. أجبر هذا على تخصيص الكائنات التي تم إنشاؤها حديثًا في مساحة الكائنات الكبيرة بدلاً من مساحة جديدة .
قد يكون الوصول إلى هذه البيانات مفيدًا جدًا أثناء التطوير ، ولكن من الواضح أنه ليس مطلوبًا في الإنتاج. لذلك فإن القاعدة بسيطة - لا تقم بإنشاء بيانات ما لم تكن بحاجة إليها بالتأكيد.
تعرف على أدواتك
أخيرًا ، ولكن ليس آخراً ، هو معرفة أدواتك. هناك العديد من أدوات تصحيح الأخطاء وكاثرات التسرب ومولدات الرسوم البيانية للاستخدام. يمكن أن تساعدك كل هذه الأدوات في جعل برنامجك أسرع وأكثر كفاءة.
خاتمة
يعد فهم كيفية عمل محسن التعليمات البرمجية وجمع البيانات المهملة في V8 مفتاحًا لأداء التطبيق. يقوم V8 بترجمة JavaScript إلى التجميع الأصلي وفي بعض الحالات يمكن أن تحقق التعليمات البرمجية المكتوبة جيدًا أداءً مشابهًا للتطبيقات المجمعة في دول مجلس التعاون الخليجي.
وفي حال كنت تتساءل ، فإن تطبيق API الجديد لعميل Toptal الخاص بي ، على الرغم من وجود مجال للتحسين ، يعمل بشكل جيد للغاية!
أصدرت Joyent مؤخرًا إصدارًا جديدًا من Node.js يستخدم أحد أحدث إصدارات V8. قد لا تكون بعض التطبيقات المكتوبة لـ Node.js v0.12.x متوافقة مع إصدار v4.x الجديد. ومع ذلك ، ستشهد التطبيقات أداءً هائلاً وتحسينًا في استخدام الذاكرة ضمن الإصدار الجديد من Node.js.