کار ناهمگام و Event Loop؛ انتظار شبکه با محاسبهٔ سنگین فرق دارد
در این درس ۶ بخش
در یک سرور، بعضی زمانها صرف انتظار میشود: پاسخ پایگاه داده، خواندن فایل یا رسیدن داده از شبکه. بعضی زمانها خود پردازنده مشغول محاسبه است. این دو وضعیت به راهحل یکسان نیاز ندارند. اگر برنامه در حال انتظار غیرمسدودکننده باشد، فرصت رسیدگی به کارهای آمادهٔ دیگر وجود دارد؛ ولی یک حلقهٔ محاسباتی طولانی روی رشتهٔ اجرای جاوااسکریپت میتواند همان رسیدگی را عقب بیندازد. افزودن async به نام تابع، محاسبهٔ درون آن را خودکار به پردازندهای دیگر نمیفرستد.
مدل ذهنی دقیقتر از «Node فقط یک رشته دارد»
در هر نمونهٔ معمول اجرای جاوااسکریپت، callbackها و کد جاوااسکریپت روی رشتهٔ مربوط به همان محیط اجرا میشوند. خود Node.js و کتابخانههای زیرین امکانات بیشتری دارند: بخشی از کارهای ورودیوخروجی با سازوکار سیستم عامل و بعضی عملیات با مخزن کارگر انجام میشود؛ worker_threads نیز برای اجرای جاوااسکریپت در رشتههای جدا وجود دارد. بنابراین «کل Node فقط یک رشته دارد» توضیح دقیقی نیست. برای این دوره، سؤال عملی این است: کدام قسمت کد من، ادامهٔ کار حلقهٔ رویداد را معطل میکند؟
آزمایش ترتیب، بدون حدس زمان دقیق
فایل order.mjs را بسازید. پیش از اجرا ترتیب سه پیام را بنویسید و سپس در PowerShell دستور node .\order.mjs را اجرا کنید. این مثال یک تأخیر مصنوعی دارد و به اینترنت وابسته نیست؛ مدت نوشتهشده برای تایمر، وعدهٔ اجرای دقیق در همان میلیثانیه نیست.
import { setTimeout as delay } from 'node:timers/promises';
console.log('A: before');
const pending = delay(30).then(() => 'C: ready');
console.log('B: still running');
console.log(await pending);خروجی به ترتیب A: before، سپس B: still running و در آخر C: ready است. تابع delay یک Promise میدهد و فرصت ادامهٔ خط بعدی را فراهم میکند. await ادامهٔ همان جریان ناهمگام را تا آمادهشدن نتیجه معلق میکند؛ معنایش خواباندن همهٔ برنامه نیست. اگر پس از await، حلقهٔ سنگین بنویسید، آن حلقه همچنان جاوااسکریپت عادی است. برای عیبیابی، محل انتظار و محل پردازش را جدا علامت بزنید.
همزمانی محدود با موازیبودن یکی نیست
فایل jobs.mjs را با کد زیر مستقل بسازید. دو کار مصنوعی شروع میشوند و نتیجهها به ترتیب آرایهٔ ورودی جمع میشوند. زمان کل را معیار قطعی آزمون نگذارید؛ بار سیستم و زمانبندی اثر دارند. با این مثال فقط میخواهیم شروع مستقل کارها و ترتیب نتیجه را ببینیم.
import { setTimeout as delay } from 'node:timers/promises';
async function readLabel(label, ms) {
await delay(ms);
return label;
}
const labels = await Promise.all([
readLabel('first', 40),
readLabel('second', 10)
]);
console.log(labels); // ['first', 'second']Promise.all در اینجا خودش دو رشتهٔ پردازنده نمیسازد؛ فقط منتظر مجموعهای از Promiseها میماند. حتی اگر کار دوم زودتر تمام شود، جای نتیجهها با ترتیب ورودی مشخص است. در برنامهٔ واقعی، شروع همزمان هزاران درخواست بدون محدودیت، میتواند سرویس مقصد یا حافظهٔ خودتان را تحت فشار بگذارد. برای چند کار مستقل و محدود، این الگو خواناست؛ برای حجم بزرگ، صف و سقف همزمانی لازم است. اگر یکی شکست بخورد، شکست مجموعه لزوماً کارهای شروعشدهٔ دیگر را لغو نمیکند.
شکست را در همان مرز انتظار ببینید
async function loadSummary(load) {
try {
const rows = await load();
if (!Array.isArray(rows)) throw new TypeError('Expected rows');
return { ok: true, count: rows.length };
} catch {
return { ok: false, error: 'summary_unavailable' };
}
}
console.log(await loadSummary(async () => [1, 2]));
console.log(await loadSummary(async () => { throw new Error('demo'); }));در خروجی اول ok برابر true و count برابر ۲ است؛ در دومی ok برابر false است. این شکل پاسخ، انتخاب قرارداد مثال ماست و قانون عمومی همهٔ توابع نیست. نکته این است که Promise ردشده را انتظار کشیدیم تا try/catch مربوط آن را ببیند. حذف await و بازگرداندن مستقیم Promise از داخل try، میتواند باعث شود شکست بعدی در آن catch گرفته نشود. ثبت علت فنی در محل مناسب و پیام قابل فهم برای کاربر را جدا طراحی کنید.
این مثال خطا را به دادهٔ نتیجه تبدیل میکند، چون لایهٔ بعدی باید بتواند دربارهٔ نمایش پیام تصمیم بگیرد. در بخشی که ادامهدادن معنی ندارد، پرتابکردن خطا و مدیریت آن در مرز بالاتر هم انتخاب معقولی است. خطا را بیخبر به آرایهٔ خالی تبدیل نکنید؛ «داده نداریم» با «بارگیری شکست خورد» متفاوت است. همین تفاوت در گزارشهای مالی یا فهرست تیکتها میتواند برداشت کاربر را کاملاً عوض کند.
کجا باید راه دیگری انتخاب کرد؟
فشردهسازی سنگین، تبدیل ویدیو و محاسبهٔ بزرگ را با برچسب async سبک نمیکنیم. ابتدا اندازهٔ ورودی و زمان را اندازه بگیرید؛ سپس برحسب مسئله از کارگر، فرایند جدا، صف کار یا سرویس تخصصی استفاده کنید. در سرور وب، JSON بسیار بزرگ یا عبارت منظم نامناسب هم میتواند مسئلهٔ پردازشی بسازد. در این دوره عمداً آزمایش فشار سنگین اجرا نمیکنیم؛ تمرینهای کوچک برای دیدن رابطهٔ علت و اثر کافیاند.
تمرین، پاسخ و معیار تحویل
تابع loadSummary را با سه بارگذار آزمایش کنید: آرایهٔ خالی، خطای عمدی و شیء غیرآرایه. پاسخ راهنما: آرایهٔ خالی موفق با count صفر است؛ دو حالت دیگر ناموفقاند. سپس توضیح دهید چرا Promise.all دو تابع محاسباتی سنگین را خودکار به دو هسته نمیبرد. معیار تحویل، جداسازی شکست از نتیجهٔ خالی و توانایی مشخصکردن بخشهای انتظار و پردازش در کد است. وعدهٔ «سرعت چند برابر» بدون اندازهگیری جزو پاسخ پذیرفتهشده نیست.
Node.js، حلقهٔ رویداد: https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick
Node.js، مسدودنکردن حلقه و مخزن کارگر: https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop
Node.js، Worker threads: https://nodejs.org/api/worker_threads.html
تألیف و مثالهای اختصاصی: تحریریه درسآوند.
متن درسهای عمومی را بدون ورود بخوانید.