טבלת החלטה: לסיים או להמשיך אחרי חריגה בלתי צפויה

· עודכן בתאריך: · · פיתוח Windows, טיפול בחריגות, תכנון, ‏C# / .NET, אמינות

הורידו את רשימת הבדיקה ב-Excel עם גיליון עברי ואנגלי

הקובץ הזה בנוי משני גיליונות, Checklist-ja ו-Checklist-en, ומפרק את הציר של המאמר הזה ל-4 קטגוריות - תנאי המשך / תנאי סיום / כיוון מימוש / סדר ההחלטה - ב-27 סעיפים. העמודות Status ו-Notes נשארות ריקות, כך שאפשר להשתמש בקובץ ישירות כטבלת בדיקה לתיעוד טיפול בתקלות או לסקירת תכנון.

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

מה שבאמת רוצים לבדוק הוא האם אפשר לכלוא את הטווח שאולי נשבר.

  • אפשר לסיים רק את הפעולה הזו בכישלון?
  • מספיק לאתחל מחדש רק את המסך / החיבור / ה-worker הזה?
  • או שכבר יש חשד לגבי העקביות של התהליך כולו?

כשמסתכלים בסדר הזה, קל בהרבה לסדר את המחשבה.

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

איור 1: בודקים את הטווח שאולי נשבר בסדר של פעולה, תת-מערכת, ואז התהליך כולו.

המאמר הזה מניח בסיס של אפליקציות Windows,‏ C# / .NET, אפליקציות שרצות ברקע, שירותי Windows וכלי חיבור לציוד, ומרכז כטבלת החלטה את התנאים שבהם מותר להמשיך אחרי חריגה בלתי צפויה, ואת התנאים שבהם עדיף לסיים.

1. קודם כל - המסקנה

  • catch (Exception) שרק בולע את החריגה וממשיך הוא בדרך כלל מסוכן.
  • מותר להמשיך רק כששלושת התנאים מתקיימים: אפשר לזרוק את היחידה שנכשלה, אפשר להחזיר את המצב המשותף למצב תקין, אפשר להסביר את תופעת הלוואי החיצונית.
  • כשגבול העיבוד ברור - פעולה אחת בממשק, קלט אחד, job אחד - לפעמים אפשר להמשיך.
  • לעומת זאת, כשמעורב מצב משותף שניתן לכתיבה, לולאה שרצה ברקע, ה-thread הראשי, עיבוד ההפעלה, או ריח של השחתת זיכרון בגבול הנייטיב - עדיף לכיוון סיום.
  • חריגות כמו StackOverflowException,‏ AccessViolationException ו-OutOfMemoryException שמעוררות חשד לגבי “שלמות התהליך כולו” עדיף לא לטפל בהן מתוך הנחה שאפשר להמשיך.
  • ל-WPF ול-Windows Forms יש דרך לתפוס חריגה לא מטופלת ולהיראות כאילו ממשיכים, אבל האפשרות להמשיך ו-הבטיחות שבהמשך הם שני דברים שונים.
  • אפליקציות ושירותים שרצים לאורך זמן - קריסה עם הפעלה מחדש בדרך כלל בטוחה וקלה יותר לאבחון מאשר הישרדות במצב חצי-שבור.

בקיצור, ציר ההחלטה הוא האם אפשר להשיב את התנאי האינווריאנטי.

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

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

1.1 מונחים שבהם משתמשים במאמר הזה

לפני שקוראים את טבלת ההחלטה, כדאי לקבוע מראש 5 מילים שחוזרות שוב ושוב.

מונח המשמעות במאמר הזה
תנאי אינווריאנטי הבטחה למצב שחייבת להתקיים לפני ואחרי הפעולה. למשל “סכום הפרטים תואם לערך המצטבר”, “תוכן ה-cache ותוכן ה-DB תואמים”, “כל חיבור פתוח בהכרח רשום ברשימת הניהול”. כשהתנאי הזה נשבר וממשיכים לפעול, כל העיבוד הבא הופך לחשוד
יחידת כישלון הטווח שאפשר לזרוק כולו כשמשהו נכשל. פעולה אחת, מסך אחד, job אחד, חיבור אחד וכדומה
תופעת לוואי חיצונית שינוי שכבר יצא אל מחוץ לתהליך. עדכון DB, כתיבה לקובץ, שליחת מייל, שליחת פקודה לציוד - דברים שאי אפשר להחזיר עם catch
תת-מערכת היחידה שאפשר לעצור ולאתחל מחדש יחד. חיבור, מסך, worker, תהליך-בן וכדומה
FailFast הכוונה ל-Environment.FailFast. API שמסיים את התהליך מיד בלי להריץ try / finally או finalizer, ומפורט בסעיף 9.7

מפת הידע של המאמר

המאמר מסדר שהתגובה לחריגה בלתי צפויה היא לא בליעתה עם catch (Exception) והמשך העיבוד, אלא החלטה בין המשך לסיום לפי התנאי האינווריאנטי של המצב המשותף, יחידת הכישלון, והיכולת להסביר את תופעת הלוואי החיצונית. חריגות כמו StackOverflowException,‏ AccessViolationException, ו-OutOfMemoryException חמור שמלוות בסימני השחתת זיכרון, וכן חריגות בגבול הנייטיב כמו COM ו-P/Invoke, נוטות לכיוון סיום, כאשר Environment.FailFast ו-Environment.Exit הם אמצעי המימוש לסיום. אם לולאת האב של BackgroundService נופלת מחריגה בלתי צפויה, כל עוד רצים כשירות Windows חובה להחזיר קוד סיום עם Environment.Exit, אחרת פעולת ההתאוששות לא תיכנס לפעולה, ו-handler-ים לחריגה לא מטופלת כמו AppDomain.UnhandledException משמשים לתיעוד ולא כאמצעי החלמה למצב.

מפת הידע של המאמר: לסיים או להמשיך אחרי חריגה בלתי צפויהתרשים שמראה שההחלטה בין המשך לסיום אחרי חריגה בלתי צפויה מניחה בדיקת התנאי האינווריאנטי, יחידת הכישלון ותופעת הלוואי החיצונית, שחריגות עם סימני השחתת זיכרון או תקלה בגבול הנייטיב נוטות לכיוון סיום, ש-BackgroundService שנופל מחריגה בלתי צפויה חייב Environment.Exit כדי שפעולת ההתאוששות של שירות Windows תיכנס לפעולה, ושה-handler-ים לחריגה לא מטופלת משמשים לתיעוד ולא להחלמה.מחייבמחייבמחייבעלול לגרום לשימוש לא מומלץ למענה מומלץ למענה מומלץ למענה מומלץ לעלול לגרום למענה מומלץ למממש אתמממש אתעלול לגרום למענה מומלץ למחייבשימוש לא מומלץ למממש אתשימוש לא מומלץ לאינו מתיישב עםמונענבדק באמצעותחריגה בלתי צפויהתנאי אינווריאנטיהמשך העיבודיחידת הכשלתופעת לוואי חיצוניתבליעת חריגות ב-catch (Exception)הפיכה לתהליך זומביסיום התהליךStackOverflowExceptionAccessViolationExceptionEnvironment.FailFastOutOfMemoryExceptionסימני השחתת זיכרוןEnvironment.Exitגבול הנייטיבBackgroundServiceIHostApplicationLifetime.StopApplicationשירות Windowsמטפל בחריגות שלא נתפסוניסיון לשחזר את המצב אחרי חריגה

בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 21, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle

2. מה נחשב “חריגה בלתי צפויה” במאמר הזה

2.1 ההבדל בין צפוי לבלתי צפוי

קודם כל, חריגה נדירה וחריגה בלתי צפויה הם לא אותו דבר.

למשל, הדברים הבאים אפשר להחשיב כצפויים גם אם התדירות שלהם נמוכה.

  • המשתמש בחר קובץ שלא קיים
  • צד השרת עבר timeout זמני
  • שורה אחת ב-CSV שנקלט הייתה פגומה
  • פעולת ביטול הצמיחה OperationCanceledException
  • הפרת חוק עסקי שרוצים להכשיל רק בגללה את הפעולה הזו

אלה מהסוג שבו אפשר להחליט מראש בתכנון איך לטפל בכישלון.

לעומת זאת, החריגות הבלתי צפויות שהמאמר הזה עוסק בהן בעיקר הן כאלה:

  • ההנחות של הקוד שלכם נשברו וקיבלתם NullReferenceException או InvalidOperationException
  • חריגה נזרקה באמצע עדכון מצב משותף, ולא ברור עד כמה השינוי כבר השתקף
  • לולאת האב של לולאת ניטור או עיבוד הודעות קרסה
  • חריגה בגבול COM / P/Invoke / SDK של ספק חיצוני
  • מצבים כמו AccessViolationException או StackOverflowException, שבהם “בדיקת הבריאות” של התהליך עצמה כבר אדומה

במילים אחרות, אלה מקרים שבהם “לא ברור אם עדיין אפשר לסמוך על מצב האפליקציה אחרי החריגה הזו”.

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

איור 3: חריגה נדירה וחריגה בלתי צפויה הן דברים שונים, וההבדל הוא אם אפשר להחליט מראש איך מטפלים.

2.2 נראה כמו שתי אפשרויות, אבל בעצם יש שלוש

מה שמסבך את הדיון הזה הוא ההתייחסות ל”המשך” כאל סוג אחד.

בפועל, בדרך כלל מתחלקים ל-3 שלבים.

בחירה משמעות
להכשיל רק את הפעולה ולהמשיך המסך נשאר, אבל רק השמירה או הקליטה הנוכחית מסומנת ככישלון
לעצור רק את תת-המערכת ולהמשיך מאתחלים מחדש רק את החיבור, המסך, ה-worker או תהליך-הבן
לסיים את התהליך הטווח של השחתת המצב לא ברור, ולכן מניחים הפעלה מחדש

“להמשיך להפעיל את האפליקציה” יכול להשמע כמו דבר אחד, אבל יש הבדל משמעותי בין להמשיך כאילו כלום לא קרה לבין לבודד את החלק שנשבר ואז להמשיך.

אותו המשך, משקל שונהתרשים שמראה שלמרות שנראה כמו "להמשיך להפעיל את האפליקציה", יש הבדל משמעותי בין המשך כאילו כלום לא קרה לבין המשך שבו בודדים את החלק שנשבר.להמשיך להפעיל את האפליקציהלהמשיך כאילו כלום לא קרהלבודד את מה שנשבר ואז להמשיךאותו המשך, משקל שונה

איור 4: לא כדאי לחשוב על “המשך” כסוג אחד - יש להבחין בין המשך שמבודד את החלק השבור.

3. טבלת ההחלטה שכדאי לבדוק ראשונה

3.1 מבט כללי

אם מתחילים מהטבלה הזו, קל להגיע לכיוון מדיניות כללי.

מצב הבחירה הראשונית הסיבה
קלט אחד, פעולת מסך אחת, או job אחד נכשל, ואפשר לזרוק את המצב נטייה להמשיך אפשר לכלוא את יחידת הכישלון
אחרי החריגה אפשר לזרוק את האובייקט או החיבור ולבנות אותם מחדש נטייה לאתחל מחדש תת-מערכת אפשר להגביל את הטווח שנשבר לאזור מקומי
מצב משותף עודכן חלקית ולא ברור עד כמה השינוי השתקף נטייה לסיום ייתכן שהתנאי האינווריאנטי נשבר
תופעת לוואי חיצונית - DB / קובץ / פקודה לציוד - נשארה באמצע, ואי אפשר להסביר כפילות או אי-שיקוף נטייה לסיום לא ברור מה מצב העקביות מול העולם החיצוני
לולאת ניטור, לולאת חיבור מחדש או לולאת האב של עיבוד הודעות קרסו מחריגה בלתי צפויה נטייה לסיום נטייה להפוך ל”זומבי” כשרק חלק מהתפקוד מת בשקט
כישלון בעיבוד הפעלה, טעינת הגדרות, בניית DI, או אתחול תלות חובה נטייה לסיום עם כישלון הפעלה הפעלה חלקית מסוכנת יותר
AccessViolationException,‏ StackOverflowException,‏ OutOfMemoryException חמור, או ריח של השחתה בצד הנייטיב נטייה לסיום מיידי חשד לגבי שלמות התהליך כולו
עיבוד מסוכן מבודד בתהליך נפרד, ותהליך האב נשאר שלם תהליך האב ממשיך, תהליך הבן מופעל מחדש אפשר לבודד את אזור התקלה
הזרימה המלאה לבחירת המשך או סיוםתרשים שמראה בדיקה בסדר של ריח השחתת זיכרון, יחידת כישלון, מצב משותף ותופעת לוואי חיצונית, ולפי זה קביעת סיום מיידי, נטייה לסיום, עצירת תת-מערכת, או המשך עם הפעולה בלבד כנכשלת.כןלאלאכןלאכןלאכןחריגה בלתי צפויהריח של השחתת זיכרון / דלדול מחסנית / דלדול משאבים קריטי?סיום / FailFast / הפעלה מחדשאפשר לזרוק את יחידת הכישלון?נטייה לסיוםאפשר לשחזר או לאתחל מחדש את המצב המשותף?עצירת תת-מערכת או סיוםאפשר להסביר את תופעת הלוואי החיצונית?המשך עם הפעולה הזו בלבד כנכשלת

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

הענף הראשון בתרשים, “ריח של השחתת זיכרון”, מפורט בסעיף 3.4, ו-FailFast בפינה הימנית העליונה מפורט בסעיף 9.7.

3.2 מה בודקים לפני סוג החריגה

לא כדאי להחליט מיד לפי סוג החריגה בלבד. כדאי לבדוק קודם את הדברים הבאים.

מה בודקים מה בודקים בפועל
איפה זה קרה אירוע UI, job בודד, לולאת האב, עיבוד הפעלה, או גבול נייטיב
כמה התקדם האם מצב הזיכרון, ה-DB, הקובץ או מצב הציוד השתנו באמצע
הטווח האפשרי של הנזק רק האובייקט הזה, כל המסך, או כל התהליך
אפשר rollback? אפשר לזרוק ולבנות מחדש, או להחזיר עם טרנזקציה
תופעת לוואי חיצונית נשלח או לא נשלח, האם הרצה כפולה בטוחה, אפשר עיבוד פיצוי
ניטור והפעלה מחדש יש הפעלה מחדש אוטומטית או מסלול שחזור אחרי הנפילה

3.3 חריגות ברמת סיכון גבוהה

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

חריגה / תסמין הבחירה הראשונית למה בודקים את זה
StackOverflowException נטייה לסיום מיידי מחסנית הקריאות קרסה, קשה להניח החלמה רגילה
AccessViolationException נטייה לסיום מיידי גישה לא חוקית לזיכרון מוגן, חשד לגבול נייטיב או השחתת זיכרון
OutOfMemoryException נטייה לסיום גם עיבוד ההחלמה עצמו, שמניח הקצאה נוספת, נוטה להיות לא יציב
NullReferenceException /‏ InvalidOperationException בלתי צפויות תלוי בהקשר, אך נטייה לסיום ההנחות שלכם נשברו, ייתכן שנשאר שינוי חלקי
חריגה בלתי צפויה שדלפה מלולאת האב נטייה לסיום ליבת הפונקציונליות מתה בזמן שהתהליך עצמו נשאר, מסוכן
חריגה שמקורה ב-callback של COM / P/Invoke / SDK ספק נטייה לסיום מיידי עד נחרץ קשה לשפוט בטיחות מצד ה-managed בלבד

3.4 מה עומד מאחורי “ריח של השחתת זיכרון” ו”היפוך לזומבי”

שני הביטויים האלה נשמעים אינטואיטיביים, אבל בפועל יש להם סימנים ברורים שבודקים.

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

מה רואים איפה רואים את זה
התהליך קורס עם קוד חריגה 0xc0000005 (גישה לא חוקית) או 0xc0000374 (השחתת heap) מציג האירועים > יומני Windows > Application, תחת “שגיאת אפליקציה” (מזהה אירוע 1000)
מקום הקריסה משתנה בכל פעם. קורס בקוד שלא נגעו בו ממש לפני כן לוגים, מחסנית הקריאות ב-dump
קורס רק כשנוגעים באובייקט או handle שכבר שוחררו תהליך שחזור, dump
קריסה בעיבוד שחרור בצד הנייטיב (free /‏ delete / שחרור COM) מחסנית הקריאות ב-dump (למשל קריסה בתוך ntdll.dll)
ערך שלא נגעו בו השתנה. אותו קלט, אבל התוצאה משתנה לוגי קלט/פלט, השוואת תוצאות בין הרצות

0xc0000005 הוא STATUS_ACCESS_VIOLATION ו-0xc0000374 הוא STATUS_HEAP_CORRUPTION, שניהם ערכים המוגדרים ברשימת NTSTATUS של Microsoft. במיוחד השחתת heap, הקריסה קורית לא ברגע ההשחתה עצמו אלא בצד שנוגע ב-heap השבור בפעם הבאה, ולכן מקום הקריסה לא בהכרח “האשם”. אם רואים תסמין מהסוג הזה, בטוח יותר להניח שתפיסה ב-catch בצד ה-managed והמשך פעולה לא באמת עוזרים.

הפער במקום הקריסה בהשחתת heapתרשים שמראה שבהשחתת heap הקריסה לא קורית ברגע ההשחתה, אלא בצד שנוגע ב-heap השבור בפעם הבאה, ולכן מקום הקריסה לא בהכרח האשם.ה-heap נשבר במקום כלשהולא קורסים באותו רגעקורסים בצד שנוגע בו בפעם הבאהמקום הקריסה לא בהכרח האשם

איור 6: השחתת heap גורמת לקריסה בצד שנוגע בה בפעם הבאה, כך שיש פער בין מקום הקריסה למקום ההשחתה.

עכשיו, תסמינים שמעוררים חשד להיפוך לזומבי.

מה רואים איפה רואים את זה
התהליך חי, אבל זמן העיבוד האחרון לא מתעדכן לוג של זמן עיבוד אחרון, heartbeat
רק מספר הפריטים הממתינים בתור או בתיקיית הקליטה ממשיך לגדול אורך התור, מספר קבצים שלא טופלו
הלוג פשוט נעצר מנקודת זמן מסוימת לוג האפליקציה
המסך פועל אך העדכון מאחורי הקלעים נעצר השוואה בין תצוגת המסך לנתונים בפועל
מספר ה-worker threads נמוך מהצפוי לוג אבחון, רשימת threads ב-Process Explorer

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

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

איור 7: זיהוי היפוך לזומבי לא נעשה לפי “לא קרס” אלא לפי מדד שמראה שהעבודה מתבצעת.

4. ההחלטה לפי המקום שבו זה קרה

4.1 אירועי ממשק המשתמש

אירועי UI כמו לחיצת כפתור, מעבר בין מסכים, חיפוש או בחירת קובץ - יש בהם יחסית הרבה מרחב להמשך. אבל יש תנאים.

קל להמשיך במקרים כאלה:

  • הכישלון קרה לפני הטעינה, ומצב העסק עוד לא נגע בו
  • רק מצב זמני בתוך דיאלוג נשבר, ואפשר לזרוק אותו בסגירת המסך
  • אפשר לבנות מחדש את ה-ViewModel או החיבור אחרי החריגה
  • אפשר להודיע למשתמש בכנות ש”הפעולה הזו נכשלה”

לעומת זאת, במצבים הבאים כדאי לנטות לכיוון סיום:

  • גם המסך וגם מצב הדומיין עודכנו חלקית
  • נגעו במצב משותף שגם מסכים אחרים רואים - static / singleton / cache
  • אחרי החריגה נשארו רק מצב הכפתור הפעיל או הבחירה, ולא ברור מה מצב העקביות
  • חריגה בלתי צפויה קרתה ב-UI thread, ולא ברור עד כמה הרינדור או ההודעה כבר התקדמו
הגבול בחריגות בפעולות UIתרשים שמראה שחריגה באירוע UI שנוגעת רק במצב זמני שאפשר לזרוק היא קלה יותר להמשך, ואם נגעה במצב משותף שמסכים אחרים רואים או בעדכון חלקי, נוטה לכיוון סיום.רק מצב זמני שאפשר לזרוקמצב משותף או עדכון חלקיחריגה בלתי צפויה בפעולת UIבאיזה מצב נגעו?המשך עם הפעולה הזו בלבד כנכשלתנטייה לסיום

איור 8: לאירועי UI יש מרחב גדול להמשך, אבל אם נגעו במצב משותף התמונה משתנה.

4.2 ‏Job / בקשה שמעובדים אחד-אחד

זה גבול שקל יחסית להמשיך ממנו.

  • הודעה אחת
  • קובץ אחד
  • בקשת HTTP אחת
  • job קליטה אחד
  • פריט batch אחד

כשיחידה כזו ברורה, אפשר להכשיל רק את הפריט הזה ולהמשיך לפריט הבא.

אבל יש תנאי מוקדם.

  • יחידת הכישלון ברורה מבחוץ
  • שינוי חלקי מתעדכן כראוי בעזרת טרנזקציה או עיבוד פיצוי
  • יש תכונת “בטוח להריץ שוב” - הרצה חוזרת של אותו עיבוד לא שוברת את התוצאה
  • אפשר להעביר את הכישלון לתור בידוד או ללוג שגיאות

4.3 לולאה שרצה ברקע / ניטור / עיבוד תור

זה המקום שבו המשך רשלני מסוכן ביותר.

לדוגמה:

  • לולאת חיבור מחדש
  • לולאת ניטור
  • לולאת צריכת תור
  • polling תקופתי
  • ניטור מצב ציוד
  • עיבוד שרץ ברקע באפליקציית tray

מה שמפחיד בסוג העיבוד הזה הוא שלולאת האב מתה מחריגה בלתי צפויה אחת, והתהליך נשאר בחיים לבדו.

כאן כדאי לחלק את המדיניות.

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

איור 9: מחלקים מדיניות בין גבול הפריט ללולאת האב, וחריגה בלתי צפויה בלולאת האב מובילה לסיום.

4.4 עיבוד הפעלה

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

  • לא ניתן לקרוא הגדרה חובה
  • מעבר גרסה / migration נכשל
  • תיקייה חובה או תעודה חסרים
  • אתחול שירות ליבה נכשל
  • הרכב התלות שבור

במקרים כאלה, ברור יותר לסיים את התהליך כתוצאה מכישלון הפעלה.

4.5 גבול הנייטיב / COM / P/Invoke / unsafe

כאן כדאי להתייחס בחומרה מעט יותר גדולה, כקטגוריה נפרדת.

  • COM
  • P/Invoke
  • מעבר דרך C++/CLI
  • SDK של ספק חיצוני
  • קוד נייטיב שחוזר דרך callback
  • עיבוד שכולל unsafe

בפרט, אם רואים את אלה כדאי לנטות לכיוון סיום.

  • AccessViolationException
  • תסמין שמעורר חשד להשחתת heap או double free
  • תקלת handle, או ריח של גישה אחרי שחרור
  • קריסה פתאומית בגבול callback
הטיפול בחריגה בגבול הנייטיבתרשים שמראה שבגבול הנייטיב, כמו COM ו-P/Invoke, אם רואים AccessViolationException, תסמין שמעורר חשד להשחתת heap, או קריסה פתאומית בגבול callback, נוטים לכיוון סיום, ושגבול זה עצמו נבדק בחומרה גבוהה יותר.AccessViolationExceptionנטייה לסיוםתסמין של השחתת heap או double freeקריסה פתאומית בגבול callbackגבול הנייטיב נבדק בקטגוריה נפרדת ובחומרה

איור 10: כשרואים תסמינים כאלה בגבול הנייטיב, נוטשים את הנחת ההמשך ופונים לכיוון סיום.

5. תנאים שמתחתם מותר להמשיך

התנאים שמותר להמשיך תחתם, מסודרים ביחד, נראים כך. בדרך כלל הם צריכים להתקיים כמעט כולם יחד.

תנאי משמעות
יחידת כישלון ברורה ברור מה זורקים - פעולה אחת, מסך אחד, job אחד, חיבור אחד
אפשר לזרוק את המצב אפשר לבנות אותו מחדש, או להתייחס אליו כלא-משוקף
המצב המשותף מוגן אין התפשטות של הדבקה לפונקציונליות אחרת
אפשר להסביר את תופעת הלוואי החיצונית ברור אם נשלח / לא נשלח / מותר לשלוח שוב
אפשר להודיע למשתמש בכנות אפשר להציג “הפעולה הזו נכשלה”
אפשר לנטר אפשר לחקור בדיעבד עם לוגים, מדדים ו-dump

6. תנאים שמתחתם עדיף לסיים

לעומת זאת, כשמתקיים אחד מהתנאים הבאים, עדיף לנטות לכיוון סיום.

  • לא ברור מה השתנה עד כה
  • נגעו במצב משותף שניתן לכתיבה, ולא ברור מה מצב העקביות
  • ניהול חיי הנעילה, התור, ה-thread או לולאת הניטור נשבר
  • לא ניתן להסביר כפילות / חסר / חלקיות בתופעת לוואי חיצונית
  • כישלון בעיבוד הפעלה או באתחול תשתית ליבה
  • חשד להשחתת זיכרון או תקלה בגבול הנייטיב

ברמה כזו, מאמץ לשמר המשך נקי פחות אפקטיבי מ-מאמץ להפיל ולהקל על ההתאוששות.

המאמץ שמועיל בנטייה לסיוםתרשים שמראה שברמה שמתאימה לתנאי הסיום, מאמץ לשמר המשך נקי פחות מועיל מהשקעה במאמץ להפיל ולהקל על ההתאוששות.מתקיים תנאי הנוטה לסיוםמאמץ לשמר המשך נקימאמץ להפיל ולהקל על ההתאוששותפחות אפקטיבי ברמה הזוזה יותר אפקטיבי

איור 11: במצב שנוטה לסיום, כדאי להשקיע בקלות התאוששות ולא במאמץ להמשיך בצורה נקייה.

7. המלצות לפי דפוסים אופייניים

טבלת ההחלטה בסעיף 3.1 כתובה בצורת תנאים, ולכן לפעמים קשה להתאים אותה למקרה קונקרטי. הטבלה הזו לוקחת את אותם תנאים ומתאימה אותם למצבים שנתקלים בהם בפועל. כשמתלבטים, הכי מהיר לבדוק קודם את התנאים בסעיף 3.1, ואז לחפש את השורה הקרובה ביותר בטבלה הזו.

איך משתמשים בטבלה כשמתלבטיםתרשים שמראה שכשמתלבטים איך להתאים את המצב, קודם בודקים את התנאים בטבלת ההחלטה בסעיף 3.1, ואז מחפשים בטבלת הפרק הזה את השורה הקרובה ביותר למצב.התלבטות בהתאמה למצבבדיקת תנאים בטבלה של 3.1חיפוש שורה קרובה בטבלת הפרק הזה

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

דפוס המלצה הסיבה
כפתור פתיחת קובץ קיבל נתיב שלא קיים המשך עם הפעולה הזו בלבד כנכשלת השחתת המצב מקומית
שורה אחת בקליטת CSV הייתה פגומה המשך עם כישלון שורה אחת או קובץ אחד קל לכלוא את יחידת הכישלון
NullReferenceException בלתי צפויה קרתה באמצע שמירת מסך בניית המסך מחדש עד נטייה לסיום לא ברור עד כמה ה-ViewModel או מצב העסק כבר השתנו
הודעה אחת בתור הפרה חוק עסקי המשך עם כישלון ההודעה הזו בלבד אפשר להעביר אותה לתור בידוד
לולאת האב של צריכת תור קרסה מחריגה בלתי צפויה נטייה לסיום התהליך חיי כל ה-worker נשברים
הגדרה חובה לא נקראת בהפעלה סיום עם כישלון הפעלה הפעלה חלקית מסוכנת יותר
AccessViolationException בסביבת callback של SDK ספק נטייה לסיום מיידי אי אפשר להתעלם מהאפשרות של השחתת זיכרון
שליחת טלמטריה לא חיונית נכשלה בלבד ביטול הפונקציה הזו בלבד, המשך אפשר להפריד בין הפונקציה הראשית לאזור התקלה

8. טעויות נפוצות

8.1 catch (Exception) שרק רושם ללוג וממשיך

זה מסוכן למדי. זה מסתיר את הסיבה, ונוטה גם להאריך חיים למצב שכבר נשבר.

8.2 ניסיון להחלים דרך handler החריגות הלא-מטופלות האחרון

AppDomain.UnhandledException,‏ Application.ThreadException,‏ DispatcherUnhandledException וכדומה שימושיים כמקום תיעוד אחרון, אבל הם לא נקודת החלמה קסומה.

8.3 ‏retry קליל כשיש תופעת לוואי חיצונית

אם מריצים retry על עיבוד עם תופעת לוואי חיצונית - פקודה לציוד, שליחת מייל, חיוב, העברת קובץ, עדכון DB - בלי שיש בטיחות להרצה חוזרת, התאונה של הרצה כפולה תופסת את המרכז.

retry קליל שמוביל להרצה כפולהתרשים שמראה שכשמריצים retry על עיבוד עם תופעת לוואי חיצונית בלי בטיחות להרצה חוזרת, תאונת ההרצה הכפולה תופסת את המרכז.עיבוד עם תופעת לוואי חיצונית נכשלretry בלי בטיחות להרצה חוזרתתאונת הרצה כפולה תופסת את המרכזלמשל פקודה לציוד, חיוב, שליחה

איור 13: retry בלי בטיחות להרצה חוזרת מוביל לתאונת הרצה כפולה.

8.4 להשאיר את ה-UI כשלולאת הניטור מתה

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

8.5 לומר “לא רוצים שיפול” בלי לתכנן את הנפילה

אם לא רוצים שיפול, יש דברים שצריך להכניס לפני כן.

  • הפעלה מחדש אוטומטית
  • שחזור session
  • שמירת תוצאות ביניים
  • בטיחות להרצה חוזרת
  • בידוד אזור תקלה

9. נקודות סידור בזמן המימוש

9.1 להצמיד את מקום ה-catch לגבול

עדיף לתפוס במקום שבו אפשר להגדיר יחידת כישלון, ולא לתפוס כל דבר בשכבה עמוקה.

  • גבול פעולת UI
  • גבול בקשה אחת
  • גבול job אחד
  • גבול חיבור אחד
  • גבול התהליך

לדוגמה, בקליטה שמעבדת פריט אחר פריט, ה-catch ממוקם בתוך הלולאה. כאן זו יחידת הכישלון.

// C# / .NET 8. לולאה שמעבדת פריט אחר פריט, כשה-catch כולא את יחידת הכישלון בתוך הלולאה.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;

public sealed record ImportItem(string Id, string Payload);
public sealed record ImportFailure(string Id, string ExceptionType, string Message);
public sealed record ImportSummary(int Succeeded, IReadOnlyList<ImportFailure> Failed);

public interface IImportStore
{
    /// <summary>
    /// כישלון של פריט בודד (שגיאת אימות, כפילות, פורמט לא תקין וכדומה) נזרק כ-ImportItemException.
    /// כל דבר אחר הוא לא "בעיה של הפריט הזה", ולכן צריך לצאת החוצה כמות שהוא.
    /// </summary>
    Task SaveAsync(ImportItem item, CancellationToken cancellationToken);
}

/// <summary>כישלון של פריט אחד, שמותר לזרוק ולהמשיך לבא.</summary>
public sealed class ImportItemException(string message, Exception? inner = null)
    : Exception(message, inner);

public sealed class ImportRunner(IImportStore store, ILogger<ImportRunner> logger)
{
    public async Task<ImportSummary> RunAsync(
        IReadOnlyList<ImportItem> items,
        CancellationToken cancellationToken)
    {
        int succeeded = 0;
        List<ImportFailure> failed = [];

        foreach (ImportItem item in items)
        {
            cancellationToken.ThrowIfCancellationRequested();

            try
            {
                await store.SaveAsync(item, cancellationToken);
                succeeded++;
            }
            catch (ImportItemException ex)
            {
                // אפשר לזרוק את מצב הפריט הזה, לכן רושמים ועוברים לפריט הבא.
                //
                // אסור להפוך את זה ל-catch (Exception). אם בולעים גם
                // NullReferenceException או OutOfMemoryException בתור "סתם נתונים פגומים",
                // אז החריגה הבלתי צפויה שהוחלט בסעיף 4.3 "לעצור בגללה את כל ה-host"
                // לא תגיע ללולאת האב, וימשיכו לכתוב פריטים גם אחרי שהמצב כבר לא אמין
                logger.LogError(ex, "Import failed. ItemId={ItemId}", item.Id);
                failed.Add(new ImportFailure(item.Id, ex.GetType().Name, ex.Message));
            }
        }

        return new ImportSummary(succeeded, failed);
    }
}

לעומת זאת, ההחלטה הנגדית היא לא לבלוע בצד לולאת האב שמריצה את העיבוד הזה. כמו שכתוב בסעיף 4.3, המצב הכי מסוכן הוא שלולאת האב מתה והתהליך נשאר לבדו, ולכן חריגה בלתי צפויה מובילה לעצירת ה-host.

// צד הלולאה שרצה ברקע. בקשת עצירה יוצאת כמסלול תקין, וכל חריגה בלתי צפויה אחרת עוצרת גם את ה-host.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

public interface IImportQueue
{
    Task<IReadOnlyList<ImportItem>> DequeueBatchAsync(CancellationToken cancellationToken);
}

public sealed class ImportWorker(
    IImportQueue queue,
    ImportRunner runner,
    IHostApplicationLifetime lifetime,
    ILogger<ImportWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            using PeriodicTimer timer = new(TimeSpan.FromSeconds(10));

            while (await timer.WaitForNextTickAsync(stoppingToken))
            {
                IReadOnlyList<ImportItem> batch = await queue.DequeueBatchAsync(stoppingToken);
                ImportSummary summary = await runner.RunAsync(batch, stoppingToken);

                logger.LogInformation(
                    "Batch finished. Succeeded={Succeeded} Failed={Failed}",
                    summary.Succeeded,
                    summary.Failed.Count);
            }
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
            // בקשת עצירה היא מסלול תקין. מסתיים כאן בשקט.
        }
        catch (Exception ex)
        {
            // לולאת האב נשברה = חיי ה-worker כולו נשברו.
            // לא בולעים כדי ליצור מצב של "רק התהליך נשאר חי" - עוצרים ומעבירים להפעלה מחדש.
            logger.LogCritical(ex, "Worker loop failed unexpectedly. Stopping the host.");

            // StopApplication היא בקשה "לסגור כרגיל". רק זה לא מספיק, כי הצד המנטר
            // שמפעיל מחדש רק בכישלון (פעולת התאוששות של השירות, Restart=on-failure
            // של systemd, מדיניות restart של container) לא יבחין בין זה לבין
            // "העבודה הסתיימה בהצלחה", ו-worker לא יקום שוב.
            //
            // גם עצם קביעת Environment.ExitCode לא מספיקה. אם רצים כשירות Windows,
            // כשה-host נעצר כרגיל, ה-SCM מקבל SERVICE_STOPPED, ו-ExitCode לא
            // משתקף במצב הסיום של השירות, ולכן פעולת ההתאוששות לא תופעל.
            // מסיימים את התהליך עם קוד סיום שונה מ-0
            Environment.Exit(1);
        }
    }
}

השימוש ב-Environment.Exit(1) הוא הנקודה המרכזית כאן.IHostApplicationLifetime.StopApplication() היא בקשה לעצירה תקינה, ולכן כשמריצים כשירות Windows, ה-SCM מקבל SERVICE_STOPPED. ‏Environment.ExitCode של התהליך לא משתקף במצב הסיום של השירות, ולכן פעולת ההתאוששות שהוגדרה במאפייני השירות (“הפעל מחדש את השירות”) לא תופעל. גם המדריך הרשמי ל-worker service מציין שהברירת מחדל BackgroundServiceExceptionBehavior.StopHost “עוצרת בצורה נקייה”, ולכן ניהול השירות של Windows לא יפעיל מחדש; כדי שפעולת ההתאוששות תיכנס לפעולה צריך לקרוא ל-Environment.Exit עם קוד סיום שונה מ-0 (ראו את המקורות בפרק 11).

Environment.Exit מסיים את התהליך הנוכחי, ולכן כל לוג שרוצים להוציא לפני הנפילה צריך להיכתב לפני השורה הזו. אם משתמשים ב-logger עם buffering, צריך להוסיף flush. לעומת זאת, אם לא רצים כשירות Windows אלא רק מול systemd או container, אפשר לקבוע Environment.ExitCode ואז לסגור עם StopApplication() - גם כך הצד המנטר יזהה את זה ככישלון. הבחירה תלויה בסביבה שבה רצים.

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

תפקיד ה-catch בפנים ובחוץתרשים שמראה ש-catch בפנים הלולאה תפקידו לרשום יחידת כישלון, ו-catch בחוץ הלולאה תפקידו לסיים את חיי המערכת, וטעות בבלבול ביניהם מובילה או לנפילה בכישלון פריט בודד או להישארות התהליך בחיים אחרי מות ה-worker.catch בפנים הלולאהרישום יחידת כישלוןcatch בחוץ הלולאהסיום חיי המערכתבלבול = נפילה מכישלון פריט אחדבלבול = התהליך נשאר בחיים אחרי שה-worker מת

איור 14: תפקיד ה-catch שונה בין פנים הלולאה לחוץ שלה, ובלבול ביניהם גורם לתקלה בשני הכיוונים.

9.2 להפריד בין חריגה צפויה לבלתי צפויה

  • צפויה: validation, not found, timeout, cancel, הפרת חוק עסקי
  • בלתי צפויה: קריסת הנחות, דליפה מלולאת האב, חריגה בגבול הנייטיב, ריח של השחתת זיכרון

9.3 לצמצם את המצב המשותף

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

9.4 להעביר עיבוד מסוכן לתהליך נפרד

עבור COM / ActiveX / SDK ספק / unsafe / עיבוד תמונה כבד / שליטה בציוד חיצוני - כל דבר שרוצים למנוע שהנזק מקריסתו יתפשט - הפרדה לתהליך נפרד עוזרת מאוד.

9.5 handler לחריגה לא מטופלת - “תיעוד” ולא “החלמה”

  • מידע על החריגה
  • הקשר הפעולה
  • הלוגים החשובים שקדמו לכך
  • הגדרות / גרסה / יעד חיבור
  • מסלול לאיסוף dump

עדיף להעדיף גיבוש כזה של המידע, כדי לאפשר להתחקות אחרי הכישלון אחרי שהוא קרה - זה בדרך כלל מוביל ליציבות טובה יותר.

ב-WPF, ה-handler לתיעוד יכול להיראות בערך כך.

// App.xaml.cs ב-WPF (‏.NET 8). ה-handler משמש ל"תיעוד" ולא ל"החלמה".
using System;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using System.Windows;

namespace SampleApp;

public partial class App : Application
{
    private static readonly string CrashLogPath = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
        "SampleApp",
        "crash.log");

    protected override void OnStartup(StartupEventArgs e)
    {
        // רישום ה-handler-ים ממוקם לפני base.OnStartup(e).
        // base.OnStartup מפעיל את אירוע Startup, ולכן אם קורית חריגה בצד
        // המנוי לאירוע הזה, ה-handler-ים שנכתבו אחריו עדיין לא נרשמו -
        // ומקבלים את המצב הכי גרוע: "נפל בהפעלה, ואין אפילו שורת לוג אחת".
        // בגלל שרוצים לתעד דווקא כישלון של עיבוד ההפעלה עצמו, רושמים קודם.

        // חריגה שהגיעה לא מטופלת מה-UI thread
        DispatcherUnhandledException += (_, args) =>
        {
            Record("DispatcherUnhandledException", args.Exception);

            // args.Handled = true מאפשר להמשיך, אבל האם מותר להמשיך נקבע לפי תנאי פרק 5.
            // אם אין ודאות, עדיף לתעד ולתת להתנהגות ברירת המחדל (סיום) לפעול.
            args.Handled = false;
        };

        // ההודעה האחרונה, כולל threads שאינם ה-UI thread. כאן אי אפשר לעצור, לכן רק לתיעוד.
        AppDomain.CurrentDomain.UnhandledException += (_, args) =>
            Record("AppDomain.UnhandledException", args.ExceptionObject as Exception);

        // חריגה מ-Task שלא חיכו לו ונאספה
        TaskScheduler.UnobservedTaskException += (_, args) =>
        {
            Record("UnobservedTaskException", args.Exception);
            args.SetObserved();
        };

        // רק אחרי שכל הרישום הסתיים, ממשיכים לעיבוד ההפעלה הרגיל (הפעלת אירוע Startup)
        base.OnStartup(e);
    }

    private static void Record(string source, Exception? exception)
    {
        try
        {
            Directory.CreateDirectory(Path.GetDirectoryName(CrashLogPath)!);

            string version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
            string text = string.Join(
                Environment.NewLine,
                $"[{DateTimeOffset.Now:O}] {source}",
                $"version={version} os={Environment.OSVersion} user={Environment.UserName}",
                exception?.ToString() ?? "(no exception object)",
                string.Empty);

            File.AppendAllText(CrashLogPath, text);
        }
        catch
        {
            // כישלון בתיעוד לא עוצר את תהליך הסיום.
        }
    }
}

הנקודה החשובה היא לא לנסות לתקן את המצב בתוך ה-handler. מה שעושים כאן הוא רק להשאיר חומר שיאפשר לעקוב אחרי אותה תופעה בפעם הבאה.

הזרימה של handler ייעודי לתיעודתרשים שמראה שב-WPF רושמים את ה-handler-ים לפני base.OnStartup, ואז ממשיכים לעיבוד ההפעלה, ובתוך ה-handler עצמו רק מתעדים חריגה בלתי צפויה בלי לנסות לתקן את המצב.רישום ה-handler-ים קודםהמשך ל-base.OnStartupתיעוד חריגה בלתי מטופלתלא מנסים לתקן את המצב

איור 15: מסיימים את הרישום לפני עיבוד ההפעלה, וה-handler משמש רק לתיעוד.

9.6 לא לסמוך יתר על המידה על אירועי חריגה לא מטופלת ב-WPF /‏ WinForms

ב-WPF, קביעת Handled = true ב-DispatcherUnhandledException מאפשרת להמשיך גם אחרי חריגה לא מטופלת. גם ב-Windows Forms, ב-UI thread הראשי אפשר לבחור את דרך העצירה לפי Application.ThreadException וההגדרה של SetUnhandledExceptionMode.

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

9.7 ‏Environment.FailFast רק כש”לסדר בעצמו מסוכן יותר”

FailFast שמופיע בתרשים הזרימה של 3.1 הוא Environment.FailFast. ההתנהגות המתועדת רשמית היא זו:

  • מסיים את התהליך בלי להריץ try / finally שרצים כרגע, וגם לא finalizer
  • ב-Windows, כותב את ההודעה שמעבירים אל יומן האירועים של האפליקציה של Windows, יוצר dump של האפליקציה ורק אז מסיים
  • ההודעה ומידע החריגה נכללים גם בדיווח שגיאות ל-Microsoft דרך Windows Error Reporting
  • קריאה תחת debugger של Visual Studio מייצרת ExecutionEngineException ומפעילה את ה-managed debugging assistant של fatalExecutionEngineError

זה שלא מריצים finally הוא לא חיסרון אלא המטרה של ה-API הזה. אם מריצים קוד סידור אחרי שהמצב כבר שבור, יש סיכוי לכתוב את התוכן השבור ישירות לקובץ או ל-DB. גם בתיעוד הרשמי מוסבר: כשמצב האפליקציה שבור באופן בלתי הפיך, וביצוע try / finally או finalizer עלול לשבור משאבים, משתמשים ב-FailFast ולא ב-Environment.Exit.

חלוקת השימוש נראית כך.

מצב מה בוחרים
התנאי האינווריאנטי נשבר, והרצת קוד סידור מסוכנת יותר Environment.FailFast
המצב תקין, ורוצים לסדר לפני שמסיימים עיבוד סיום תקין (למשל IHostApplicationLifetime.StopApplication)
רוצים פשוט להחזיר קוד סיום ולסיים Environment.Exit או return מ-Main

בקוד, זה נראה כך:

// C# / .NET 8. מקום שבו התגלה שהתנאי האינווריאנטי של המצב המשותף נשבר.
// מכאן והלאה, אי אפשר לסמוך על אף פעולת סידור.
if (cache.Count != store.Count)
{
    Environment.FailFast(
        $"Invariant broken: cache={cache.Count} store={store.Count}",
        new InvalidOperationException("Cache and store are out of sync."));
}

מכיוון שה-dump נאסף אוטומטית, אם ההודעה שמעבירים ל-FailFast כוללת איזה תנאי אינווריאנטי נשבר, ובאילו ערכים, זה מקל מאוד על החקירה בהמשך. לעומת זאת, קריאה ל-FailFast בגלל כישלון צפוי, כמו טעות קלט או שגיאת תקשורת, היא מוגזמת. זה חוזר לדיון על יחידת הכישלון בסעיף 9.1.

למה FailFast לא מריץ עיבוד סידורתרשים שמראה שכשמצב שבור באופן בלתי הפיך והרצת קוד סידור עלולה לכתוב תוכן שבור, Environment.FailFast מסיים בלי להריץ try, finally או finalizer, ובוWindows משאיר dump ותיעוד ביומן אירועים.תנאי אינווריאנטי נשברסיום מיידי עם FailFastלא מריץ finally ולא finalizerמשאיר dump ותיעוד ביומן אירועים (Windows)מונע כתיבת תוכן שבור

איור 16: FailFast מונע כתיבת מצב שבור על ידי כך שהוא לא מריץ קוד סידור.

10. סיכום

מה שצריך לבדוק כשקורית חריגה בלתי צפויה הוא לא “אפשר לתפוס את החריגה הזו?” אלא עדיין אפשר לסמוך על מצב האפליקציה אחרי זה?

סדר ההחלטה נראה כך, וברוב המקרים זה מספיק.

  1. אפשר לזרוק את יחידת הכישלון?
  2. אפשר להחזיר את המצב המשותף, או לבנות אותו מחדש?
  3. אפשר להסביר את תופעת הלוואי החיצונית?
  4. אפשר לסמוך על שלמות הזיכרון / ה-threads / גבול הנייטיב?

אם יש ביטחון בארבעת הנקודות האלה - אפשר להמשיך. אם אין ביטחון - עדיף לנטות לכיוון סיום.

סדר ההחלטה בסיכוםתרשים שמראה בדיקה בסדר של אפשר לזרוק את יחידת הכישלון, אפשר להחזיר את המצב המשותף, אפשר להסביר את תופעת הלוואי, ואפשר לסמוך על שלמות המערכת כולל גבול הנייטיב, ואם יש ביטחון בכל ארבעתם ממשיכים, אחרת נוטים לסיום.ביטחון בכל 4אין ביטחוןאפשר לזרוק את יחידת הכישלון?אפשר להחזיר את המצב המשותף?אפשר להסביר את תופעת הלוואי?אפשר לסמוך על שלמות המערכת?אפשר להמשיךנטייה לסיום

איור 17: עונים על ארבע השאלות בסדר, וממשיכים רק אם יש ביטחון בכולן.

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

טיפול בחריגות הוא לא “טכניקה למניעת נפילה”. זה תכנון שמקטין את היקף השבירה, נופל בכנות כשמשהו נשבר, ומקל על ההתאוששות.

11. מקורות

מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.

העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.

המאמר קשור ישירות לשירותים הבאים.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

מותר לתפוס חריגה בלתי צפויה עם ‏catch (Exception), לרשום ללוג ולהמשיך?
רישום ללוג בלבד והמשך פעולה הוא בדרך כלל מסוכן. זה מסתיר את הסיבה האמיתית, וגם נוטה להאריך חיים למצב שכבר נשבר. מותר להמשיך רק כששלושת התנאים הבאים מתקיימים יחד: אפשר לזרוק את היחידה שנכשלה, אפשר להחזיר את המצב המשותף למצב תקין, ואפשר להסביר את תופעת הלוואי החיצונית. הציר של ההחלטה הוא לא "אפשר לתפוס את החריגה הזו?" אלא "עדיין אפשר לסמוך על מצב האפליקציה אחרי זה?".
אילו חריגות מחייבות סיום מיידי?
‏StackOverflowException,‏ AccessViolationException, ו-OutOfMemoryException חמור - חריגות שמעוררות חשד לגבי שלמות התהליך כולו - עדיף לא לטפל בהן מתוך הנחה שאפשר להמשיך. ב-StackOverflowException מחסנית הקריאות קרסה, וב-AccessViolationException מדובר בגישה לא חוקית לזיכרון מוגן שמעוררת חשד להשחתת זיכרון. גם חריגות שמקורן ב-callback של COM,‏ P/Invoke או SDK של ספק חיצוני קשה לשפוט את בטיחותן רק מצד ה-managed, ולכן גם הן נוטות לכיוון סיום נחרץ.
באילו תנאים מותר להמשיך להפעיל את האפליקציה גם אחרי חריגה?
בדרך כלל צריך שיתקיימו יחד: יחידת כישלון ברורה (פעולה אחת, מסך אחד, job אחד, חיבור אחד וכדומה - כלומר ברור מה זורקים), אפשר לזרוק את המצב ולבנות אותו מחדש, אין הדבקה שמתפשטת למצב המשותף, אפשר להסביר את תופעת הלוואי החיצונית, אפשר להודיע למשתמש בכנות ש"הפעולה הזו נכשלה", ואפשר לחקור בדיעבד בעזרת לוגים ומדדים. כשגבול העיבוד ברור, כמו פעולה אחת בממשק המשתמש או job קליטה אחד, לפעמים אפשר להמשיך. לעומת זאת, חריגה באמצע עדכון מצב משותף, בלולאת האב, בעיבוד ההפעלה או בגבול הנייטיב נוטה לכיוון סיום.
אם מגדירים Handled=true ב-DispatcherUnhandledException של WPF, אפשר להמשיך?
אפשר טכנית להמשיך גם אחרי חריגה לא מטופלת, אבל האפשרות להמשיך והבטיחות שבהמשך הן שני דברים שונים. handlers כמו AppDomain.UnhandledException או DispatcherUnhandledException שימושיים כמקום תיעוד אחרון, אבל הם לא נקודת החלמה קסומה. עדיף להעדיף איסוף מידע על החריגה, הקשר הפעולה ומסלול לאיסוף dump כך שאפשר לחקור אחרי הקריסה - זה בדרך כלל מוביל ליציבות טובה יותר. בפרט בשירותים ואפליקציות ניטור שרצים לאורך זמן, קריסה עם הפעלה מחדש בדרך כלל בטוחה וקלה יותר לאבחון מאשר הישרדות במצב חצי-שבור.

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

מתמחה בפיתוח תוכנה עבור Windows, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

קישורים ציבוריים

חזרה לבלוג