פרומיסים לוקחים זמן?
-
שאלה שמטרידה אותי מזמן, וכעת נהייתה לי משמעותית.
כידוע, JS היא סנכרונית, מה שאומר ש2 פעולות יכולות להתבצע בו זמנית, הקוד שמריץ את הפעולה השניה יופעל בלי להמתין לסיום הפעולה הקודמת.אמנם, זה נכון לגבי סוגים מסוימים של פעולות, כאלו שדורשות תקשורת עם שרת או IO בצורה כלשהי. לוגיקה רגילה רצה שורה אחרי שורה כמו קוד רגיל.
הפרומיסים (עם הawait) החדש גורמים גם לקוד סינכרוני להתנהג בצורה אסינכרונית, למשל להמתין לתשובת השרת לפני המעבר להמשך הקוד. זו כמובן התנהגות שמאיטה את ריצת הקוד, ולפעמים מאיטה משמעותית.ומכאן לשאלה:
מה קורה עם אני משתמש בפרומיס, אך מכניס בתוכו קוד שממילא היה פועל באופן אסינכרוני. האם יש לזה משמעות מבחינת זמן ריצה של הקוד?
למשל, האם בקוד הבא, הראשון יהיה יעיל פחות מבחינה כלשהי מאשר השני:async function doSomthimg() { let a = 0; for (let i = 0; i < 100; i++) a++; return a; } router.get("/", async (req, res, next) => { let result = await doSomthimg(); res.send(result); });
השתמשתי בדוגמה הפשוטה ביותר, שכן הוספת
async
לפוקנציה בעצם הופכת אותה לפרומיס.לעומת
function doSomthimg() { let a = 0; for (let i = 0; i < 100; i++) a++; return a; } router.get("/", async (req, res, next) => { let result = doSomthimg(); res.send(result); });
(את ה
async
בשורה 7 בדוגמה האחרונה השארתי בכונה).האם בדוגמה השניה הראוטר יחזיר את תוצאת הפונקציה יותר מהר, או יחסוך בעיה אחרת?
הנפקא מינא הבסיסית פשוטה מאוד. אם יש לי קוד, שבמצבים מסוימים נדרש להמתין לפרומיסים ובמצבים אחרים לא, האם יש ענין להתאמץ לחלק את הקוד כך שלא יפגוש async/await מיותרים?
אני לא מדבר על מקרה שזה זניח, אלא מקרה שזה משמעותי כמותית הן מבחינת כמות הקריאות והן מבחינת מבנה הקוד. -
@davidnead
סנכרוני = מסונכרן, דבר קורה מתי שציפו שיקרה (למשל C# היא שפה סינכרונית לחלוטין). אסינכרוני = כמו אנאלפבית כלומר לא מסונכרן. אין תזמון מסודר מה יקרה מתי. JS היא אסינכרונית, כלומר יש פעולות שאין המתנה לסיום שלהם וממילא הקוד שאחריהם יכול להיות "לא בעניינים".
כפי שאמרת, למעט בפעולות מסויימות מאוד JS נוהגת בדיוק כמו הדרך הראשונה, כלומר הקוד ממתין ומבצע לפי סדר.
await משנה את זה למראית עין בלבד. הוא מרשה לכתוב קוד יפה, בעוד שבאמת מה שקורה הוא שכל השורות שאחריו (עד סוף הפונקציה) נמחקות מהקוד, ומוכנסות בתוך הקאלבק של הפעולה אליו הוא ממתין. כלומר:var oneValueProccess = Promise.resolve(1); oneValueProccess.then(x => console.log(x));
נראה יפה יותר ככה:
var x = await Promise.resolve(1); console.log(x);
אבל זה בדיוק אותו קוד! מבחינת הרצת התוכנית.
כיון שזה בדיוק אותו דבר, כדאי להתמקד באמת שזה הקוד הראשון. כפי שאמרת יש "תחושה" לאיטיות, הנה המחשה:var oneValueProccess = Promise.resolve(1); oneValueProccess.then(x => console.log(x)); console.log('fast code win!!');
הfast code win!! יודפס קודם, למרות שאנחנו יודעים היטב שלא לקח שום זמן לפרומייז לארגן את הערך 1 אותו הוא קיבל על מגש של כסף. הסיבה לכך היא המנגנון של הevent loop, בקוד JavaScript המחשב מבצע שורה אחרי שורה עד לסיום כלל השורות, ורק אז נפנה לעבור על קאלבקים שנפתרו.
לכן תמיד קוד בקאלבק יקרה אחרי כל שורה שהייתה זמינה ללא קלאבק לפניו. זה לא איטיות, זה פשוט תזמון.
(בפרומייז גם הקוד שמחולל את התוצאה הוא עצמו קלאבק, אז הוא גם תמיד יורץ אחרי הקוד הזמין ללא קאלבקים לפניו).
מה קורה כשאנו רוצים שקוד אחרי await לא יחכה סתם לתוצאה? פשוט משמיטים את המילה await. פירוש המילה awite היא... להמתין. אם אין await, הקוד שבפונקציה יבוצע מיידית עוד לפני השורה הבאה, אבל הפרומייזים שנמצאים בה או חוזרים ממנה ייפתרו בבוא העת = המאוחר מבין: סיום הרצתם, סיום הרצת כל הקוד הזמין ללא קלאבק. -
@dovid אם אני מבין נכון, עולה מדבריך שפרומיס לעולם אינו איטי יותר מפונקציה רגילה, מפני שפרומיס אינו אלא אובייקט המריץ קולבק. ההבדל הוא בתזמון בלבד. ולפיכך, ככל שאני בכל מקרה רוצה שפעולות יתבצעו בסדר מסוים (כלומר, בתזמון מסוים) ואין לי רצון לאפשר סנכרוניות, אין שום מניעה שאקרא לפונקציה שמחזירה פרומיס, גם אם בתוכו הוא אינו עושה דבר הדורש "המתנה".
אלא שבמשפט שכתבת "המנגנון של הevent loop, בקוד JavaScript המחשב מבצע שורה אחרי שורה עד לסיום כלל השורות, ורק אז נפנה לעבור על קאלבקים שנפתרו" - כאן טמון הסוד. זה בעצם החלק שאני מתקשה להבין. הרי ככל שאין לי פרומיס, גם אם אני משתמש בקלבקים בצורה ידנית כלשהם, לעולם הם יגמרו לפני שהקוד ימשיך לשורה הבאה.
למשלfunction a(cb){ cb() } a(()=>console.log(1)) console.log('fast code win!!')
הפעם 1 יודפס לפני 'fast code win!!', למרות שהוא בתוך קולבק. היות שהוא לא כולל בתוכו שום פעולה שדורשת התנהגת סנכרונית, הקולבק מתבצע קודם.
כלומר לפרומיס יש התנהגות מיוחדת, זו לא סתם העברת כל הקוד אל תוך קולבק. הפרומיס זה אובייקט שכביכול מניח מראש שיש בתוכו פעולה סנכרונית, ולפיכך מריץ את הקלבק שלו ומתחיל להתבצע רק אחר סיום הקוד שאחריו.
אז בהחלט מובן שawait
הוא סוכר תחבירי לקלבק, זה אני מבין. אבל פרומיס הוא לכאורה התנהגות אמיתית שונה. ואם זה נכון - זה מחזיר את השאלה האם כדאי להשתמש בו כשלא צריכים אותו. בהנחה שהוא מתנהג בצורה שונה בכך שהוא מפעיל את הקולבק שבתוכו רק בסיום הevent loop, ואם כן הוא מצריך להמתין לו אם רוצים לשמור על התזמון, שזה אומר בעצם להעתיק את כל הקוד שאחריו על תוך הקולבק של הפרומיס - שמופעל אחרי שכל קוד אחר שמחוץ לפרומיס מתבצע.נפק"מ למעשה בשרת למשל. שמקבל הרבה קריאות במקביל. האם זה אומר שיש לו הרבה קוד שמחוץ לפרומיס, שעשוי להתבצע לפני שהפרומיס שלי יופעל, וכך כל שימוש מיותר בפרומיס מגביר את הסיכוי להאט את הקוד בכך ש"בטעות" ידחפו לו פעולות קודם, למרות שמבחינת הסדר הפנימי בתוך הפרומיס אני אכן מעונין בכל ההמתנות שכמו שאמרת הן כלשעצמן לא אמורות להאט שכן הן שומרות על תזמון שהיה קורה ממילא.
מקוה שהייתי מובן.
-
Microtasks/Macrotasks
Microtasks/Macrotasks הם משימות הזוכות ליחס שונה בJS, המשימות נכנסות לתור מיוחד וממתינות עד לאחר סיום ההרצה של המשימות בStack, רגע לפני הריצה הבאה של הEvent loop.
דוגמאות לפעולות מסוג Micro-Macro)Tasks):
- SetTimeOut
- SetInterval
- Promise
- Mutation Observer API
ועוד (ניתן לעיין בתרשים למטה)
לסיכום: Promise (או Async function) אינן איטיות יותר, אלא משתייכות לרשימה מצומצמת של משימות מסוג Microtasks בעלי סדר קדימה נמוך יותר שיתבצעו רק בסיום הלופ הנוכחי)
נ.ב. לקריאה על ההבדל בין Macrotasks וMicrotasks - ובקצרה: לפעולות מסוג Micro עדיפות גבוהה יותר.
-
@davidnead הצבעת באמת על כשל בלשון שלי, המילה קלאבק. זו מילה גרועה, כי כל אחד שרואה אתה מפרש אותה (ודי בצדק): "פיסת קוד שמועברת כארגומנט לפוקנציה אחרת על מנת שתריץ אותו לכשהיא תחליט".
אבל הכוונה שלי המילה קאלבק הייתה המשמעות המקורית של המילה - קריאה חזרה. כלומר "לכש"תגמור. זה משמעות שישנה רק בפעולות אסנכרוניות, כמו fetch וtimeout.
(אגב, בדפדפן אלו שני המקרים היחידים שהם באמת אסינכרוניים!).
לכן הדוגמה שלך עם הפוקנציה שמקבלת כפרמטר פונקציה אותו היא מריצה היא מקרה קלאסי של קוד שמורץ שורה אחרי שורה. הדוגמה עליה אני מדבר היא אך ורק בfetch וtimeout. המקרה הכי מובהק הוא:timeout(() => console.log(1), 0); //המתנה של 0 מילישניות כלומר מייד, בעצם לא ממש מייד...
זה נקרא קאלבק, כי זה אומר כשייגמר הזמן (אבל זה עכשיו!!) אזזזז תריץ את זה. אוקי אז כעת אני קודם עושה מה שמיידי! זה הראש של JS.
נפק"מ למעשה בשרת למשל. שמקבל הרבה קריאות במקביל. האם זה אומר שיש לו הרבה קוד שמחוץ לפרומיס, שעשוי להתבצע לפני שהפרומיס שלי יופעל, וכך כל שימוש מיותר בפרומיס מגביר את הסיכוי להאט את הקוד בכך ש"בטעות" ידחפו לו פעולות קודם, למרות שמבחינת הסדר הפנימי בתוך הפרומיס אני אכן מעונין בכל ההמתנות שכמו שאמרת הן כלשעצמן לא אמורות להאט שכן הן שומרות על תזמון שהיה קורה ממילא.
JavaScript היא שפה שלא בנויה להמתנות של קוד "ארוך", הכל נעשה במהירות הבזק. גם אם בשרת שלך יש המון המון קוד, הוא מבוצע כ"כ מהר, שלא צריכה להיות לך שום נפק"מ אם השורה תורץ אחריהם או באמצעיתם. כל קוד שכן לוקח זמן או תלוי בתוצאה כל שהיא חיצונית, חייב להיות כתוב בצורה טובה שמריצה אותו אסינכרונית וממילא הוא לא יעכב את השורה שלך. במילים אחרות, הסר דאגה מליבך כשאתה עושה פרומייז אבל אל תמתין אף פעם אם אין צורך (לא בגלל אטיות/מהירות, אלא כדי להיפטר מהלקוח שמחכה לתשובה כמה שיותר מהר, זה טוב לו ואפילו טוב לשרת).
-
@dovid אמר בפרומיסים לוקחים זמן?:
הסר דאגה מליבך כשאתה עושה פרומייז אבל אל תמתין אף פעם אם אין צורך (לא בגלל אטיות/מהירות, אלא כדי להיפטר מהלקוח שמחכה לתשובה כמה שיותר מהר, זה טוב לו ואפילו טוב לשרת).
חידה לי דבריך. לא להמתין - לא בגלל מהירות, אלא כדי לתת ללקוח תשובה כמה שיותר מהר??
שורה תחתונה אחרי דבריך ודברי @רפאל המנגנון של מאחורי הקלעים של הפרומיסים אכן יותר מובן, ובכל זאת אני לא בטוח שיש תשובה לשאלתי.
בהינתן פונקציה שמבצעת לוגיקה מסוימת, במצב מסוים מספיק לוגיקה טהורה, ובתנאי מסוים יש צורך להעזר במידע חיצוני - קראיה מקובץ לצורך הענין. הלוגיקה חייבת להעשות באותו סדר, ולכן צריך להמתין לקריאת הקובץ לפני שממשיכים, וממילא להמתין לתשובת כל הפונקציה שהופכת בעצמה לפרומיס.
יוצא שהפכתי את הפונקציה לפרומיס, למרות שלא בכל ריצה שלה אני באמת צריך אותה כפרומיס. אז כשצריך (נדרש נתון מקובץ) - צריך, גם אם יש לזה מחיר של זמן (בכל מקרה הIO עצמו לוקח זמן ויש לזה יותר משמעות), אבל כשלא צריך - השימוש בפרומיס מיותר.
אני יכול לעשות פקטוריזציה של הקוד באופן שיבדוק מראש האם צריך נתון חיצוני ולפי זה ידע האם לקרוא לפונקציה של פרומיס או לפונקציה רגילה. אבל במקרה שלי מדובר בפקטוריזציה מסובכת מאוד עם מחירים משמעותיים אחרים. ולכן אני מנסה להבין את ה"עלות" של השימוש המיותר בפרומיס. ובמחילה, טרם הצלחתי להבין. מחד אתם אומרים שלא אמורה להיות לפרומיס משמעות של זמן יותר ממה שהיה לוקח ממילא. מאידך אתם מסבירים שמשימה שבתוך פרומיס מקבלת עדיפות נמוכה, ואתה עצמך כותב לא להמתין אם אין צורך.נדמה לי שיש פה אי הבנה מסוימת על המקרה המדויק שאליו אני מתייחס, אז אנסה לכתוב פה קוד שלם שממחיש את השאלה/
בדוגמה הבאה, הלקוח שולח בקשה לשרת, שקורא לפונקציה שמחזירה נתונים ושולחת ללקוח.
במידה ובנתונים שהלקוח שלח נכללת ההרשאה שלו (המחשה בלבד.....) הלוגיקה מתבצעת בלי להעזר במקור חיצוני (היא מחזירה סטרינג מוכן) ולכן השימוש בפרומיס (הגדרת הפונקציה כasync
) מיותר, וכך גם ההמתנה לו.async function getData(userData) { if (userData.type == undefined) { userData.type = await readFile(`path/to/${userData.id}`); } return userData.type == "manager" ? "secreet data" : "free data"; } router.get("/", async (req, res, next) => { const userData = req.body.userData; let result = await getData(userData); res.send(result); });
לעומת זאת, יכולתי לכתוב את הקוד בצורה כזו:
async function getData(userData) { return userData.type == "manager" ? "secreet data" : "free data"; } async function getUnexpectedData(userData) { userData.type = await readFile(`path/to/${userData.id}`); return userType == "manager" ? "secreet data" : "free data"; } router.get("/", async (req, res, next) => { const userData = req.body.userData; let result; if (userData.type == undefined) { result = await getUnexpectedData(userData); } else { result = getData(userData); } res.send(result); });
בצורה האחרונה, איני משתמש בפרומיס אלא אם אני באמת צריך אותו. האם הצורה הזו יעילה יותר מהצורה הראשונה? האם הרצת הקוד (בהנחה שהuserData.type כן הגיע מהלקוח כך שהפרומיס מיותר) תסתיים מהר יותר, וממילא התשובה תישלח ללקוח מהר יותר?
-
@davidnead נכון, לעשות פרומייס ממשהו שהוא לא אסינכרוני זה מיותר ואפילו יש לזה תקורת ביצועים מסויימת, היא שולית כ"כ שבחיים לא תראה את זה (אם על זה עקשנותך, חפש בגוגל javascript promise overhead). ולכן אני התעלמתי לגמרי מהנושא (וזה באמת גורם שהקוד הלא מפוקטר, וגם שני הקודים בשאלה הפותחת אכן יראו הבדל, שטויות). התייחסתי למה שראית בעיניים: הקוד שאחרי await קורה באיחור מאשר קוד שבלעדיו. זו עובדה. זה לא עניין של איטיות אלא עניין של תזמון.
למעשה טעיתי, כי await קורה באיחור רק כשיש באמת אסיכנרוניות כל שהיא. אם זה פייק אסינכרוניות, זה הכל יקרה בדיוק בתזמון הנכון גם עם await. בקיצור, על הצד שיש מה להבטיח בקוד, ההבטחה נצרכת ואם לא היא לא משנה כלום מההתנהגות שבלעדיה.אמרתי לך לא להמתין כשאין צורך לא כדי לחסוך זמן (מהירות/איטיות) אלא כדי שהלקוח יקבל תשובה יותר מהר. ונפלאת שדבריי סותרים זה את זה. הפליאה היא מה כ"כ קשה להבין שבתזמון אם אתה אומר למישהו שאתה אחרי X אתה גורם לו להמתין לחינם... אם אתה כותב כזה קוד:
await emailConfirm(); res.json('ok');
הלקוח יקבל את התשובה אחרי שליחת המייל. ואילו אם תהפוך את סדר השורות או תסיר את הawait הוא יקבל מיידית תשובה (למרות לגיטימי ברוב המקרים לכפות המתנה כדי שהלקוח ידע אם קרתה שגיאה וכדומה).
-
@dovid אכן, עשה רושם שאנו לא מתמקדים באותה נקודה, טוב שהבהרתי את עצמי.
בדיוק אותה נקודה שלא התייחסת עליה - עליה נסובה שאלתי, וכמשמעותו המדויקת של כותרת האשכול. (ברור שדרך השימוש בפועל בפרומיס וסדר ביצוע הפעולות מוכר לי כמובן היטב, אם כי היה מחכים להבין כיצד זה מופעל על ידי המנוע של JS).זו גם התשובה על הפליאה שלך על הפליאה שלי - הדוגמה שלך על מייל היא אכן טובה, אך אני הדגשתי כמה פעמים שאני מדבר על לוגיקה שצריכה להתבצע כסדרה, כלומר שאני זקוק שהיא תסתיים כדי לקבל את התשובה, וכמו בדוגמה שלי על קריאה מקובץ ולא כמו בדוגמה שלך על שליחת מייל. וממילא השאלה לא על המתנה מיותרת שכן ההמתנה לצורך הרצת הקוד כסדרו נדרשת, אלא על פרומיס/אסינכ מיותר מפני שאינו עושה פעולה סינכרונית.
אז לגוף השאלה:
אתה אומר שהמשמעות זניחה ולא אמורה להטריד אותי. היות שאני מדבר על מקרה שאמור להיות מאתגר מבחינת ביצועים והמהירות חשובה לי מאוד ברמת חלקי שניות (עשרות קריאות לפונקציה רקורסיבית שמורצת בסוף מאות פעמים, כשאני מצפה מהשרת לתשובה בזמן אפס או קרוב לכך), לא התעצלתי ועשיתי את הגוגל אליו שלחתי אותי, שהוביל אותי למאמר הבא:
https://madelinemiller.dev/blog/javascript-promise-overhead/המאמר הזה מתמקד בדיוק בשאלתי, ובודק כמה מקרים. מסקנתה היא ששימוש מיותר בפונקציית async גורם לפונקציה להיות איטית בין 81-85 אחוז (כמעט חצי מהירות). ושימוש מיותר בפרומיס (לא ידעתי שיש הבדל בינו לפונקציית async) גורם לאיטיות של כ26%.
בכל זאת, למרות האיטיות המשמעותית היא ממליצה לתת על זה את הדעת רק אם נתקלים בבעיות ביצועים בקוד. אני מניח שהסיבה לכך היא שעדין מדובר בהפרש זמן זעיר מאוד, בפרט שניתן להניח שתוכן הלוגיקה בקוד תופסת את עיקר נפח הביצועים ולא עצם הרצת הפונקציה או הפרומיס. כנראה זה גם השיקול שלך.
הוספה
ככלל, אני חושב שהבנת הנקודה הזו חשובה בשביל סיגול הרגלים נכונים בכתיבת קוד, בלי קשר לתוצאת הביצועים.
למשל, פעמים רבות נוח לי לכתוב קוד כזה:async function getData() { const res = await readFiles("path"); return res; } async function run() { const res = await getData(); console.log(res); }
במקום קוד כזה:
function getData() { return readFiles("path"); } async function run() { const res = await getData(); console.log(res); }
וזאת לצרכי דיבוג.
כל פעם שאני כותב כך עובר לי איזה יתוש באחורי המוח ששואל האם לכתיבה המיותרת הזו יש משמעות מעבר לכמה מילים יתירות בעורך הקוד. כעת מתברר שכן, זה גורם לפעולה נוספת, גם אם זניחה. -
@davidnead אני לא מבין מה שהוא כותב עם האחוזים, אבל זה לא נוגע לך, מבטיח לך. אם זה היה טיפה טיפה נוגע לך היו על זה פי מאות מאמרים ודיונים באינטרנט וגם הV8 היה מחסל ממזמן את הבעיה ע"י אופטימיזציה שמתעלמת מהasync המזוייף. אגב מצאתי שאכן הם שיפרו את זה דרמטית בשנים עברו: https://v8.dev/blog/fast-async (אתה יכול לראות שמה שבנוד 8 היה לאט פי ארבע).
אתה צריך תשובה במהירות רבה, אבל החלק שמשנה אם זה async או לא, שקול לחלקים הזויים אחרים שבחיים לא תיתן עליהם את הדעת... היה פה אחד בפורום שחשש שאורך שמות המשתנים משפיע על המהירות וכל מיני דברים שגם לו הם היו נכונים פסול לחלוטין לשקול איך לכתוב קוד לפי זה.
בקשר לקודים שהבאת, אתה צודק, return await נחשב מיותר הן תחבירית והן הוא מוסיף לחינם פרומייז. אבל זה נחוץ עם אתה עושה try catch בפונקציה.
היה דיון מעניין, תודה! -
@dovid אמר בפרומיסים לוקחים זמן?:
@davidnead אני לא מבין מה שהוא כותב עם האחוזים, אבל זה לא נוגע לך, מבטיח לך.
(אגב זה היא, לא הוא). זה מצוין אם זה לא נוגע לי. החשש העיקרי שלי היה שאני עושה שימוש שגוי בקוד, ניסיון מר הראה לי ששימוש לא נכון נכון עשוי להפוך קוד פשוט למפלצתי מבחינת ביצועים. פוגגת לי את החשש, תודה.
וגם הV8 היה מחסל ממזמן את הבעיה ע"י אופטימיזציה שמתעלמת מהasync המזוייף.
אממ... הגיוני.
תודה גם לך
-
@dovid אמר בפרומיסים לוקחים זמן?:
JS היא אסינכרונית
סליחה שאני מתקן את מורי ורבי אבל אין משוא פנים בתיכנות, JS היא שפה סינכרונית לחלוטין!!!!
כל מה ש"אסינכרוני" ב JS אלו משימות API שהדפדפן עושה בשבילו (כגון קריאות הטטפ או אינטרוול וכדומה), וזה מה שנכנס ללולאת האירועים. -