ה-event loop של Node.js (לולאת האירועים) הוא הלולאה שמחליטה איזה callback רץ הבא, אחרי שהקוד הסינכרוני שלכם הסתיים. היא עוברת בין שלבים במחזור קבוע: timers (setTimeout, setInterval), pending callbacks, poll (I/O כמו קריאות קבצים ונתוני רשת), check (setImmediate) ו-close callbacks. אחרי כל callback, Node מרוקן קודם את תור ה-process.nextTick ואחר כך את תור ה-promises, כך שהם תמיד רצים לפני שהלולאה ממשיכה.
הוא מדפיס sync, nextTick, promise, setTimeout, הפוך מהסדר שבו הם נכתבו. קוד סינכרוני תמיד מסתיים ראשון, אחר כך Node מרוקן את תור ה-nextTick, אחר כך את תור ה-promises, ורק אז הלולאה מתחילה ומגיעה לשלב ה-timers.
השלבים של ה-event loop
הלולאה של Node מגיעה מ-libuv, ספריית ה-C ש-Node משתמש בה ל-I/O. לפני הסיבוב הראשון היא מריצה את ה-timers שהזמן שלהם כבר הגיע, ואחר כך כל סיבוב של הלולאה מריץ את השלבים האלה לפי הסדר:
- Pending callbacks: כמה callbacks של המערכת שנדחו מהסיבוב הקודם, כמו חלק משגיאות הרשת.
- Poll: אירועי I/O חדשים וה-callbacks שלהם (קריאת קובץ הסתיימה, נתונים הגיעו ל-socket). כששום דבר אחר לא מתוזמן, הלולאה מחכה כאן.
- Check: ה-callbacks של
setImmediate. - Close callbacks: אירועי
'close', כמו socket שהושמד. - Timers: ה-callbacks של
setTimeoutו-setIntervalשהגיע הזמן שלהם.
מדריכים רבים מציגים את ה-timers ראשונים בכל סיבוב, וכך Node עבד לפני גרסה 20 (libuv 1.45). ה-callbacks רצים באותו סדר בשני המקרים: אחרי ה-timers של סיבוב אחד מגיע ה-poll של הסיבוב הבא.
שני תורים יושבים מחוץ לשלבים: תור ה-nextTick ותור ה-microtasks (ה-callbacks של promises ו-queueMicrotask). Node מרוקן את שניהם אחרי כל callback בודד, בכל שלב, קודם את ה-nextTick. הלולאה ממשיכה להסתובב כל עוד יש למה לחכות (טיימר, קובץ או socket פתוחים, שרת), והתהליך יוצא כשלא נשאר כלום.
זו הלולאה של Node עצמו. הדפדפן חולק את הרעיון של tasks ו-microtasks, אבל אין בו שלבים, אין setImmediate ואין process.nextTick; הדף על ה-event loop של JavaScript מכסה את התמונה ברמת השפה.
process.nextTick מול promises
שניהם רצים לפני שהלולאה ממשיכה, אבל הם לא מתערבבים. Node מריץ את כל תור ה-nextTick, כולל ticks שנוספו בזמן שהוא רץ, ורק אז את תור ה-promises. נסו לנחש את זה:
הסדר הוא tick 1, tick 2, tick from tick, promise 1, promise 2. ה-tick שתוזמן בתוך tick מצטרף לאותו תור ועדיין רץ לפני כל promise, למרות שה-promise נוצר קודם.
ברמה העליונה של ES module (קובץ שמשתמש ב-import) השניים מתחלפים: הקוד של המודול עצמו רץ בתוך promise job, ולכן ה-callbacks של ה-promises שלו מסתיימים לפני ש-Node מגיע לתור ה-nextTick. בתוך callbacks, הכלל של nextTick-קודם תקף בשתי מערכות המודולים.
setImmediate מול setTimeout(fn, 0)
setTimeout(fn, 0) בפועל אומר "אחרי מילישנייה אחת לפחות, בשלב ה-timers". setImmediate(fn) אומר "בשלב ה-check הבא". מי מהם רץ ראשון תלוי במקום שבו קוראים להם.
בתוך callback של I/O הסדר קבוע. ה-callback רץ בשלב ה-poll, שלב ה-check מגיע מיד אחריו, ושלב ה-timers מגיע רק אחר כך:
הוא מדפיס nextTick, setImmediate, setTimeout, בכל פעם. זה הופך את setImmediate לבחירה הנכונה עבור "הרץ את זה אחרי עבודת ה-I/O הנוכחית".
במודול הראשי, מחוץ לכל callback, הסדר בין השניים לא קבוע:
// Order depends on how fast the process started
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
אם עברה לפחות מילישנייה אחת כשהלולאה מתחילה, הטיימר כבר הגיע לזמנו ומודפס ראשון; אחרת setImmediate מנצח. אל תכתבו קוד שתלוי באחת התוצאות.
| מתי רץ | שימוש | |
|---|---|---|
process.nextTick(fn) | מיד אחרי הפעולה הנוכחית, לפני promises | פליטת אירוע או שגיאה אחרי שהקוד הקורא חיבר listeners |
Promise.then, queueMicrotask | אחרי שתור ה-nextTick ריק | קוד אסינכרוני רגיל |
setImmediate(fn) | שלב ה-check, אחרי ה-callbacks של I/O | לתת ללולאה סיבוב בין חלקי עבודה |
setTimeout(fn, 0) | שלב ה-timers, לפחות מילישנייה אחת אחר כך | השהיות, כשהרגע המדויק לא חשוב |
חסימה של ה-event loop
מכיוון שיש thread אחד של JavaScript, callback שרץ הרבה זמן עוצר את כל השאר: טיימרים, ה-callbacks של I/O, ובשרת, כל בקשה אחרת. ההשהיה של טיימר היא מינימום, לא הבטחה.
loop finished מודפס ראשון, ואז true: הטיימר היה אמור לרוץ אחרי 10 מילישניות, אבל לא יכול היה לרוץ עד שהלולאה העסוקה החזירה את ה-thread. בשרת, אותה לולאה בתוך route handler מקפיאה כל בקשה אחרת לחצי שנייה.
הפתרון לעבודה ארוכה הוא לפצל אותה ולתת ללולאה סיבוב בין החלקים עם setImmediate:
הטיימר נכנס אחרי חלק 1. החליפו את setImmediate(() => work(part + 1)) ב-work(part + 1) והריצו שוב: כל שלושת החלקים רצים ברצף אחד ו-timer מודפס אחרון.
לעבודה שכבדה מתחילתה ועד סופה, כמו עיבוד תמונות, פיצול לא מספיק: העבירו אותה מה-thread הראשי עם worker_threads.
טעויות נפוצות
קריאה רקורסיבית ל-process.nextTick. callback של nextTick שממשיך לתזמן עוד nextTick לא נותן ללולאה להמשיך אף פעם, וטיימרים ו-I/O מורעבים. השתמשו ב-setImmediate לעבודה חוזרת.
שימוש ב-APIs סינכרוניים בשרת. fs.readFileSync, crypto.pbkdf2Sync וקריאות JSON.parse גדולות חוסמים את הלולאה לכל המשתמשים. השתמשו בגרסאות האסינכרוניות בתוך request handlers.
לצפות ש-setTimeout(fn, 0) ירוץ מיד. הוא רץ אחרי כל הקוד הסינכרוני, כל nextTick וכל callback של promise, ורק כשהלולאה מגיעה לשלב ה-timers.
שאלות נפוצות
האם Node.js הוא single-threaded?
ה-JavaScript שלכם רץ על thread אחד, כך ששני callbacks אף פעם לא רצים באותו זמן. Node עצמו משתמש ביותר threads: libuv מחזיק pool (ארבעה threads כברירת מחדל) לעבודה עם מערכת הקבצים, לחיפושי DNS ולחלק מפעולות ההצפנה, ומחזיר את התוצאות ללולאה.
מתי תוכנית Node.js יוצאת?
כשללולאת האירועים לא נשאר למה לחכות: אין טיימרים, אין I/O שממתין, אין שרתים או sockets פתוחים. setInterval שרץ או שרת שמאזין משאירים את התהליך חי עד שמנקים או סוגרים אותם.
האם ה-event loop של Node.js זהה לזה של הדפדפן?
הרעיון זהה, thread אחד שמריץ callbacks מתור, אבל הפרטים שונים. בדפדפן אין setImmediate או process.nextTick והוא משלב רינדור בין משימות, ואילו Node מריץ את השלבים של libuv. הדף על ה-event loop של JavaScript מכסה את החלק המשותף ברמת השפה.
איך יודעים אם ה-event loop חסום?
מודדים באיזה איחור הוא רץ: monitorEventLoopDelay() מתוך perf_hooks רושם את העיכוב, וממוצע של עשרות מילישניות אומר שיש callback שמחזיק את ה-thread יותר מדי זמן. הריצו את Node עם --cpu-prof כדי לראות איזו פונקציה זו.