2013-06-08

אבני הבניין של האינטרנט: סביבת-הריצה של ג'אווהסקריפט

שפת ג׳אווהסקריפט תוכננה כשפה פשוטה למדי, שפה מפורשת המורצת ע״י מפרשן (interpreter) [מה ההבדל בין מפרשן למהדר]. לא עוד.


המנועים המודרניים של ג׳אווהסקריפט מבצעים קומפילציית JIT לקוד מכונה יעיל למדי - ועל כן הם מתהדרים בשם "מנועים". הביצועים, השתפרו בסדר גודל בכמה השנים האחרונות. הגרסה הבאה של ג׳אווהסקריפט (שם קוד Harmony) כוללת אלמנטים רבים חדשים בשפה שיעזרו לתמוך במערכות קוד גדולות (מחלקות, מודולים, ועוד).

התקדמות מנועי הג'אווהסקריפט בשנים האחרונות. מקור (2011).

בכל זאת, העקרונות הבסיסיים של סביבת הריצה של ג'אווהסקריפט בתוך הדפדפן לא השתנו הרבה - עליהם נדבר בפוסט זה.


פוסט זה שייך לסדרה אבני הבניין של האינטרנט.



מפת התמצאות


הנה מנועי הג'אווהסקריפט של כמה מהדפדפנים הנפוצים:

עץ המשפחה של מנועי הג'אווהסקריפט. מקור.

JIT Compilation (קימפול קוד לג'אווהסקריפט לקוד מכונה לפני ההרצה) היא כבר תכונה סטנדרטית של מנועי הג'אווהסקריפט. Crankshaft (בעברית: גל ארכובה) ו TraceMonkey עד OdinMonkey הם בעצם JIT compilers (בהפשטה) של מנועי הג'אווהסקריפט V8 ו SpiderMonkey בהתאמה. הקומפיילר הוא רק חלק מסוים מהעבודה שמנוע הג'אווהסקריפט מבצע.

כל המנועים המודרניים (כרום +13, פיירפוקס +4, ספארי +5.1, אינטרנט אקספלורר +9) תומכים* ב ECMAScript 5.1 - שהיא הגרסה האחרונה של שפת ג'אווהסקריפט. בעת פוסט זה התמיכה ב ECMAScript 6 (הגרסה הבאה) היא חלקית למדי.

* חריגות קלות: IE9 שלא תומך ב "use strict" ו Safari שעדיין לא תומך ב prototype.bind




טעינת הסקריפטים וסדר ההרצה

הרצת קוד ג'אווהסקריפט כוללת 3 שלבים מרכזיים: פענוח (parsing), אופטימיזציה ורישום הפונקציות (function resolution) והרצת הקוד (execution).



שלב 1: שלב הפענוח
בשלב זה מפרשים את "עץ התוכנית" של קוד הג'אווהסקריפט, מבצעים תיקונים קלים (הוספת נקודה פסיק בסוף משפט, דבר שעלול לעתים להסתיים בקוד שגוי) והמרה לשפת ביניים יעילה יותר (JIT compilation).

אם המפענח נתקל בשגיאה בקוד (למשל: פונקציה שלא נסגרה) הוא מדלג על קטע הקוד וממשיך לבלוק הבא. מדיניות סלחנית זו נקבעה עוד בימים בהם עוד היה מקובל לכתוב "בלוקים" של ג'אווהסקריפט בתוך ה HTML, כך שדילוג על בלוק יחיד היה יכול לאפשר הרצת דף בצורה נסבלת. תקלה בקובץ ג'אווהסקריפט תגרום לדילוג על כל הקובץ - מה שקרוב לוודאי וישבית את כל התוכנית.


שלב 2: שלב האופטימיזציה ורישום הפונקציות
בשלב זה עוברים על קוד הג'אווהסקריפט (בצורת "bytecode") ומבצעים בו שיפורים:
  • inline של פונקציות קצרות לתוך הקוד שקורא להן
  • hoisting - העברת ההגדרה של משתנים ופונקציות לתחילת ה scope בהן הן הוגדרו. מנגנון זה, כך שמעתי, החל כאופטימיזציה ללולאות for אך הפך לחלק מהשפה, וחלק שגם יכול לגרום לבעיות (הסבר מיד).
  • inline caching - הוספת שכבה של caching מעל אובייקטי ג'אווהסקריפט, אחת מהאופטימיזציות היעילות בשנים האחרונות. מקור1 מקור2.
  • inline של קבועים (משתנים שבכל התוכנית אין להם השמה).
  • ביטול קוד שאי אפשר להגיע אליו ("dead-code elimination").
הנה דוגמה המסבירה את מנגנון ה hoisting. אפשר למצוא עליו הסבר נוסף בפוסט מבוא מואץ לג'אווהסקריפט (חפשו: hoisting).


קוד שנראה כך:


בעצם מתורגם, לאחר ה hoisting, לכך:


שלב 3: שלב ההרצה
הרצת הקוד, כפי שהיינו מצפים שיקרה.



ג'אווהסקריפט והדפדפן

לכל טאב או חלון בדפדפן מוגדרת סביבת ג'אווהסקריפט עצמאית משלה. בסביבה זו יש אובייקט בשם window המהווה את ה context הגלובלי. אם אגדיר var x גלובלי בדף אחד הוא יהיה מופע שונה מ var x גלובלי בדף אחר (בהגדרת "var x", בעצם הגדרתי member חדש בשם x על האובייקט window). באופן דומה, פקודות שניתן לייחס בטעות לשפת JavaScript כגון setTimeout או alert הן בעצם פקודות על האובייקט window (הערך ב mdn), כלומר window.setTimeout או window.alert.

את האובייקטים שמספקת סביבת הדפדפן נוטים לחלק לשני אזורים:


BOM - אובייקטים הקשורים ל Shell של הדפדפן.
DOM - אובייקטים הקשורים לדף ה HTML המרונדר.

ה DOM הוא מורכב למדי ומעבר ל scope של פוסט זה (הנה לינק לספר טוב אונליין).
להלן סקירה מהירה של האלמנטים המרכזיים ב BOM:
  • window - חלון[א] או טאב של הדפדפן. מלבד תפקידו כ context הגלובלי ואובייקט האב, יש על אובייקט ה window מתודות למדידת גודל חלון הדפדפן, מצב ה Scroll ומספר אירועים שקשורים לשינוי גודל החלון או ה scrolling.
  • location - מתאר את ה URL הנוכחי של החלון. ניתן לקרוא אלמנטים, לשנות או להאזין לשינויים. שם מדויק יותר היה פשוט "url".
  • history - היסטריית שינויי ה location בדפדפן. ניתן להפעיל פקודות back, forward לדחוף או להסיר אלמנטים מההיסטוריה.
  • navigator - פרטים על הדפדפן: user agent string, תמיכה בג'אווה או ב cookies, סוג וגרסת הדפדפן וכו'. קרוב לוודאי שמקור השם הוא בדפדפן Netscape) Navigator) ומאז הוא איתנו. שם מדויק יותר היה פשוט "browserInfo".
  • screen - פרטים על התצוגה המסופקת לדפדפן: רזולוציה, עומק צבע וכו'.
  • frames - רשימת ה frames או ה iframes שבתוך החלון.
תגית <iframe>, משלבת בתוך המסמך מעין "מופע חדש של דפדפן" עם אובייקט windows משלו, document משלו, היסטוריה משלו ועוד (הערך ב mdn). השימוש העיקרי של ה iframe הוא להציג בצורה מבודדת, בתוך חלון, תוכן שתוכנן לרוץ בדף עצמאי (לא מתנהג בצורה "חברותית") או שמגיע מ domain אחר. מבחינות מסוימות iframe היא גישה מיושנת ומפתחי ווב רבים רואים אותה כ deprecated, מצד שני ליכולותיה עדיין אין תחליף - ולכן עדיין משתמשים בה.

הערה: השם iframe הוא קיצור של internal frame. ישנה גם תגית בשם frmae שהיא כמעט זהה, אך מחויבת להיות בתוך תגית frameset. כל מה שאומר על iframe נכון בעיקרון גם ל frame.

כל iframe הוא בעצם context חדש בדפדפן, כלומר אובייקט window, מרחב גלובלי חדש, DOMTree חדש וכו'. הוא כמעט כמו "חלון חדש בתוך חלון קיים" בדפדפן, מלבד 2 הבדלים:
  • ניתן לגשת מ iframe אל משתנים / פונקציות ב top frame (כלומר ה context המקורי של הדף). כל זאת תחת מגבלות ה Single Origin Policy - נושא אליו נצלול בפוסט אחר בסדרה.
  • עבור חלון חדש יהיה thread חדש בדפדפן, בעוד iframes באותו חלון משתפים את אותו ה thread. הצורך בשיתוף thread נובע מהדרך בה מתרחשת מקביליות בדפדפן - נושא שנדון בו מיד.



מודל המקביליות בדפדפן: Thread יחיד

לכל חלון פיסי בדפדפן יש thread יחיד. גישה זו היא שונה למדי ממודל ה threads של Java או NET.
היתרונות של גישה זו הם:
  1. יעילות גבוהה מאוד בקוד עם הרבה פעולות IO. כדאי להזכיר: Threads הוא מודל שעיקרו להקל על המפתח, ואינו הדרך היעילה להגיע לביצועים גבוהים [זהירות, פוסט].
  2. פשטות הנובעת מכך שאין צורך לדאוג לסנכרון. Deadlock או Racing Condition הם פשוט לא אפשריים כאשר ישנו thread בודד.
  3. מודל שהתאים מאוד ל"דפי אינטרנט דינמיים" - כלומר אתרים עם אינטראקציה ולא בהכרח אפליקציות.

לגישה זו יש גם כמה חסרונות משמעותיים:
  1. העובדה שב javaScript כל פעולות ה IO הן אסינכרוניות בהכרח [ב] יכולה להקשות מאוד על המעקב אחר flow בקוד. Promises [זהירות, פוסט] היא אחת הדרכים הנפוצות להתמודד עם קושי זה.
  2. אי-יכולת לנצל יותר מ core אחד במעבד.
  3. קוד שכולו CPU (כגון חישוב מתמטי מורכב) יתקע ממשק המשתמש ויהפוך את האפליקציה ללא רספונסיבית.

החסרונות הם אכן משמעותיים ובהמשך נראה את דרכי התמודדות איתם. סה"כ גישת "ה thread הבודד" זלגה כיום לצד-השרת (node, nginx וכד') משיקולי יעילות.




ה Event Loop

אני מניח שאתם מגיעים עם רקע במדעי המחשב (Java, ++C או NET.) והרעיונות של מחסנית (stack) וערימה (heap) אינם זרים לכם.


בג'אווהסקריפט המודל דומה למודל הערימה והמחסניות של השפות הנ"ל, אך יש בו כמה שינויים:
  • ישנו stack יחיד - מכיוון שיש thread יחיד.
  • ישנו Queue - עליו נדבר מייד.
  • האובייקטים ב Heap הם בעיקר Closures (אם כי לא רק) - כך שהתלות בחלק הפומבי שלהם היא מה שמונע מהחלק הפנימי להתנקות ע"י ה garbage collector.

ה thread רץ במין לולאה אינסופית שנראית בערך כך:


מה ממלא את ה Queue?
  • פעולות IO שנסתיימו, כגון ajax.$ (שזו בעצם עטיפה יפה לאובייקט ה XmlHttpRequest).
  • events של הדפדפן כגון DOMElement.onClick או window.resize.
  • פעולות setInterval ו setTimeout. למי שלא מכיר, פעולות אלו מבקשות להפעיל פונקציה נתונה בעוד זמן מסויים (setTimeout) או כל פרק זמן קבוע (setInterval).

כל הפעולות הנ"ל לא יופעלו לעולם ע"י thread אחר (כאשר מדובר באותו החלון). הן יופעלו כאשר ה thread הראשי ביצע את כל הקוד שלפניו, רוקן את המחסנית מקוד להרצה - ורק אז הוא זמין לטיפול ב"בקשות אחרות". אם נעמיס על ה thread הראשי פעולות חישוב כבדות, כל האירועים שממתינים ב Queue "ייתקעו" (ואיתם תתקע האפליקציה) עד אשר ה thread הראשי יתפנה אליהם.

הנה דוגמת קוד שתעזור להדגים את התנהגות ה event loop:


והנה תוצאת ההרצה:



חדי העין ישימו לב ש "work for later" יצא 3 ולא מה שהיינו מצפים: 0 עד 2. הסיבה לכך היא שהערך של i מוערך בעת הרצת הקוד (הקוד נקשר ל global closure) ולא בעת הקריאה ל setTimeout, כפי שאולי יכול להשתמע.

הפתרון הוא כמובן להעביר את i כפרמטר לפונקציה בעת הקריאה ל setTimeout:


בפעולת setTimout, פרמטרים נוספים לאחר הזמן להמתנה (במקרה שלנו: 0) פשוט יועברו כפרמטרים לפונקציה הנרשמת. דפדפני אינטרנט אקספלורר הקודמים לגרסה 10 לא תומכים בהתנהגות זו.



תורת היחסות של סביבת-הריצה של ג'אווהסקריפט

שאלת טיזר: כמה intervals של 100ms קיימים בשנייה אחת?
תשובה מתמטית: עשר.
תשובה בסביבה-הריצה של ג'אווהסקריפט: ניתן לקוות ל 9, אבל לפעמים גם רק 2.

הא?! נסביר מייד.


מה ההבדל בין 2 צורות הכתיבה הבאות?


דעה רווחת היא שההבדל הוא שאופציה א' תפעל כל INTERVAL קבוע, בעוד אופציה ב' תפעל כל INTERVAL + הזמן שלוקח לבצע את doSomeWork.

תיאור זה הוא קרוב למציאות - אך איננו נכון. נאמר שקבענו setTimeout להחליף אייקון על המסך בעוד 100ms. ברגע שנזרק האירוע (timer) - מי ייקח את הקוד ויבצע אותו? אם ה main thread שקוע בתוך קוד שבמחסנית - אין מי שיטפל בבקשה. כל מה שיקרה בעקבות ה timer הוא רישום הפונקציה שביקשנו להריץ ל Queue. היא תטופל רק ברגע שה main thread יתפנה (וכל האלמנטים הקודמים ב Queue יטופלו).

ההבדל המדויק, אם כן, בין setInterval ("אופציה א'") ל setTimeout ("אופציה ב'") היא שבאופציה א' יבצוע רישום של הפעלת הפונקציה doSomeWork ל Queue כל INTERVAL. בכדי להגן על ה Queue מפני זליגה (Queue Overflow), הפעלת הפונקציה תרשם רק אם היא איננה רשומה כבר ב Queue - ז"א לא יהיה יותר מעותק אחד שלה ב Queue.
באופציה ב' יהיה רישום ל Queue ברגע שאנחנו קראנו לפקודת setTimeout - וכאן אין מנגנון הגנה מפני רישום חוזר (כי פחות סביר שהוא יקרה ללא שהתכוונו לכך). מצד שני ההפעלות לא ינסו להתרחש בפרקי זמן קבועים.

שימו לב שלמנגנון ה timers של הדפדפן ישנם חוסרי-דיוק משלו הנובעים מחלוקת העבודה במערכת ההפעלה וה threads בתוך הדפדפן. כדאי להניח על שגיאות של פלוס/מינוס 5ms בהפעלת ה timers.

חזרה לטיזר בתחילת הפסקה: כיצד אם כן ייתכן ש setInterval של 100ms יתרחש רק פעמיים בשנייה?

הנה דוגמת קוד שגורמת לכך:


כמה הערות על הקוד:
  • ()performance.now היא דרך מדויקת ואמינה יותר מ ()Date.now - עבור מדידת ביצועים.
  • באופציה ב' תנאי היציאה מתרחש לאחר 9 פרקי זמן, מכיוון שפעולת הרישום מתרחשת כ INTERVAL אחד לפני שהקוד ירוץ בפועל.

העבודה שנעשית ב doSomeWork נראית אמנם דיי אגרסיבית, אבל יש גם תנאים מקלים:
  • הרצתי את הבדיקות על about:blank - כלומר דף ללא StyleTree ו DOM מינימלי.
  • הרצתי את הבדיקות על דפדפן כרום גרסה 27, ועל מעבד i5 שולחני שרץ במהירות 4Ghz - הקצה הגבוה של החומרה הסבירה שעליה תרוץ אפליציית ווב בימנו.

סה"כ סביר אפליקצית ווב מורכבת תגיע לרגעים שהיא תבצע עבודה רבה באותה המידה (במיוחד לפני ביצוע אופטימיזציות בקוד!)


הנה התוצאות:



לא ניקיתי את ה DOM בין ההרצות כך שלהרצה מאוחרת יותר - יש עבודה קשה יותר בכל הרצה של ()doSomeWork.

הרצה ראשונה
כבר כאן אנו רואים שמנגנון ה setInterval (יוצר ה"טיק") מזייף ואפילו מדלג על 3 טיקים (5, 7 ו 9).

הרצה שלישית
הנה 2 טיקים בלבד בשנייה. מי אמר שאין עוד אירועים שמתרחשים? (כמו "טוק")

קצת יותר ברצינות: זמן של 100ms הוא "זמן דפדפן" ארוך, ואם הקוד שלכם יעיל - אינכם אמורים להגיע ל2 טיקים של 100ms בשנייה. מצד שני, ישנם רגעים שבהם הדפדפן עובד קשה יותר - ועליכם להניח שטיקים מסוימים "יאבדו", או לפחות יתבצעו באיחור.


2 לקחים מעשיים

טריק ידוע בכדי "לא לתקוע" את הדפדפן עם פעולות חישוב יקרות הוא לעצור אותן בנקודות ציון מסוימות ו"לדחוף" את המשך הפעולה ל Queue בעזרת (setTimeout(calcFunction, 0. פעולת ההמשך ("calcFunction") תרשם לסוף ה Queue ותאפשר לאירועים אחרים להתרחש. ההבדל יכול להיות דרמטי בין "דפדפן תקוע" (שהמשתמש מנסה לסגור) ל "דפדפן מגיב".

דרך (עקיפה) אחרת לשחרר עבודה מה Thread הראשי (כלומר: היחידי) היא לשלב יכולות שעובדות באופן מובנה על threads אחרים בדפדפן. דוגמה טובה לכך תהיה שימוש ב CSS Animations המבוצעים ע"י ה thread של מנוע הרינדור - וכך משחררים את ה Thread הראשי להריץ קוד ג'אווהסקריפט. מעבר לכך - כנראה שניצלתם בדרך זו core אחר של הCPU שעד כה היה בכלל מובטל!




Web Workers

לצורך התמודדות עם חישובים ארוכים, או בכדי לנצל בצורה מירבית מעבד בעל מספר ליבות, נבנה תקן ה Web Workers - תקן חדש יחסית המאפשר להאציל עבודה מוגדרת מה Thread הראשי על Threads אחרים. עבודה עם web workers היא מעט מסורבלת מכיוון שה threads השונים אינם משתפים זיכרון - עליהם לתקשר בעזרת הודעות בלבד (רעיון דומה ל Actors, אותם הזכרתי בעבר).

הצורך בהעברת ההודעות בין ה web workers ל main thread נובע ממודל ״ה Thread היחיד״ של הדפדפן: אם הייתה גישה ישירה לזיכרון משותף - אנו נחשפים לבעיות concurrency אפשריות. באופן דומה אילוץ זה הכתיב שאם frames באותו חלון יכולים לתקשר אחד עם השני - אסור להם לרוץ ב threads נפרדים והם חייבים לשתף thread.

ההודעות שעוברות בין web workers עוברות by-copy ולא by-reference מהסיבות הנ"ל. נראה שרוב הדפדפנים ממש מעבירים אותן כהודעות IPC שעוברות serialization / de-serialization בכל פעם.

הנה רשימה קצרה של מה ש Web worker יכול / לא יכול לעשות.

ל Web Worker מותר:
  • לשלוח הודעות ל Workers אחרים.
  • ליצור ולהרוג workers אחרים.
  • לגשת (קריאה-בלבד) לאובייקטי ה navigatior וה location של ה BOM.
  • לבצע קריאות ajax (ע"י שימוש ב XmlHttpRequest).
  • להשתמש ב timers של הדפדפן.
  • לטעון קבצי ג'אווהסקריפט נוספים.
  • לפתוח connections לשרת, בעזרת web sockets.
  • לגשת ל cache ו local storage.

ל Web Worker אסור:
  • לגשת ל DOM
  • לגשת לאובייקט ה window (מלבד 2 החריגות לעיל).



סיכום

זה היה פוסט ארוך לכתיבה!
כתיבת פוסט זה הזכירה לי את קורס "מערכות ההפעלה" באוניברסיטה, וכנראה לא סתם. מערכת ההפעלה הייתה סביבת הריצה (Runtime Environment) של שרתים ואפליקציות שולחניות (לפני ש virtual machines נכנסו באמצע ושינו חלקית את התמונה).
כיום, הדפדפן הופך להיות סביבת ריצה חשובה לחלק הולך וגדל של אפליקציות, ועל כן הכרת סביבת ריצה זו - חשובה לא פחות.

שיהיה בהצלחה!


-----

[א] חלון חדש של דפדפן יכול להיות עדיין שייך לאותו process של מערכת ההפעלה.

[ב] מקרה קצת יוצא דופן הוא modal dialogs בדפדפן כגון window.confirm או window.alert. זו תכונה של הדפדפן ולא של שפת ג'אווהסקריפט.


מקורות נוספים:
הרצאה ב Velocity 2011 על מנועי ג'אווהסקריפט
כמה טיפים מעניינים לקראת סוף המצגת.
http://velocityconf.com/velocity2011/public/schedule/detail/18087

הרצאה על פרטי המימוש של מנועי ג'אווהסקריפט, מכנס SenchaCon 2010
כניסה לפרטים טכניים יותר ממה שכיסיתי בפוסט זה.
http://vimeo.com/18783283

5 תגובות:

  1. אחלה פוסט! ממש מעניין וכתוב היטב.
    יצא לי לעבוד עם שפת סקריפט שנקראת TCL ושם יש פונקציה שנקראת update שמאפשרת למתכנת לעצור את ריצת התכנית העיקרית ולהמתין עד לביצוע כל הפקודות ב-event loop, כלומר ב-queue, צריך להוסיף משהו בסגנון ב-javascript.

    השבמחק
  2. תודה רבה פוסט מעולה מעולה .

    השבמחק
  3. אנונימי26/5/14 23:54

    אחלה מאמר..
    רק שתדע שהמינימום של setInterval זה 400ms..

    השבמחק