ה-TimeProvider של .NET 8 ושינוי קטן ב-SynchronizationContext יכולים לחסוך שניות בבדיקות יחידה (unit tests) של לוגיקת ניסיונות חוזרים (retry-logic), שבדרך כלל נשארות במצב המתנה בגלל sleep אמיתי. בדיקה שבעבר ארכה שבע שניות מסתיימת כעת בכמה עשרות מילישניות, והשינוי אינו כרוך בעלות נוספת בהרצה בסביבת ייצור (production).
למה העיכוב הזה משנה
עזרי ניסיונות חוזרים (retry helpers) רבים משתמשים ב-exponential backoff: המתנה של שנייה אחת, אחר כך 2 שניות, אחר כך 4 שניות, וכן הלאה. בסביבת ייצור, זה מגן על שירותים מפני הצפה של נקודת קצה (endpoint) שנכשלה. בערכת בדיקות, אותו קוד קורא ל-Task.Delay על שעון אמיתי, כך שחוט הבדיקה (test thread) למעשה נרדם. כשמכפילים את זה בעשרות בדיקות, תהליך ה-continuous-integration (CI) מתארך בדקות ללא שום תועלת פונקציונלית. "מס ה-sleep" הוא פשוט זמן מבוזבז.
איך TimeProvider עובד
.NET 8 הציגה את ה-TimeProvider, אבסטרקציה מעל שעון המערכת. מחלקה שזקוקה לזמן הנוכחי או צריכה להשהות פעולה יכולה לקבל מופע (instance) של TimeProvider במקום לקרוא ישירות ל-DateTime.UtcNow או ל-Task.Delay. בסביבת ייצור אתם מעבירים את TimeProvider.System, שמעביר את הבקשה לשעון האמיתי. בבדיקה, אתם מספקים FakeTimeProvider. ה-fake מאפשר לכם לקדם את הזמן ידנית באמצעות Advance(TimeSpan). מבחינה פנימית, Task.Delay קורא לספק (provider), כך שקידום השעון המדומה מקדם באופן מיידי כל השהיה ממתינה.
הרעיון פשוט: להחליף את השעון האמיתי בשעון שניתן לשליטה, ואז לקפוץ קדימה לנקודה שבה הקוד הנבדק היה ממשיך לפעול.
מלכודת ה-SynchronizationContext של xUnit
הקונספט עובד באפליקציית קונסול, אך ב-xUnit הוא גרם ל-deadlock. הבדיקה קראה ל-Advance() בזמן שעזר הניסיונות החוזרים המתין להשהיה. xUnit מתקין SynchronizationContext משלו שתופס המשכי פעולה (continuations) ומריץ אותם על חוט הבדיקה. כש-Advance() קידם את השעון, המשך הפעולה נכנס לתור של ה-thread pool במקום לחוט הבדיקה. חוט הבדיקה המשיך לקדם את הזמן, מה שדחף את השעון המדומה אל מעבר לרגע שבו הניסיון החוזר הבא אמור היה להתבצע. הטיימר החדש נקבע לנקודה עתידית שלעולם לא תתקבל, מכיוון שלא נשאר חוט שיקדם את השעון שוב. הבדיקה נתקעה ללא הגבלה.
התיקון בשורה אחת
הוספת שורה אחת בתחילת הבדיקה מחזירה את ההתנהגות הצפויה:
SynchronizationContext.SetSynchronizationContext(null);
ניקוי ההקשר (context) המותאם אישית מאלץ את המשכי ה-await לרוץ על ה-thread pool, שם ה-callbacks של הטיימר המדומה יכולים להתבצע. עם השינוי הזה, אותה בדיקה צונחת משבע שניות לכ-36 מילישניות.
יתרונות רחבים יותר
מעבר לביטול מצבי המתנה מיותרים, שעון מדומה מקל על בדיקת לוגיקה רגישה לאזורי זמן. ניתן לוודא שמכסה יומית מתאפסת בחצות המקומי הנכון מבלי לחכות שהשעון האמיתי יגיע לשעה שתים עשרה. אותה גישה עובדת עבור כל קוד המתבסס על הזמן הנוכחי: מזריקים (inject) את ה-provider, נמנעים מקריאות נסתרות ל-Thread.Sleep או ל-Task.Delay, ומקבלים בדיקות מהירות ודטרמיניסטיות.
דברים שצריך לשים לב אליהם
- כל השהיה חייבת לעבור דרך ה-
TimeProviderהמוזרק.Thread.Sleepתועה אוTask.Delayישיר עדיין יפעילו את השעון האמיתי ויחזירו את השיהוי. - אם ספרייה שאתם תלויים בה קוראת פנימית ל-
Task.Delayמבלי לחשוף provider, לא תוכלו לשלוט בתזמון שלה ללא shim פולשני יותר. במקרים כאלה, התועלת עשויה להיות מוגבלת. - בצעו grep בתיקיית הבדיקות עבור
Task.Delayו-Thread.Sleepכדי לאתר המתנות נסתרות. רפקטורינג (Refactoring) של הקריאות הללו לשימוש ב-provider הוא הדרך היחידה לשמור על יעילות השעון המדומה.
שורה תחתונה
החלפת שעון המערכת ב-TimeProvider וביטול ה-SynchronizationContext של xUnit הופכים בדיקות ניסיונות חוזרים איטיות לבדיקות כמעט מיידיות, מה שמשחרר משאבי CI והופך קוד תלוי-זמן לקל יותר לאימות. המאמץ הוא של מספר שורות קוד; התמורה נמדדת בשניות שנחסכו בכל הרצת בדיקה.
