توسعه فرانت‌اند

JavaScript Event Loop: درک عمیق ناهمزمانی

مدل اجرای مرورگر، تفاوت Task و Microtask، محدودیت Timer و async، ترتیب پاسخ و لغو؛ نمونه HTML کامل با ترتیب پیام مشخص.

1404/12/12 2 دقیقه مطالعه 1,738 بازدید

Event Loop کمک می‌کند محیطی مثل مرورگر، اجرای کد و رسیدگی به رویدادها را هماهنگ کند. از آن نباید یک صف واحد با اولویت ثابت برای همه کارها ساخت. کد هم‌زمان، Timer، Promise و فرصت Render رابطه‌های متفاوتی دارند و رفتار Host مهم است. وقتی یک Handler طولانی اجرا می‌شود، صرف استفاده از async در نام تابع آن را از مسیر اصلی صفحه جدا نمی‌کند. باید بدانید کدام بخش اکنون اجرا می‌شود و کدام بخش برای بعد زمان‌بندی شده است.

برای مثال فرضی، کاربر دکمه ساخت گزارش را می‌زند و صفحه پیام «در حال پردازش» می‌نویسد. اگر بلافاصله یک حلقه طولانی در همان اجرای هم‌زمان شروع شود، مرورگر ممکن است فرصت نمایش پیام را تا پایان حلقه پیدا نکند. تغییر DOM با دیده شدن تغییر یکسان نیست. فهم Event Loop این رابطه را توضیح می‌دهد، اما تضمین نمی‌کند همه باگ‌های async حذف شوند؛ اعتبار داده، لغو و ترتیب پاسخ‌ها هنوز طراحی جداگانه می‌خواهند.

Engine و Host مسئولیت متفاوت دارند

مدل اجرای JavaScript در MDN نقش Engine و Host را تفکیک می‌کند. زبان، قواعد اجرای تابع و Promise را تعریف می‌کند و محیط مرورگر امکاناتی مثل DOM، شبکه و Timer را اضافه می‌کند. Node.js Host دیگری است و جزئیات زمان‌بندی آن را نباید از یک مثال مرورگر نتیجه گرفت. اصطلاح JavaScript تک‌نخی باید با محدوده Agent و محیط توضیح داده شود؛ مرورگر در مجموع فقط یک رشته برای همه کارهایش ندارد.

Call Stack مسیر فراخوانی‌های هم‌زمان را نگه می‌دارد. تابعی که تابع دیگری را صدا می‌زند تا پایان آن فراخوانی منتظر اجرای هم‌زمان می‌ماند. Callback زمان‌بندی‌شده نمی‌تواند وسط یک قطعه هم‌زمان معمولی وارد شود و همان Stack را قطع کند. این رفتار به استدلال درباره تغییر متغیرها کمک می‌کند. از طرف دیگر، داده میان اجراهای جدا ممکن است تغییر کرده باشد. مقاله Closures توضیح می‌دهد Callback از کدام Binding می‌خواند.

Task را با یک صف عمومی اشتباه نگیرید

در مرورگر، رویدادها و بعضی Callbackها به Task مربوط می‌شوند. استاندارد HTML Event Loop، Task Sourceها و فرصت‌های Render را تعریف می‌کند. نمایش یک Callback Queue واحد در نمودار آموزشی، ساده‌سازی است و همه انتخاب‌های محیط را نشان نمی‌دهد. MacroTask اصطلاح رایج آموزشی است؛ هنگام خواندن استاندارد، Task نام دقیق‌تر همان بخش مدل است. نام صف به تنهایی درباره ترتیب همه رویدادهای مستقل حکم نمی‌دهد.

setTimeout با تأخیر صفر، Callback را هم‌زمان اجرا نمی‌کند. تأخیر، زمان دقیق اجرا یا Deadline قطعی نیست؛ کارهای جاری و سیاست محیط می‌توانند آن را عقب بیندازند. در تب پس‌زمینه یا Timerهای تودرتو نیز محدودیت‌های دیگری وجود دارند. اگر عملیات باید در زمان تجاری مشخص انجام شود، آن را با Timer Client تضمین نکنید. نمایش شمارش معکوس می‌تواند از زمان معتبر استفاده کند، اما اجرای Callback مرورگر مرجع نهایی زمان Server نیست.

Microtask در Checkpoint رسیدگی می‌شود

راهنمای Microtask در MDN queueMicrotask و رابطه آن با Task را توضیح می‌دهد. Callbackهای Promise و queueMicrotask می‌توانند در Microtask Checkpoint اجرا شوند. در مثال ساده‌ای که در یک اجرا هر دو Promise آماده و Timer زمان‌بندی می‌شوند، Microtaskها پیش از Callback آن Timer رسیدگی می‌شوند. این انتظار را به «هر Promise در هر شرایطی همیشه زودتر از هر Timer» تبدیل نکنید؛ زمان آماده شدن و محیط مشترک مهم‌اند.

Microtaskهای تازه می‌توانند در همان رسیدگی به صف اضافه شوند. زنجیره‌ای که بی‌حد کار تازه زمان‌بندی می‌کند ممکن است فرصت Task بعدی یا Render را به تأخیر بیندازد. استفاده از Promise برای شکستن یک محاسبه سنگین الزاماً به مرورگر فرصت نمایش نمی‌دهد. باید مشخص کنید هدف فقط جدا کردن ادامه منطقی است یا واگذاری فرصت به محیط. برای پردازش CPU مستقل، مقاله Web Workers گزینه متفاوتی معرفی می‌کند.

async و await کار را موازی نمی‌کنند

بخش ابتدایی تابع async تا رسیدن به نقطه واگذاری، به شکل هم‌زمان اجرا می‌شود. await ادامه تابع را در ارتباط با نتیجه Promise زمان‌بندی می‌کند؛ محاسبه CPU خودکار به Thread دیگری نمی‌رود. اگر قبل از await یک حلقه طولانی دارید، همان حلقه می‌تواند تعامل را دیر کند. همچنین await روی Promise آماده، با دریافت شبکه واقعی یکسان نیست. هر دو ادامه async دارند، اما زمان آماده شدن نتیجه متفاوت است و باید در تحلیل مسیر دیده شود.

چند await پیاپی می‌توانند عملیات مستقل را بی‌دلیل پشت هم قرار دهند. اگر وابستگی واقعی ندارند، اجرای هم‌زمان و انتظار گروهی ممکن است مناسب باشد؛ اما تعداد درخواست‌ها و محدودیت Server را در نظر بگیرید. Promise.all برای سیاست شکست مشخصی دارد و allSettled نتیجه همه را نگه می‌دارد. انتخاب باید با قرارداد کار مرتبط باشد. هیچ کدام مجوز، Retry یا Cancel را خودکار تعریف نمی‌کنند و نباید برای پنهان کردن خطای جزئی در گزارش کامل استفاده شوند.

نمونه کامل ترتیب قابل مشاهده

فایل زیر را event-loop-demo.html ذخیره و مستقیم در مرورگر باز کنید. وابستگی خارجی ندارد و شبکه‌ای استفاده نمی‌کند. دکمه اجرا یک Task تعاملی را شروع می‌کند و پیام‌های هم‌زمان، Microtask و Timer را در فهرست می‌نویسد. تا پایان Timer دکمه غیرفعال است تا اجرای دوم با اول مخلوط نشود. مثال عمداً کوچک است و برای Benchmark یا نمایش کل ترتیب داخلی مرورگر طراحی نشده؛ فقط رابطه همان کارهای مشخص را نشان می‌دهد.

<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>نمونه Event Loop</title><style>body{font:1rem/1.8 Tahoma,sans-serif;max-width:40rem;margin:2rem auto;padding:1rem}button{font:inherit;padding:.5rem}button:focus-visible{outline:3px solid #111;outline-offset:3px}</style></head><body><h1>ترتیب ثبت پیام</h1><button id="run" type="button">اجرای نمونه</button><ol id="log" aria-label="پیام‌ها"></ol><p id="state" role="status">آماده</p><script>
const button=document.getElementById('run'),list=document.getElementById('log'),state=document.getElementById('state');
function log(text){const item=document.createElement('li');item.textContent=text;list.append(item)}
button.onclick=()=>{button.disabled=true;list.replaceChildren();state.textContent='در حال اجرا';log('شروع هم‌زمان');setTimeout(()=>{log('Timer');button.disabled=false;state.textContent='اجرای محدود نمونه تمام شد.'},0);Promise.resolve().then(()=>log('Promise.then'));queueMicrotask(()=>log('queueMicrotask'));(async()=>{await Promise.resolve();log('ادامه async')})();log('پایان هم‌زمان')};
</script></body></html>

انتظار این نمونه از قواعد زمان‌بندی همان قطعه به دست می‌آید: «شروع هم‌زمان» و «پایان هم‌زمان» پیش از پیام‌های صف نوشته می‌شوند. Promise.then پیش از queueMicrotask در کد زمان‌بندی شده و سپس ادامه async قرار می‌گیرد. Timer پس از این Microtaskهای محدود پیام خودش را اضافه می‌کند. این توضیح درباره همین ترتیب ثبت است؛ اگر جای فراخوانی‌ها، آماده بودن Promise یا Host را تغییر دهید باید دوباره رابطه را بررسی کنید.

append کردن پیام، به معنی Paint جدا برای هر پیام نیست. مرورگر ممکن است چند تغییر DOM را در یک فرصت نمایش نشان دهد. مثال requestAnimationFrame را وارد ترتیب نکرده، چون هدف، ادعای ترتیب کامل Frameها نیست. اگر درباره حرکت یا نمایش فریم تصمیم می‌گیرید، سازوکار مربوط و هزینه Layout را جدا بررسی کنید. افزودن تعداد بیشتری Log به نمونه کوچک، آن را به مدل کامل موتور مرورگر تبدیل نمی‌کند.

پاسخ قدیمی و لغو را در قرارداد بیاورید

در جست‌وجو، درخواست دوم ممکن است زودتر از درخواست اول جواب بدهد. Event Loop نتیجه را طبق آماده شدن دریافت می‌کند و نمی‌داند کدام Query برای کاربر تازه‌تر است. شناسه درخواست یا روش لغو می‌تواند مانع جایگزینی نتیجه جدید با قدیمی شود. Closure هم باید داده مناسب همان عملیات را نگه دارد. مقاله عملکرد React درباره پاسخ‌گویی رابط توضیح دارد، اما سریع بودن Render جای درستی ترتیب داده را نمی‌گیرد.

Cancel یک انتظار محصول است و باید به لایه انجام کار وصل شود. بستن یک Dialog لزوماً درخواست شبکه را متوقف نمی‌کند؛ نتیجه دیررس نباید State بخش حذف‌شده را به شکلی نامناسب تغییر دهد. بعضی APIها AbortSignal می‌پذیرند و بعضی عملیات فقط می‌توانند نتیجه را کنار بگذارند. وعده «لغو شد» باید با همان رفتار سازگار باشد. برای تراکنش Server نیز لغو انتظار Client همیشه به معنی لغو عملیات ثبت‌شده نیست.

خطا و فرصت اجرای رابط را روشن کنید

خطای یک Promise باید در مسیر مناسب دریافت و مدیریت شود. try/catch هم‌زمان دور ثبت Callback، همه خطاهای اجرای بعدی آن را نمی‌گیرد. اگر نتیجه عملیات async لازم است، آن نتیجه را await یا با Handler مناسب مصرف کنید. خطای شبکه، خطای تبدیل داده و خطای برنامه‌نویسی نقش متفاوت دارند. نمایش موفقیت در finally اشتباه است؛ finally برای کاری است که مستقل از موفقیت باید انجام شود، مانند پایان وضعیت انتظار یا آزادسازی منبع مناسب.

برای محاسبه طولانی، کاهش کار و تقسیم آن به بخش‌های محدود می‌تواند کمک کند. اگر استقلال از DOM وجود دارد، Worker را بررسی کنید. زمان‌بندی Task تازه هم تضمین سرعت بیشتر خود محاسبه نیست و هزینه رفت‌وبرگشت دارد. در رابط، پیام انتظار، حد ورودی و راه لغو مشخص باشند. مقاله فرم مرحله‌ای درباره حفظ داده هنگام انتظار و شکست مفید است؛ کاربر باید نتیجه نامعلوم را با ثبت موفق اشتباه نگیرد.

مدل را با محیط و سناریو تحویل دهید

برای تحلیل یک قطعه، اجرای هم‌زمان، زمان ثبت Callback، زمان آماده شدن داده و تغییر State را روی یک خط زمانی ساده بنویسید. سپس مشخص کنید کدام رابطه تضمین‌شده و کدام به شبکه یا Host وابسته است. حدس درباره «اولویت بالاتر» بدون شناخت صف، معمولاً تحلیل را ناقص می‌کند. در Code Review، نام تابع async نباید مانع دیدن کار سنگین هم‌زمان داخل آن شود.

در تحویل، Host، رفتار خطا و روش Cancel را ثبت کنید. خروجی مورد انتظار نمونه کوچک را از زمان اجرای واقعی جدا نگه دارید و درصد یا زمان اندازه‌گیری‌نشده اعلام نکنید. Event Loop زمانی به طراحی کمک می‌کند که ترتیب داده و فرصت تعامل را روشن کند. کار بعدی، انتخاب یک مسیر async واقعی و بررسی همین مرزها است؛ نه افزودن Timer یا Promise تازه صرفاً برای اینکه کد ظاهر ناهم‌زمان پیدا کند.

واکنش به این مقاله

اشتراک‌گذاری این مقاله:
دیدگاه‌ها 0
۰ / ۲۰۰۰

هنوز دیدگاهی ثبت نشده

اولین نفری باشید که نظر می‌دهد!