Menu

Event Loop ב-JavaScript: איך קוד אסינכרוני באמת רץ

המודל המנטלי שמאחורי JavaScript אסינכרוני: מחסנית הקריאות, תור המשימות, תור ה-microtasks, ואיך ה-event loop מחבר ביניהם.

בדף הזה יש עורכים שאפשר להריץ - לערוך, להריץ ולראות את הפלט מיד.

thread יחיד, אבל לא סדרתי

JavaScript רצה על thread אחד. יש מחסנית קריאות אחת, ובכל רגע נתון רצה בדיוק פונקציה אחת. אף פעם לא ירוצו שתי שורות מהקוד שלכם במקביל בתוך אותו realm.

זה נשמע מגביל, עד שנזכרים מה JavaScript עושה בפועל: מביאה נתונים, מחכה לקליקים של המשתמש, קוראת קבצים. רוב ה"עבודה" היא המתנה. ה-event loop הוא הטריק שהופך המתנה לזולה: הקוד שלכם מעביר משימה לדפדפן או ל-Node, חוזר לעשות דברים אחרים, ומקבל הודעה כשהתוצאה מוכנה.

ראשון ו-שני מודפסים לפי הסדר. שלישי מודפס אחריהם, למרות שה-timeout הוא 0. הפער הזה הוא ה-event loop בפעולה, ולהבין למה זה קורה זו כל המטרה של העמוד הזה.

מחסנית הקריאות

כל קריאה לפונקציה דוחפת frame למחסנית הקריאות. כשהפונקציה חוזרת, ה-frame שלה נשלף. המחסנית היא פשוט מחסנית: האחרון שנכנס הוא הראשון שיוצא.

כש-outer() רצה, Node דוחף את outer, אחר כך את inner, שולף את inner כשהיא מחזירה "סיימתי", ואז שולף את outer. המחסנית שוב ריקה. רגע ה"מחסנית ריקה" הזה הוא בדיוק מה שה-event loop מחכה לו.

קוד סינכרוני רץ מתחילתו ועד סופו על המחסנית. שום דבר אסינכרוני לא יכול לקטוע אותו. אם תכתבו לולאת while (true), המחסנית אף פעם לא מתרוקנת והדף קופא: אין קליקים, אין טיימרים, אין callbacks של promises. ל-event loop אין מה לעשות, כי הוא לא מקבל תור.

איפה העבודה האסינכרונית באמת נמצאת

JavaScript עצמה לא יודעת לשלוח בקשת רשת או לחכות 100 מילישניות. ה-APIs האלה שייכים לסביבה המארחת: הדפדפן או Node. כשקוראים ל-setTimeout(fn, 100), זה מה שקורה:

  1. הטיימר נרשם אצל הסביבה המארחת.
  2. setTimeout חוזר מיד. המחסנית ממשיכה לרוץ.
  3. אחרי 100ms, הסביבה המארחת מכניסה את fn לתור.
  4. כשהמחסנית ריקה, ה-event loop מוציא את fn מהתור ומריץ אותה.

ה-callback של הטיימר לא יכול לרוץ עד שלולאת ה-for ו-console.log("סוף") מסתיימים, כי המחסנית עוד לא ריקה. טיימרים הם השהיה מינימלית, לא הבטחה.

שני תורים: tasks ו-microtasks

אין רק תור אחד. יש שניים, וההבחנה ביניהם מסבירה את רוב ההפתעות של ה-event loop.

  • Task queue (שנקרא לפעמים macrotask queue): setTimeout, setInterval, callbacks של קלט/פלט, אירועי UI.
  • Microtask queue: callbacks של promises (.then, .catch, .finally), המשכים של await, וכל דבר שתוזמן עם queueMicrotask.

הכלל שה-event loop פועל לפיו:

  1. להריץ task אחד מה-task queue.
  2. לרוקן את כל ה-microtask queue: כל microtask, כולל כאלה שתוזמנו בזמן הריקון.
  3. לרנדר אם צריך (בדפדפנים).
  4. לחזור לשלב 1.

Microtasks תמיד רצים לפני ה-task הבא. לכן זה מפתיע אנשים:

סדר הפלט: sync 1, sync 2, promise, timeout. הקוד הסינכרוני רץ קודם. אחר כך המחסנית מתרוקנת. אחר כך ה-event loop מרוקן את ה-microtasks (promise). רק אז הוא לוקח את ה-task של הטיימר (timeout).

Microtasks יכולים להרעיב tasks

מכיוון שתור ה-microtasks מתרוקן לגמרי לפני ה-task הבא, microtask שממשיך לתזמן עוד microtasks יחסום את ה-task queue לנצח:

הטיימר לעולם לא יופעל, כי כל microtask מכניס לתור microtask נוסף, והלולאה אף פעם לא נותנת לתור להתרוקן. שרשראות של promises בטוחות, כי כל .then מתזמן רק המשך אחד, אבל לולאות microtasks שנכתבות ביד הן מלכודת ששווה להכיר.

await הוא syntactic sugar ל-microtask

כשעושים await ל-promise, הפונקציה נעצרת, ושאר הפונקציה מתוזמן כ-microtask שירוץ ברגע שה-promise מסתיים. אין כאן קסם: זה פשוט .then מתחת למכסה המנוע.

פלט: A, C, B. ה-await מחזיר את השליטה לקורא. console.log("C") רץ על המחסנית הנוכחית. אחר כך תור ה-microtasks מתרוקן ושאר demo ממשיך, ומדפיס B.

זכרו את זה כשאתם קוראים קוד אסינכרוני. await לא חוסם, הוא מוותר על התור.

דוגמה מלאה: לסדר את הכול

נחבר את כל החלקים:

הסדר:

  1. 1: sync: רץ על המחסנית.
  2. 6: sync: עדיין על המחסנית.
  3. המחסנית מתרוקנת. תור ה-microtasks מתרוקן: 3: promise, 5: microtask, ואז 4: nested microtask (תוזמן בזמן הריקון, ועדיין נלקח באותו סבב).
  4. ה-task הבא רץ: 2: timeout.

פלט סופי: 1, 6, 3, 5, 4, 2. אם אתם מצליחים לעקוב אחרי הדוגמה הזו, אתם מבינים את ה-event loop.

ל-Node יש יותר שלבים

ה-event loop של Node הוא הרחבה של המודל של הדפדפן. יש לו שלבים נפרדים: timers, pending I/O callbacks, poll, check, close, ו-microtasks מתרוקנים בין כל שלב לשלב. setImmediate רץ בשלב ה-check, ו-process.nextTick רץ לפני microtasks רגילים (יש לו תור משלו עם עדיפות גבוהה עוד יותר).

לא צריך לשנן את תרשים השלבים ביום הראשון. המסקנה העיקרית זהה לזו שבדפדפן: קוד סינכרוני רץ עד הסוף, אחר כך microtasks מתרוקנים, ואז הלולאה לוקחת את ה-callback הבא שבתור.

למה זה חשוב

ברגע שהמודל נקלט, הרבה קוד אסינכרוני מפסיק להיות מסתורי:

  • לולאת for ארוכה מקפיאה את ה-UI, כי ה-event loop לא מקבל תור.
  • setTimeout(fn, 0) היא דרך לדחות עבודה עד אחרי שה-task הנוכחי וה-microtasks מסתיימים.
  • callback של .then שרץ "מיד" אחרי promise שכבר מומש עדיין מחכה שהקוד הסינכרוני הנוכחי יסתיים.
  • await בתוך לולאה הופך את העבודה לסדרתית, כי כל איטרציה מוותרת על התור לטובת תור ה-microtasks לפני שהיא ממשיכה.

דיבוג של קוד אסינכרוני הוא בעיקר לשאול "מה נמצא עכשיו על המחסנית, ומה מחכה בתור?". ה-event loop הוא התשובה.

הבא בתור: Callbacks

לפני promises ו-async/await, הכלי היחיד של JavaScript לעבודה אסינכרונית היה ה-callback: פונקציה שמעבירים ל-API כדי שיקרא לה מאוחר יותר. Callbacks עדיין נמצאים בכל מקום (event listeners, ה-APIs הבסיסיים של Node), והבנה שלהם היא הבסיס לכל השאר בפרק הזה.

שאלות נפוצות

מה זה event loop ב-JavaScript?

זה המנגנון שמאפשר ל-JavaScript, שרצה על thread יחיד, להריץ עבודה אסינכרונית בלי לחסום. הלולאה מסתכלת על מחסנית הקריאות: כשהיא ריקה, היא שולפת את ה-callback הבא שממתין בתור ומריצה אותו. טיימרים, קלט/פלט והמשכים של promises מגיעים כולם לתורים שה-event loop מרוקן פריט אחד בכל פעם.

למה JavaScript רצה על thread יחיד?

המפרט של השפה מגדיר מחסנית קריאות אחת לכל realm, ולכן הקוד שלכם רץ על thread יחיד. המקביליות מגיעה מהסביבה המארחת (דפדפן או Node), שמעבירה עבודה ל-APIs ברקע, כמו טיימרים, רשת וקלט/פלט של קבצים, ומכניסה callback לתור כשהם מסיימים. לעולם לא תראו שתי חתיכות JS רצות באותו זמן באותו הקשר.

מה ההבדל בין microtasks ל-macrotasks?

Microtasks מגיעים מ-promises (.then, await) ומ-queueMicrotask. Macrotasks מגיעים מ-setTimeout, setInterval, קלט/פלט ואירועי UI. אחרי שכל macrotask מסתיים, ה-event loop מרוקן את כל תור ה-microtasks לפני שהוא מריץ את ה-macrotask הבא. לכן Promise.resolve().then(...) תמיד רץ לפני setTimeout(..., 0) שתוזמן באותו רגע.

למה setTimeout עם 0ms לא רץ מיד?

setTimeout(fn, 0) לא אומר 'תריץ עכשיו', אלא 'תכניס את fn לתור כ-macrotask, לכל המוקדם אחרי 0ms'. הקוד הסינכרוני הנוכחי צריך להסתיים, תור ה-microtasks צריך להתרוקן, ורק אז ה-event loop לוקח את ה-callback של הטיימר. כלומר 0 הוא רצפה, לא הבטחה.

איור של שפות התכנות ב-Coddy

ללמוד תכנות עם Coddy

להתחיל