سریع نگه داشتن Node.js: ابزار، تکنیک‌ها و نکاتی برای ساخت سرورهای Node.js با کارایی بالا - بخش دوم
ﺯﻣﺎﻥ ﻣﻄﺎﻟﻌﻪ: 13 دقیقه

سریع نگه داشتن Node.js: ابزار، تکنیک‌ها و نکاتی برای ساخت سرورهای Node.js با کارایی بالا - بخش دوم

خلاصه اولیه: Node یک پلتفرم همه کاره است، اما یکی از مصارف غالب آن، ساخت پردازش‌های شبکه شده می‌باشد. در این مقاله، ما بر روی profile کردن رایج‌ترین این موارد تمرکز خواهیم کرد: وب سرورهای HTTP.

به بخش دوم این مقاله خوش آمدید. در ادامه با ما همراه باشید...

تجزیه و تحلیل

یک راه برای تشخیص سریع کدی که کارایی پایینی دارد، این است که یک نمودار شعله‌ای را ساخته، و آن را تجزیه و تحلیل کنیم. یک نمودار شعله‌ای، فراخوانی‌های تابع را به عنوان بلوک‌هایی که بر روی یکدیگر قرار دارند، نمایش می‌دهد. علت این که آن را «نمودار شعله‌ای» می‌نامند، این است که معمولا از طرح‌های نارنجی تا قرمز استفاده می‌کند که در آن‌ها هرچه یک بلوک قرمزتر باشد، تابع مربوطه داغ‌تر است، که یعنی احتمال مسدود کردن حلقه رویداد در آن بیشتر است. دریافت داده‌ها از یک نمودار شعله‌ای، از طریق نمونه برداری از CPU انجام می‌شود. یعنی یک تصویر فوری از تابعی که در حال حاضر اجرا می‌شود، و پشته آن گرفته می‌شود. دما، توسط درصد زمان صرف شده در حین profile کردن، که یک تابع داده شده در بالای پشته مربوطه (برای مثال تابعی که در حال حاضر اجرا می‌شود) برای هر نمونه قرار دارد، تعیین می‌شود. اگر این تابع، آخرین تابعی نیست که داخل آن پشته فراخوانی می‌شود، پس احتمال مسدود کردن حلقه رویداد در آن بالا است.

بیایید از clinic flame برای تولید یک نمودار شعله‌ای از روی برنامه نمونه استفاده کنیم:

clinic flame --on-port=’autocannon -c100 localhost:$PORT/seed/v1’ -- node index.js

نتیجه باید در مرورگر ما به همراه چنین چیزی باز شود:

عرض یک بلوک، نمایانگر زمانی است که این بلوک به طور کلی بر روی CPU صرف کرده است. می‌توان دید که سه پشته اصلی بیشترین زمان را می‌برند، که تمام آن‌ها server.on را داغ‌ترین تابع نشان می‌دهند. در واقع، هر سه پشته مشابه هستند. آن‌ها به این دلیل از هم دور می‌شوند که در حین profile کردن، با توابع بهینه‌سازی شده و بهینه‌سازی نشده به عنوان فریم‌های جداگانه رفتار می‌شود. توابعی که یک «*» را به عنوان پیشوند خود دارند، توسط موتور JavaScript بهینه‌سازی شده‌اند و توابعی که یک «~» را به عنوان پیشوند خود دارند، بهینه‌سازی نشده‌اند. اگر وضعیت بهینه‌سازی برای ما مهم نیست، ما می‌توانیم نمودار را با فشردن دکمه Merge، ساده‌تر کنیم. این کار باید ما را به چیزی به مانند این تصویر برساند:

طبق این خروجی، ما می‌توانیم نتیجه‌گیری کنیم که کد مجرم، فایل util.js مربوط به کد برنامه است.

تابع کند ما، همچنین یک handler رویداد است: عملکردهایی که به تابع ختم می‌شوند، بخشی ماژول هسته‌ای events هستند، و server.on یک نام پشتیبان برای یک تابع ناشناس است که به عنوان یک رویداد در حال مدیریت تابع فراهم شده است. همچنین ما می‌توانیم ببینیم که این کد، مشابه به کدی که در واقع درخواست را مدیریت می‌کند نیست. اگر مشابه آن بود، توابع ماژول‌های هسته‌ای http، net و stream در پشته مربوطه وجود داشتند.

توابع هسته‌ای این چنینی، می‌توانند با گسترش بخش‌های دیگر و بسیار کوچک‌تر نمودار شعله‌ای یافت شوند. برای مثال، سعی کنید از ورودی جستجو در بالا سمت راست رابط کاربری استفاده کنید و برای عبارت send‌ (نام هر دو متد داخلی restify و http) جستجو کنید. این عبارت باید در بالا سمت راست نمودار باشد (توابع به صورت الفبایی چیده شده‌اند):

دقت کنید که بلوک‌های مدیریت HTTP چقدر کوچک هستند.

ما بر روی یکی از بلوک‌هایی که با رنگ فیروزه‌ای برجسته شده‌اند کلیک می‌کنیم تا توابعی مانند writeHead و write در فایل http_outgoing.js (بخشی از کتابخانه هسته‌ای HTTP در Node) را نمایش دهیم:

ما می‌توانیم بر روی all stacks کلیک کنیم، تا به صفحه اصلی برگردیم.

نکته کلیدی در اینجا،‌ این است که با این که تابع server.on مشابه به کد مربوط به مدیریت درخواست اصلی نیست، اما همچنان با ایجاد تاخیر در اجرای کد کارآمد، در حال تاثیر گذاشتن بر روی کارایی کلی سرور است.

عیب‌یابی

ما طبق نمودار شعله‌ای می‌دانیم که تابع مشکل‌دار، handler رویدادی است که به server.on در فایل util.js منتقل شده است.

بیایید نگاهی به آن داشته باشیم:

server.on('after', (req, res) => {

  if (res.statusCode !== 200) return

  if (!res._body) return

  const key = crypto.createHash('sha512')

    .update(req.url)

    .digest()

    .toString('hex')

  const etag = crypto.createHash('sha512')

    .update(JSON.stringify(res._body))

    .digest()

    .toString('hex')

  if (cache[key] !== etag) cache[key] = etag

})

ما به خوبی می‌دانیم که عملیات‌های رمزنگاری، و همینطور سریال‌سازی (JSON.stringify) هزینه بردار هستند، اما چرا در نمودار شعله‌ای ظاهر نمی‌شوند؟ این عملیات‌ها در نمونه‌های دریافت شده وجود دارند، اما پشت فیلتر cpp مخفی شده‌اند. اگر ما دکمه cpp را بفشاریم، باید چنین چیزی ببینیم:

دستور العمل‌های داخلی V8 مربوط به سریال‌سازی و رمزنگاری، به عنوان داغ‌ترین پشته نشان داده می‌شوند؛ زیرا بیشترین زمان را می‌برند. متد JSON.stringify مسقیما کد C++ را فراخوانی می‌کند؛ به همین علت است که ما هیچ تابع JavaScriptای را نمی‌بینیم. در موقعیت رمزنگاری، توابعی مانند createHash و update در داده‌ها وجود دارند، اما نه فشرده شده‌اند (یعنی برخلاف حالت فشرده شده، در صفحه ادغام شده غیب نمی‌شوند) و نه بیش از حد کوچک هستند که بتوان آن‌ها را رندر کرد.

پس از این که ما کد موجود در تابع etagger را استدلال کنیم، به سرعت می‌توان دید که طراحی آن ضعیف است. چرا ما نمونه server را از زمینه تابع می‌گیریم؟ مقدار زیادی فرایند hash کردن در اینجا برقرار است، اما آیا همه آن‌ها ضروری‌اند؟ همچنین در یک سناریوی واقعی، هیچ نوع پشتیبانی برای یک هِدِر تحت عنوان IF-Node-Match در پیاده‌سازی مربوط به مهاجرت داده‌ها وجود ندارد؛ زیرا کاربران فقط یک درخواست را برای تعیین تازه بودن صفحه ارسال خواهند کرد.

بیایید فعلا تمام این نکات را فراموش کنیم و این که کار اصلی تحت اجرا در server.on، در تنگنای ما است را تصدیق کنیم. این هدف می‌تواند با تنظیم server.on برابر با کد مربوط به یک تابع خالی و تولید یک نمودار شعله‌ای جدید، تحقق یابد.

تابع etagger را به این صورت تغییر دهید:

function etagger () {

  var cache = {}

  var afterEventAttached = false

  function attachAfterEvent (server) {

    if (attachAfterEvent === true) return

    afterEventAttached = true

    server.on('after', (req, res) => {})

  }

  return function (req, res, next) {

    attachAfterEvent(this)

    const key = crypto.createHash('sha512')

      .update(req.url)

      .digest()

      .toString('hex')

    if (key in cache) res.set('Etag', cache[key])

    res.set('Cache-Control', 'public, max-age=120')

    next()

  }

}

تابع listener رویدادی که به server.on‌ منتقل شده است، حال یک فضای اشغال شده است که هیچ عملیاتی را انجام نمی‌دهد.

بیایید دستور clinic flame را مجددا اجرا کنیم:

clinic flame --on-port='autocannon -c100 localhost:$PORT/seed/v1' -- node index.js

این کد باید یک نمودار شعله‌ای به مانند این تصویر را ایجاد کند:

این نمودار ظاهر بهتری دارد، و ما باید متوجه افزایشی در تعداد درخواست‌ها بر ثانیه شده باشیم. اما چرا این رویداد چنین کد داغی را می‌‌تابد؟ ما در اینجا انتظار داریم که کد پردازش HTTP اکثریت زمان CPU را ببرد، اما هیچ اجرایی در رویداد server.on وجود ندارد.

این نوع تنگنا، توسط اجرای بیش از حد یک تابع ایجاد می‌شود.

کد مشکوک زیر در بالای util.js می‌تواند یک سر نخ باشد:

require('events').defaultMaxListeners = Infinity

بیایید این خط را حذف کنیم و پردازش را با --trace-warnings شروع کنیم:

node --trace-warnings index.js

اگر ما به این صورت با AutoCannon در یک ترمینال دیگر profile کنیم:

autocannon -c100 localhost:3000/seed/v1

پردازش ما چیزی به مانند این نتیجه را خروجی خواهد داد:

(node:96371) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 after listeners added. Use emitter.setMaxListeners() to increase limit

  at _addListener (events.js:280:19)

  at Server.addListener (events.js:297:10)

  at attachAfterEvent 

    (/Users/davidclements/z/nearForm/keeping-node-fast/slow/util.js:22:14)

  at Server.

    (/Users/davidclements/z/nearForm/keeping-node-fast/slow/util.js:25:7)

  at call

    (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:164:9)

  at next

    (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:120:9)

  at Chain.run

    (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:123:5)

  at Server._runUse

    (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:976:19)

  at Server._runRoute

    (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:918:10)

  at Server._afterPre

    (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:888:10)

Node دارد به ما می‌گوید که تعداد زیادی رویداد در حال اتصال یافتن (connect شدن) به آبجکت server هستند. این مسئله عجیب است؛ زیرا یک مقدار boolean در کد ما وجود دارد که وضعیت اتصال یک رویداد را بررسی می‌کند و پس از این که اولین رویداد متصل شده است، attachAfterEvent را تبدیل به یک فضای اشغال شده می‌کند که هیچ عملیاتی را انجام نمی‌دهد.

بیایید به تابع attachAfterEvent نگاهی داشته باشیم:

var afterEventAttached = false

function attachAfterEvent (server) {

  if (attachAfterEvent === true) return

  afterEventAttached = true

  server.on('after', (req, res) => {})

}

بررسی شرطی ما اشتباه است! این بررسی، وضعیت true بودن attachAfterEvent را به جای afterEventAttached بررسی می‌کند. این یعنی در هنگام هر درخواست، یک رویداد جدید در حال متصل شدن به نمونه server است، و سپس تمام رویدادهای از پیش متصل شده، پس از هر درخواست اجرا می‌شوند.

بهینه‌سازی

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

ثمره آسان

بیایید کد listener مربوط به server.on را پشت سر بگذاریم و از نام Boolean مناسب در بررسی شرطی استفاده کنیم. تابع etagger ما به این صورت است:

function etagger () {

  var cache = {}

  var afterEventAttached = false

  function attachAfterEvent (server) {

    if (afterEventAttached === true) return

    afterEventAttached = true

    server.on('after', (req, res) => {

      if (res.statusCode !== 200) return

      if (!res._body) return

      const key = crypto.createHash('sha512')

        .update(req.url)

        .digest()

        .toString('hex')

      const etag = crypto.createHash('sha512')

        .update(JSON.stringify(res._body))

        .digest()

        .toString('hex')

      if (cache[key] !== etag) cache[key] = etag

    })

  }

  return function (req, res, next) {

    attachAfterEvent(this)

    const key = crypto.createHash('sha512')

      .update(req.url)

      .digest()

      .toString('hex')

    if (key in cache) res.set('Etag', cache[key])

    res.set('Cache-Control', 'public, max-age=120')

    next()

  }

}

حال وضعیت رفع مشکل خود را با profile کردن مجدد بررسی می‌کنیم. سرور را در یک ترمینال اجرا کنید:

node index.js

سپس مجددا با استفاده از AutoCannon آن را profile کنید:

autocannon -c100 localhost:3000/seed/v1

ما باید نتایج را جایی در محدوده ۲۰۰ برابر بهتر ببینیم (اجرای‌ آزمایش دهم در http://localhost:3000/seed/v1 - ۱۰۰ ارتباط):

مهم است که کاهش‌های احتمالی هزینه سرور را با هزینه‌های توسعه‌دهی متعادل کنیم. ما باید در زمینه موقعیتی خود تعریف کنیم که چقدر باید یک پروژه را بهینه‌سازی کنیم. در غیر این صورت، به سادگی می‌توان ۸۰ درصد تلاش را در ۲۰ درصد پیشرفت سرعت خرج کرد. آیا محدودیت‌های پروژه این مسئله را توجیح می‌کنند؟

در برخی سناریوها، می‌توان یک بهبود ۲۰۰ برابری را با یک ثمره آسان به دست آورد و به کار اتمام بخشید. در باقی سناریوها، شاید بخواهیم پیاده‌سازی‌های خود را در حد ممکن سریع کنیم. این مسئله به اولویت‌های پروژه بستگی دارد.

یک راه برای کنترل استفاده از منبع، این است که یک هدف برای خود قرار دهیم. برای مثال، بهبود ۱۰ برابری سرور یا اجرای ۴۰۰۰ درخواست بر ثانیه. پایه‌‌گذاری این موضوع در کسب و کار، باید حداکثر منطق را داشته باشد. برای مثال، اگر هزینه‌های سرور ۱۰۰ درصد بیشتر از بودجه ما هستند، ما می‌توانیم هدف بهبود ۲ برابری سرور را برای خود قرار دهیم.

فراتر بردن این کار

اگر ما یک نمودار شعله‌ای جدید از سرور خود ایجاد کنیم، باید چیزی به مانند این تصویر ببینیم:

Listener رویداد، همچنان تنگنای ما است و همچنان یک سوم CPU در را در حین profile‌ کردن اشغال می‌کند. (عرض، تقریبا یک سوم کل نمودار است)

چه دستاوردهای بیشتری می‌توان داشت، و آیا این تغییرات (به همراه توزیعی که در کنارشان انجام می‌شود) ارزش پیاده‌سازی را دارند؟

با یک پیاده‌سازی بهینه‌سازی شده، که با این وجود کمی محدود است، این مشخصات می‌توانند تحقق یابند (اجرای‌ آزمایش دهم در http://localhost:3000/seed/v1 - ۱۰۰ ارتباط):

با این که یک بهبود چشمگیر ۱.۶ برابری در اینجا وجود دارد، اما باز هم بستگی دارد که تلاش‌ها، تغییرات و توزیع کدی که برای این بهبود انجام شده است، توجیح شده است یا نه. به خصوص وقتی که با یک بهبود ۲۰۰ برابری بر روی پیاده‌سازی اصلی، و فقط با یک بر طرف کردن باگ ساده مقایسه شود.

برای رسیدن به این بهبود، یک سری تکنیک‌های profile کردن، تولید نمودار شعله‌ای، تجزیه و تحلیل و بهینه‌سازی تکراری برای رسیدن به سرور بهینه‌سازی شده نهایی انجام شدند. کد این سرور می‌تواند در این لینک یافت شود.

تغییرات اعمال شده نهایی برای رسیدن به ۸۰۰۰ درخواست بر ثانیه، این موارد بودند:

این تغییرات کمی درگیرتر بوده، کمی در قبال سورس کد مخرب‌تر هستند و میان‌افزار etagger را با کمی انعطاف کمتر رها می‌کنند؛ زیرا مسئولیت فراهم کردن مقدار Etag را به مسیر (route) می‌سپارند. اما دستگاه profile کردن، به ۳۰۰۰ درخواست بیشتر در ثانیه می‌رسد.

بیایید نگاهی به نمودار شعله‌ای مربوط به این بهبود نهایی داشته باشیم:

داغ‌ترین بخش نمودار شعله‌ای، بخشی از هسته Node در ماژول net است. این مسئله ایده‌آل می‌باشد.

جلوگیری از مشکلات کارایی

برای خلاصه کردن، در اینجا برخی پیشنهادات مربوط به راه‌های جلوگیری از مشکلات کارایی را برای قبل از استقرار آن‌ها مشاهده می‌نمایید.

استفاده از ابزار کارایی به عنوان نقاط بازرسی غیر رسمی در طی توسعه‌دهی، می‌تواند عیب‌های کارایی را قبل از این که به مرحله تولید برسند، جدا کند. پیشنهاد می‌شود که AutoCannon و Clinic را تبدیل به بخشی از ابزار توسعه‌دهی روزانه خود کنید.

وقتی که به یک فریم‌وورک وارد می‌شوید، سیاست آن درباره کارایی را بررسی کنید. اگر این فریم‌وورک کارایی را در اولویت قرار نمی‌دهد، پس مهم است بررسی کنید که آیا با شیوه‌های زیرساختاری و اهداف کسب و کار هم‌تراز است، یا نه. برای مثال، Restify (از نسخه ۷ به بعد) به وضوح در بهبود کارایی کتابخانه سرمایه‌گذاری کرده است.

در آخر، همیشه به یاد داشته باشید که حلقه رویداد یک منبع به اشتراک گذاشته شده است. یک سرور Node.js، در نهایت توسط کندترین منطق و در داغ‌ترین مسیر محدود می‌شود.

منبع

چه امتیازی برای این مقاله میدهید؟

خیلی بد
بد
متوسط
خوب
عالی
در انتظار ثبت رای

/@er79ka

دیدگاه و پرسش

برای ارسال دیدگاه لازم است وارد شده یا ثبت‌نام کنید ورود یا ثبت‌نام

در حال دریافت نظرات از سرور، لطفا منتظر بمانید

در حال دریافت نظرات از سرور، لطفا منتظر بمانید