טבלת החלטה: לסיים או להמשיך אחרי חריגה בלתי צפויה
· עודכן בתאריך: · Go Komura · פיתוח Windows, טיפול בחריגות, תכנון, C# / .NET, אמינות
הורידו את רשימת הבדיקה ב-Excel עם גיליון עברי ואנגלי
הקובץ הזה בנוי משני גיליונות, Checklist-ja ו-Checklist-en, ומפרק את הציר של המאמר הזה ל-4 קטגוריות - תנאי המשך / תנאי סיום / כיוון מימוש / סדר ההחלטה - ב-27 סעיפים. העמודות Status ו-Notes נשארות ריקות, כך שאפשר להשתמש בקובץ ישירות כטבלת בדיקה לתיעוד טיפול בתקלות או לסקירת תכנון.
כשמדברים על חריגה בלתי צפויה, נוטים לחשוב במונחים של שתי אפשרויות: להפיל את האפליקציה, או לתפוס אותה ב-catch ולהמשיך. אבל בפועל, החלוקה הבינארית הזו קצת גסה מדי.
מה שבאמת רוצים לבדוק הוא האם אפשר לכלוא את הטווח שאולי נשבר.
- אפשר לסיים רק את הפעולה הזו בכישלון?
- מספיק לאתחל מחדש רק את המסך / החיבור / ה-worker הזה?
- או שכבר יש חשד לגבי העקביות של התהליך כולו?
כשמסתכלים בסדר הזה, קל בהרבה לסדר את המחשבה.
flowchart TB
accTitle: הסדר לבדיקת הטווח שנשבר
accDescr: תרשים שמראה שכשמתרחשת חריגה בלתי צפויה, בודקים בסדר הזה - האם אפשר לסיים רק את הפעולה הזו בכישלון, האם מספיק לאתחל מחדש רק את תת-המערכת, ואם יש חשד לגבי התהליך כולו.
q1["אפשר להפיל רק את הפעולה?"] --> q2["לאתחל רק מסך או חיבור?"]
q2 --> q3["יש חשד לגבי כל התהליך?"]
איור 1: בודקים את הטווח שאולי נשבר בסדר של פעולה, תת-מערכת, ואז התהליך כולו.
המאמר הזה מניח בסיס של אפליקציות Windows, C# / .NET, אפליקציות שרצות ברקע, שירותי Windows וכלי חיבור לציוד, ומרכז כטבלת החלטה את התנאים שבהם מותר להמשיך אחרי חריגה בלתי צפויה, ואת התנאים שבהם עדיף לסיים.
1. קודם כל - המסקנה
-
catch (Exception)שרק בולע את החריגה וממשיך הוא בדרך כלל מסוכן. - מותר להמשיך רק כששלושת התנאים מתקיימים: אפשר לזרוק את היחידה שנכשלה, אפשר להחזיר את המצב המשותף למצב תקין, אפשר להסביר את תופעת הלוואי החיצונית.
- כשגבול העיבוד ברור - פעולה אחת בממשק, קלט אחד, job אחד - לפעמים אפשר להמשיך.
- לעומת זאת, כשמעורב מצב משותף שניתן לכתיבה, לולאה שרצה ברקע, ה-thread הראשי, עיבוד ההפעלה, או ריח של השחתת זיכרון בגבול הנייטיב - עדיף לכיוון סיום.
- חריגות כמו
StackOverflowException,AccessViolationExceptionו-OutOfMemoryExceptionשמעוררות חשד לגבי “שלמות התהליך כולו” עדיף לא לטפל בהן מתוך הנחה שאפשר להמשיך. - ל-WPF ול-Windows Forms יש דרך לתפוס חריגה לא מטופלת ולהיראות כאילו ממשיכים, אבל האפשרות להמשיך ו-הבטיחות שבהמשך הם שני דברים שונים.
- אפליקציות ושירותים שרצים לאורך זמן - קריסה עם הפעלה מחדש בדרך כלל בטוחה וקלה יותר לאבחון מאשר הישרדות במצב חצי-שבור.
בקיצור, ציר ההחלטה הוא האם אפשר להשיב את התנאי האינווריאנטי.
flowchart TB
accTitle: שלושה תנאים למותר להמשיך
accDescr: תרשים שמראה שמותר להמשיך רק כששלושת התנאים - אפשר לזרוק את היחידה שנכשלה, אפשר להחזיר את המצב המשותף, ואפשר להסביר את תופעת הלוואי - מתקיימים יחד, וציר ההחלטה הוא האם אפשר להשיב את התנאי האינווריאנטי.
c1["אפשר לזרוק את יחידת הכישלון"] --> ok3["3 תנאים יחד = מותר להמשיך"]
c2["אפשר להחזיר את המצב המשותף"] --> ok3
c3["אפשר להסביר את תופעת הלוואי"] --> ok3
ok3 -.-> jiku["הציר הוא: אפשר להשיב את התנאי האינווריאנטי?"]
איור 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 משמשים לתיעוד ולא כאמצעי החלמה למצב.
flowchart LR
accTitle: מפת הידע של המאמר: לסיים או להמשיך אחרי חריגה בלתי צפויה
accDescr: תרשים שמראה שההחלטה בין המשך לסיום אחרי חריגה בלתי צפויה מניחה בדיקת התנאי האינווריאנטי, יחידת הכישלון ותופעת הלוואי החיצונית, שחריגות עם סימני השחתת זיכרון או תקלה בגבול הנייטיב נוטות לכיוון סיום, ש-BackgroundService שנופל מחריגה בלתי צפויה חייב Environment.Exit כדי שפעולת ההתאוששות של שירות Windows תיכנס לפעולה, ושה-handler-ים לחריגה לא מטופלת משמשים לתיעוד ולא להחלמה.
unexpected_exception["חריגה בלתי צפויה"]
invariant["תנאי אינווריאנטי"]
continue_processing["המשך העיבוד"]
failure_unit["יחידת הכשל"]
external_side_effect["תופעת לוואי חיצונית"]
catch_exception_antipattern["בליעת חריגות ב-catch (Exception)"]
zombie_process["הפיכה לתהליך זומבי"]
terminate_process["סיום התהליך"]
stackoverflowexception["StackOverflowException"]
accessviolationexception["AccessViolationException"]
environment_failfast["Environment.FailFast"]
outofmemoryexception["OutOfMemoryException"]
memory_corruption_symptom["סימני השחתת זיכרון"]
environment_exit["Environment.Exit"]
native_interop_boundary["גבול הנייטיב"]
backgroundservice["BackgroundService"]
stopapplication["IHostApplicationLifetime.StopApplication"]
windows_service["שירות Windows"]
unhandled_exception_handler["מטפל בחריגות שלא נתפסו"]
exception_recovery_attempt["ניסיון לשחזר את המצב אחרי חריגה"]
continue_processing -->|"מחייב"| invariant
continue_processing -->|"מחייב"| failure_unit
continue_processing -->|"מחייב"| external_side_effect
catch_exception_antipattern -.->|"עלול לגרום ל"| zombie_process
catch_exception_antipattern -->|"שימוש לא מומלץ ל"| unexpected_exception
terminate_process -->|"מענה מומלץ ל"| stackoverflowexception
terminate_process -->|"מענה מומלץ ל"| accessviolationexception
environment_failfast -->|"מענה מומלץ ל"| outofmemoryexception
accessviolationexception -.->|"עלול לגרום ל"| memory_corruption_symptom
environment_failfast -->|"מענה מומלץ ל"| memory_corruption_symptom
environment_failfast -->|"מממש את"| terminate_process
environment_exit -->|"מממש את"| terminate_process
native_interop_boundary -.->|"עלול לגרום ל"| memory_corruption_symptom
terminate_process -.->|"מענה מומלץ ל"| native_interop_boundary
backgroundservice -.->|"מחייב"| environment_exit
stopapplication -.->|"שימוש לא מומלץ ל"| windows_service
backgroundservice -->|"מממש את"| windows_service
unhandled_exception_handler -->|"שימוש לא מומלץ ל"| exception_recovery_attempt
catch_exception_antipattern -.->|"אינו מתיישב עם"| backgroundservice
environment_failfast -.->|"מונע"| external_side_effect
unexpected_exception -->|"נבדק באמצעות"| unhandled_exception_handler
בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 21, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle
2. מה נחשב “חריגה בלתי צפויה” במאמר הזה
2.1 ההבדל בין צפוי לבלתי צפוי
קודם כל, חריגה נדירה וחריגה בלתי צפויה הם לא אותו דבר.
למשל, הדברים הבאים אפשר להחשיב כצפויים גם אם התדירות שלהם נמוכה.
- המשתמש בחר קובץ שלא קיים
- צד השרת עבר timeout זמני
- שורה אחת ב-CSV שנקלט הייתה פגומה
- פעולת ביטול הצמיחה
OperationCanceledException - הפרת חוק עסקי שרוצים להכשיל רק בגללה את הפעולה הזו
אלה מהסוג שבו אפשר להחליט מראש בתכנון איך לטפל בכישלון.
לעומת זאת, החריגות הבלתי צפויות שהמאמר הזה עוסק בהן בעיקר הן כאלה:
- ההנחות של הקוד שלכם נשברו וקיבלתם
NullReferenceExceptionאוInvalidOperationException - חריגה נזרקה באמצע עדכון מצב משותף, ולא ברור עד כמה השינוי כבר השתקף
- לולאת האב של לולאת ניטור או עיבוד הודעות קרסה
- חריגה בגבול COM / P/Invoke / SDK של ספק חיצוני
- מצבים כמו
AccessViolationExceptionאוStackOverflowException, שבהם “בדיקת הבריאות” של התהליך עצמה כבר אדומה
במילים אחרות, אלה מקרים שבהם “לא ברור אם עדיין אפשר לסמוך על מצב האפליקציה אחרי החריגה הזו”.
flowchart TB
accTitle: הגבול בין צפוי לבלתי צפוי
accDescr: תרשים שמראה שחריגה שאפשר להחליט מראש בתכנון איך לטפל בה נחשבת צפויה גם אם התדירות נמוכה, וחריגה שלא ברור אחריה אם אפשר לסמוך על מצב האפליקציה נחשבת בלתי צפויה במאמר הזה.
e1["חריגה התרחשה"] --> q1{"אפשר להחליט מראש איך מטפלים?"}
q1 -->|"אפשר"| soutei["צפויה (גם אם נדירה)"]
q1 -->|"אי אפשר"| sotogai["בלתי צפויה (לא ברור אם אפשר לסמוך על המצב)"]
איור 3: חריגה נדירה וחריגה בלתי צפויה הן דברים שונים, וההבדל הוא אם אפשר להחליט מראש איך מטפלים.
2.2 נראה כמו שתי אפשרויות, אבל בעצם יש שלוש
מה שמסבך את הדיון הזה הוא ההתייחסות ל”המשך” כאל סוג אחד.
בפועל, בדרך כלל מתחלקים ל-3 שלבים.
| בחירה | משמעות |
|---|---|
| להכשיל רק את הפעולה ולהמשיך | המסך נשאר, אבל רק השמירה או הקליטה הנוכחית מסומנת ככישלון |
| לעצור רק את תת-המערכת ולהמשיך | מאתחלים מחדש רק את החיבור, המסך, ה-worker או תהליך-הבן |
| לסיים את התהליך | הטווח של השחתת המצב לא ברור, ולכן מניחים הפעלה מחדש |
“להמשיך להפעיל את האפליקציה” יכול להשמע כמו דבר אחד, אבל יש הבדל משמעותי בין להמשיך כאילו כלום לא קרה לבין לבודד את החלק שנשבר ואז להמשיך.
flowchart TB
accTitle: אותו המשך, משקל שונה
accDescr: תרשים שמראה שלמרות שנראה כמו "להמשיך להפעיל את האפליקציה", יש הבדל משמעותי בין המשך כאילו כלום לא קרה לבין המשך שבו בודדים את החלק שנשבר.
keizoku["להמשיך להפעיל את האפליקציה"] --> k1["להמשיך כאילו כלום לא קרה"]
keizoku --> k2["לבודד את מה שנשבר ואז להמשיך"]
k1 -.-> omomi["אותו המשך, משקל שונה"]
k2 -.-> omomi
איור 4: לא כדאי לחשוב על “המשך” כסוג אחד - יש להבחין בין המשך שמבודד את החלק השבור.
3. טבלת ההחלטה שכדאי לבדוק ראשונה
3.1 מבט כללי
אם מתחילים מהטבלה הזו, קל להגיע לכיוון מדיניות כללי.
| מצב | הבחירה הראשונית | הסיבה |
|---|---|---|
| קלט אחד, פעולת מסך אחת, או job אחד נכשל, ואפשר לזרוק את המצב | נטייה להמשיך | אפשר לכלוא את יחידת הכישלון |
| אחרי החריגה אפשר לזרוק את האובייקט או החיבור ולבנות אותם מחדש | נטייה לאתחל מחדש תת-מערכת | אפשר להגביל את הטווח שנשבר לאזור מקומי |
| מצב משותף עודכן חלקית ולא ברור עד כמה השינוי השתקף | נטייה לסיום | ייתכן שהתנאי האינווריאנטי נשבר |
| תופעת לוואי חיצונית - DB / קובץ / פקודה לציוד - נשארה באמצע, ואי אפשר להסביר כפילות או אי-שיקוף | נטייה לסיום | לא ברור מה מצב העקביות מול העולם החיצוני |
| לולאת ניטור, לולאת חיבור מחדש או לולאת האב של עיבוד הודעות קרסו מחריגה בלתי צפויה | נטייה לסיום | נטייה להפוך ל”זומבי” כשרק חלק מהתפקוד מת בשקט |
| כישלון בעיבוד הפעלה, טעינת הגדרות, בניית DI, או אתחול תלות חובה | נטייה לסיום עם כישלון הפעלה | הפעלה חלקית מסוכנת יותר |
AccessViolationException, StackOverflowException, OutOfMemoryException חמור, או ריח של השחתה בצד הנייטיב |
נטייה לסיום מיידי | חשד לגבי שלמות התהליך כולו |
| עיבוד מסוכן מבודד בתהליך נפרד, ותהליך האב נשאר שלם | תהליך האב ממשיך, תהליך הבן מופעל מחדש | אפשר לבודד את אזור התקלה |
flowchart TD
accTitle: הזרימה המלאה לבחירת המשך או סיום
accDescr: תרשים שמראה בדיקה בסדר של ריח השחתת זיכרון, יחידת כישלון, מצב משותף ותופעת לוואי חיצונית, ולפי זה קביעת סיום מיידי, נטייה לסיום, עצירת תת-מערכת, או המשך עם הפעולה בלבד כנכשלת.
A["חריגה בלתי צפויה"] --> B{"ריח של השחתת זיכרון / דלדול מחסנית / דלדול משאבים קריטי?"}
B -- "כן" --> Z["סיום / FailFast / הפעלה מחדש"]
B -- "לא" --> C{"אפשר לזרוק את יחידת הכישלון?"}
C -- "לא" --> Y["נטייה לסיום"]
C -- "כן" --> D{"אפשר לשחזר או לאתחל מחדש את המצב המשותף?"}
D -- "לא" --> X["עצירת תת-מערכת או סיום"]
D -- "כן" --> E{"אפשר להסביר את תופעת הלוואי החיצונית?"}
E -- "לא" --> X
E -- "כן" --> W["המשך עם הפעולה הזו בלבד כנכשלת"]
איור 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 והמשך פעולה לא באמת עוזרים.
flowchart TB
accTitle: הפער במקום הקריסה בהשחתת heap
accDescr: תרשים שמראה שבהשחתת heap הקריסה לא קורית ברגע ההשחתה, אלא בצד שנוגע ב-heap השבור בפעם הבאה, ולכן מקום הקריסה לא בהכרח האשם.
h1["ה-heap נשבר במקום כלשהו"] --> h2["לא קורסים באותו רגע"]
h2 --> h3["קורסים בצד שנוגע בו בפעם הבאה"]
h3 -.-> h4["מקום הקריסה לא בהכרח האשם"]
איור 6: השחתת heap גורמת לקריסה בצד שנוגע בה בפעם הבאה, כך שיש פער בין מקום הקריסה למקום ההשחתה.
עכשיו, תסמינים שמעוררים חשד להיפוך לזומבי.
| מה רואים | איפה רואים את זה |
|---|---|
| התהליך חי, אבל זמן העיבוד האחרון לא מתעדכן | לוג של זמן עיבוד אחרון, heartbeat |
| רק מספר הפריטים הממתינים בתור או בתיקיית הקליטה ממשיך לגדול | אורך התור, מספר קבצים שלא טופלו |
| הלוג פשוט נעצר מנקודת זמן מסוימת | לוג האפליקציה |
| המסך פועל אך העדכון מאחורי הקלעים נעצר | השוואה בין תצוגת המסך לנתונים בפועל |
| מספר ה-worker threads נמוך מהצפוי | לוג אבחון, רשימת threads ב-Process Explorer |
היפוך לזומבי הוא לא “התהליך קרס” אלא “התהליך חי אך לא עושה עבודה”. אם מכינים מראש מדדים שמראים “שהעבודה מתבצעת” - זמן עיבוד אחרון, מספר פריטים ממתינים, heartbeat - גם ההחלטה בין המשך לסיום וגם החקירה בדיעבד נעשות הרבה יותר קלות.
flowchart TB
accTitle: מדדים לזיהוי היפוך לזומבי
accDescr: תרשים שמראה שהיפוך לזומבי הוא מצב שבו התהליך חי אך לא עושה עבודה, ושהכנת מדדים כמו זמן עיבוד אחרון, מספר פריטים ממתינים ו-heartbeat מקלה גם על ההחלטה וגם על החקירה בדיעבד.
z1["זמן עיבוד אחרון"] --> z4["הכנת מדד שמראה שהעבודה מתבצעת"]
z2["מספר פריטים ממתינים"] --> z4
z3["heartbeat"] --> z4
z4 --> z5["מקלה על ההחלטה: המשך או סיום"]
z4 --> z6["מקלה על החקירה בדיעבד"]
איור 7: זיהוי היפוך לזומבי לא נעשה לפי “לא קרס” אלא לפי מדד שמראה שהעבודה מתבצעת.
4. ההחלטה לפי המקום שבו זה קרה
4.1 אירועי ממשק המשתמש
אירועי UI כמו לחיצת כפתור, מעבר בין מסכים, חיפוש או בחירת קובץ - יש בהם יחסית הרבה מרחב להמשך. אבל יש תנאים.
קל להמשיך במקרים כאלה:
- הכישלון קרה לפני הטעינה, ומצב העסק עוד לא נגע בו
- רק מצב זמני בתוך דיאלוג נשבר, ואפשר לזרוק אותו בסגירת המסך
- אפשר לבנות מחדש את ה-ViewModel או החיבור אחרי החריגה
- אפשר להודיע למשתמש בכנות ש”הפעולה הזו נכשלה”
לעומת זאת, במצבים הבאים כדאי לנטות לכיוון סיום:
- גם המסך וגם מצב הדומיין עודכנו חלקית
- נגעו במצב משותף שגם מסכים אחרים רואים - static / singleton / cache
- אחרי החריגה נשארו רק מצב הכפתור הפעיל או הבחירה, ולא ברור מה מצב העקביות
- חריגה בלתי צפויה קרתה ב-UI thread, ולא ברור עד כמה הרינדור או ההודעה כבר התקדמו
flowchart TB
accTitle: הגבול בחריגות בפעולות UI
accDescr: תרשים שמראה שחריגה באירוע UI שנוגעת רק במצב זמני שאפשר לזרוק היא קלה יותר להמשך, ואם נגעה במצב משותף שמסכים אחרים רואים או בעדכון חלקי, נוטה לכיוון סיום.
u1["חריגה בלתי צפויה בפעולת UI"] --> q1{"באיזה מצב נגעו?"}
q1 -->|"רק מצב זמני שאפשר לזרוק"| u2["המשך עם הפעולה הזו בלבד כנכשלת"]
q1 -->|"מצב משותף או עדכון חלקי"| u3["נטייה לסיום"]
איור 8: לאירועי UI יש מרחב גדול להמשך, אבל אם נגעו במצב משותף התמונה משתנה.
4.2 Job / בקשה שמעובדים אחד-אחד
זה גבול שקל יחסית להמשיך ממנו.
- הודעה אחת
- קובץ אחד
- בקשת HTTP אחת
- job קליטה אחד
- פריט batch אחד
כשיחידה כזו ברורה, אפשר להכשיל רק את הפריט הזה ולהמשיך לפריט הבא.
אבל יש תנאי מוקדם.
- יחידת הכישלון ברורה מבחוץ
- שינוי חלקי מתעדכן כראוי בעזרת טרנזקציה או עיבוד פיצוי
- יש תכונת “בטוח להריץ שוב” - הרצה חוזרת של אותו עיבוד לא שוברת את התוצאה
- אפשר להעביר את הכישלון לתור בידוד או ללוג שגיאות
4.3 לולאה שרצה ברקע / ניטור / עיבוד תור
זה המקום שבו המשך רשלני מסוכן ביותר.
לדוגמה:
- לולאת חיבור מחדש
- לולאת ניטור
- לולאת צריכת תור
- polling תקופתי
- ניטור מצב ציוד
- עיבוד שרץ ברקע באפליקציית tray
מה שמפחיד בסוג העיבוד הזה הוא שלולאת האב מתה מחריגה בלתי צפויה אחת, והתהליך נשאר בחיים לבדו.
כאן כדאי לחלק את המדיניות.
- בגבול העיבוד של כל פריט - לתפוס חריגות צפויות
- בלולאת האב - אם דלפה חריגה בלתי צפויה, לנטות לכיוון סיום התהליך
flowchart TB
accTitle: חלוקת המדיניות בלולאה שרצה ברקע
accDescr: תרשים שמראה שבגבול העיבוד של כל פריט תופסים חריגות צפויות, ואם דלפה חריגה בלתי צפויה מלולאת האב נוטים לכיוון סיום התהליך, כדי למנוע מהתהליך להישאר לבדו בחיים.
loop1["לולאה שרצה ברקע"] --> b1["גבול עיבוד של כל פריט"]
loop1 --> b2["לולאת האב"]
b1 --> r1["תופסים חריגה צפויה"]
b2 --> r2["חריגה בלתי צפויה דולפת = נטייה לסיום"]
r2 -.-> zb["מונע מהתהליך להישאר לבדו בחיים"]
איור 9: מחלקים מדיניות בין גבול הפריט ללולאת האב, וחריגה בלתי צפויה בלולאת האב מובילה לסיום.
4.4 עיבוד הפעלה
אם מתייחסים לכישלון בהפעלה כ”נעלה קודם ואחר כך נחשוב”, האפליקציה תמשיך לפעול כשחלק מהפונקציונליות חסר, ובהמשך יהיה קשה מאוד לבודד את הסיבה.
- לא ניתן לקרוא הגדרה חובה
- מעבר גרסה / migration נכשל
- תיקייה חובה או תעודה חסרים
- אתחול שירות ליבה נכשל
- הרכב התלות שבור
במקרים כאלה, ברור יותר לסיים את התהליך כתוצאה מכישלון הפעלה.
4.5 גבול הנייטיב / COM / P/Invoke / unsafe
כאן כדאי להתייחס בחומרה מעט יותר גדולה, כקטגוריה נפרדת.
- COM
- P/Invoke
- מעבר דרך C++/CLI
- SDK של ספק חיצוני
- קוד נייטיב שחוזר דרך callback
- עיבוד שכולל
unsafe
בפרט, אם רואים את אלה כדאי לנטות לכיוון סיום.
AccessViolationException- תסמין שמעורר חשד להשחתת heap או double free
- תקלת handle, או ריח של גישה אחרי שחרור
- קריסה פתאומית בגבול callback
flowchart TB
accTitle: הטיפול בחריגה בגבול הנייטיב
accDescr: תרשים שמראה שבגבול הנייטיב, כמו COM ו-P/Invoke, אם רואים AccessViolationException, תסמין שמעורר חשד להשחתת heap, או קריסה פתאומית בגבול callback, נוטים לכיוון סיום, ושגבול זה עצמו נבדק בחומרה גבוהה יותר.
n4["AccessViolationException"] --> n3["נטייה לסיום"]
n5["תסמין של השחתת heap או double free"] --> n3
n6["קריסה פתאומית בגבול callback"] --> n3
n3 -.-> n2["גבול הנייטיב נבדק בקטגוריה נפרדת ובחומרה"]
איור 10: כשרואים תסמינים כאלה בגבול הנייטיב, נוטשים את הנחת ההמשך ופונים לכיוון סיום.
5. תנאים שמתחתם מותר להמשיך
התנאים שמותר להמשיך תחתם, מסודרים ביחד, נראים כך. בדרך כלל הם צריכים להתקיים כמעט כולם יחד.
| תנאי | משמעות |
|---|---|
| יחידת כישלון ברורה | ברור מה זורקים - פעולה אחת, מסך אחד, job אחד, חיבור אחד |
| אפשר לזרוק את המצב | אפשר לבנות אותו מחדש, או להתייחס אליו כלא-משוקף |
| המצב המשותף מוגן | אין התפשטות של הדבקה לפונקציונליות אחרת |
| אפשר להסביר את תופעת הלוואי החיצונית | ברור אם נשלח / לא נשלח / מותר לשלוח שוב |
| אפשר להודיע למשתמש בכנות | אפשר להציג “הפעולה הזו נכשלה” |
| אפשר לנטר | אפשר לחקור בדיעבד עם לוגים, מדדים ו-dump |
6. תנאים שמתחתם עדיף לסיים
לעומת זאת, כשמתקיים אחד מהתנאים הבאים, עדיף לנטות לכיוון סיום.
- לא ברור מה השתנה עד כה
- נגעו במצב משותף שניתן לכתיבה, ולא ברור מה מצב העקביות
- ניהול חיי הנעילה, התור, ה-thread או לולאת הניטור נשבר
- לא ניתן להסביר כפילות / חסר / חלקיות בתופעת לוואי חיצונית
- כישלון בעיבוד הפעלה או באתחול תשתית ליבה
- חשד להשחתת זיכרון או תקלה בגבול הנייטיב
ברמה כזו, מאמץ לשמר המשך נקי פחות אפקטיבי מ-מאמץ להפיל ולהקל על ההתאוששות.
flowchart TB
accTitle: המאמץ שמועיל בנטייה לסיום
accDescr: תרשים שמראה שברמה שמתאימה לתנאי הסיום, מאמץ לשמר המשך נקי פחות מועיל מהשקעה במאמץ להפיל ולהקל על ההתאוששות.
j1["מתקיים תנאי הנוטה לסיום"] --> j2["מאמץ לשמר המשך נקי"]
j1 --> j3["מאמץ להפיל ולהקל על ההתאוששות"]
j2 -.-> j4["פחות אפקטיבי ברמה הזו"]
j3 -.-> j5["זה יותר אפקטיבי"]
איור 11: במצב שנוטה לסיום, כדאי להשקיע בקלות התאוששות ולא במאמץ להמשיך בצורה נקייה.
7. המלצות לפי דפוסים אופייניים
טבלת ההחלטה בסעיף 3.1 כתובה בצורת תנאים, ולכן לפעמים קשה להתאים אותה למקרה קונקרטי. הטבלה הזו לוקחת את אותם תנאים ומתאימה אותם למצבים שנתקלים בהם בפועל. כשמתלבטים, הכי מהיר לבדוק קודם את התנאים בסעיף 3.1, ואז לחפש את השורה הקרובה ביותר בטבלה הזו.
flowchart TB
accTitle: איך משתמשים בטבלה כשמתלבטים
accDescr: תרשים שמראה שכשמתלבטים איך להתאים את המצב, קודם בודקים את התנאים בטבלת ההחלטה בסעיף 3.1, ואז מחפשים בטבלת הפרק הזה את השורה הקרובה ביותר למצב.
m1["התלבטות בהתאמה למצב"] --> m2["בדיקת תנאים בטבלה של 3.1"]
m2 --> m3["חיפוש שורה קרובה בטבלת הפרק הזה"]
איור 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 - בלי שיש בטיחות להרצה חוזרת, התאונה של הרצה כפולה תופסת את המרכז.
flowchart TB
accTitle: retry קליל שמוביל להרצה כפולה
accDescr: תרשים שמראה שכשמריצים retry על עיבוד עם תופעת לוואי חיצונית בלי בטיחות להרצה חוזרת, תאונת ההרצה הכפולה תופסת את המרכז.
y1["עיבוד עם תופעת לוואי חיצונית נכשל"] --> y2["retry בלי בטיחות להרצה חוזרת"]
y2 --> y3["תאונת הרצה כפולה תופסת את המרכז"]
y1 -.-> y4["למשל פקודה לציוד, חיוב, שליחה"]
איור 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 יכול למות בזמן שהתהליך נשאר בחיים.
flowchart TB
accTitle: תפקיד ה-catch בפנים ובחוץ
accDescr: תרשים שמראה ש-catch בפנים הלולאה תפקידו לרשום יחידת כישלון, ו-catch בחוץ הלולאה תפקידו לסיים את חיי המערכת, וטעות בבלבול ביניהם מובילה או לנפילה בכישלון פריט בודד או להישארות התהליך בחיים אחרי מות ה-worker.
c1["catch בפנים הלולאה"] --> c2["רישום יחידת כישלון"]
c3["catch בחוץ הלולאה"] --> c4["סיום חיי המערכת"]
c2 -.-> ng1["בלבול = נפילה מכישלון פריט אחד"]
c4 -.-> ng2["בלבול = התהליך נשאר בחיים אחרי שה-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. מה שעושים כאן הוא רק להשאיר חומר שיאפשר לעקוב אחרי אותה תופעה בפעם הבאה.
flowchart TB
accTitle: הזרימה של handler ייעודי לתיעוד
accDescr: תרשים שמראה שב-WPF רושמים את ה-handler-ים לפני base.OnStartup, ואז ממשיכים לעיבוד ההפעלה, ובתוך ה-handler עצמו רק מתעדים חריגה בלתי צפויה בלי לנסות לתקן את המצב.
w1["רישום ה-handler-ים קודם"] --> w2["המשך ל-base.OnStartup"]
w2 --> w3["תיעוד חריגה בלתי מטופלת"]
w3 -.-> w4["לא מנסים לתקן את המצב"]
איור 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.
flowchart TB
accTitle: למה FailFast לא מריץ עיבוד סידור
accDescr: תרשים שמראה שכשמצב שבור באופן בלתי הפיך והרצת קוד סידור עלולה לכתוב תוכן שבור, Environment.FailFast מסיים בלי להריץ try, finally או finalizer, ובוWindows משאיר dump ותיעוד ביומן אירועים.
f1["תנאי אינווריאנטי נשבר"] --> f2["סיום מיידי עם FailFast"]
f2 --> f3["לא מריץ finally ולא finalizer"]
f2 --> f4["משאיר dump ותיעוד ביומן אירועים (Windows)"]
f3 -.-> f5["מונע כתיבת תוכן שבור"]
איור 16: FailFast מונע כתיבת מצב שבור על ידי כך שהוא לא מריץ קוד סידור.
10. סיכום
מה שצריך לבדוק כשקורית חריגה בלתי צפויה הוא לא “אפשר לתפוס את החריגה הזו?” אלא עדיין אפשר לסמוך על מצב האפליקציה אחרי זה?
סדר ההחלטה נראה כך, וברוב המקרים זה מספיק.
- אפשר לזרוק את יחידת הכישלון?
- אפשר להחזיר את המצב המשותף, או לבנות אותו מחדש?
- אפשר להסביר את תופעת הלוואי החיצונית?
- אפשר לסמוך על שלמות הזיכרון / ה-threads / גבול הנייטיב?
אם יש ביטחון בארבעת הנקודות האלה - אפשר להמשיך. אם אין ביטחון - עדיף לנטות לכיוון סיום.
flowchart TB
accTitle: סדר ההחלטה בסיכום
accDescr: תרשים שמראה בדיקה בסדר של אפשר לזרוק את יחידת הכישלון, אפשר להחזיר את המצב המשותף, אפשר להסביר את תופעת הלוואי, ואפשר לסמוך על שלמות המערכת כולל גבול הנייטיב, ואם יש ביטחון בכל ארבעתם ממשיכים, אחרת נוטים לסיום.
g1["אפשר לזרוק את יחידת הכישלון?"] --> g2["אפשר להחזיר את המצב המשותף?"]
g2 --> g3["אפשר להסביר את תופעת הלוואי?"]
g3 --> g4["אפשר לסמוך על שלמות המערכת?"]
g4 -->|"ביטחון בכל 4"| g5["אפשר להמשיך"]
g4 -->|"אין ביטחון"| g6["נטייה לסיום"]
איור 17: עונים על ארבע השאלות בסדר, וממשיכים רק אם יש ביטחון בכולן.
בפרט באפליקציות, אפליקציות ניטור, שירותים וכלי חיבור לציוד שרצים לאורך זמן, יש הרבה מצבים שבהם להישאר בחיים במצב שבור מסוכן יותר מ-ליפול בפשטות.
טיפול בחריגות הוא לא “טכניקה למניעת נפילה”. זה תכנון שמקטין את היקף השבירה, נופל בכנות כשמשהו נשבר, ומקל על ההתאוששות.
11. מקורות
- .NET: Best practices for exceptions
- .NET: Create a Windows Service using BackgroundService (הברירת מחדל
BackgroundServiceExceptionBehavior.StopHostעוצרת את ה-host בצורה נקייה, ולכן ניהול השירות של Windows לא יפעיל מחדש; כדי שפעולת ההתאוששות תיכנס לפעולה, צריך לקרוא ל-Environment.Exitעם קוד סיום שונה מ-0) - .NET: System.Exception
- .NET: StackOverflowException
- .NET: System.AccessViolationException
- .NET: Environment.FailFast
- .NET: AppDomain.UnhandledException
- WPF: Application.DispatcherUnhandledException
- Windows Forms: Application.SetUnhandledExceptionMode
- .NET: Exceptions in Managed Threads
- .NET: TaskScheduler.UnobservedTaskException
- Windows: NTSTATUS Values - MS-ERREF
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
תכנון שמירת יומנים ו-dump בקריסת יישום Windows
המאמר מסדר איך לשלב יומן רגיל, סמן קריסה סופי, WER LocalDumps ותהליך ניטור, כדי שגם כשיישום Windows קורס מחריגה בלתי צפויה או מבאג בתוכני...
למה עדיף המתנה מונחית אירועים על פני Sleep(1) ב-Windows
ב-Windows הדיוק של timed wait קצר מושפע מרזולוציית שעון המערכת ומהתזמון. המאמר מסביר מדוע כשמחכים להגעת עבודה, השלמת קלט/פלט או בקשת עציר...
רשימת בדיקה מינימלית לאבטחה בפיתוח יישומי Windows
מסדרים בצורת רשימת בדיקה את היסודות של הרשאות, חתימה, עדכונים, סודות, HTTPS, אימות קלט, טעינת DLL ולוגים ביישומים עסקיים ב-WPF / WinForm...
למה להשתמש ב-Generic Host וב-BackgroundService של .NET באפליקציית desktop
בכלי Windows ובאפליקציות שרצות ברקע, מסודר כאן איך להשתמש ב-Generic Host וב-BackgroundService כדי לסדר הפעלה, עיבוד תקופתי, סיום, לוג, הג...
מדריך מעשי ל-FileSystemWatcher — התמודדות עם פספוסים וכפילויות
המאמר מסדר את אופן השימוש ואת נקודות התשומת לב של FileSystemWatcher, מנקודת המבט של פספוסים, התראות כפולות, מלכודות בקביעת סיום, סריקה חו...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
ייעוץ טכני וסקירת תכנון
מדובר בגיבוש מדיניות טיפול בחריגות, גבולות תקלה, אסטרטגיית הפעלה מחדש וקריטריוני המשך פעולה - נושא שמתאים היטב לייעוץ טכני ולסקירת תכנון.
חקירת תקלות ואיתור גורמים
התהליך של הכרעה בין המשך לסיום אחרי חריגה בלתי צפויה, כולל בדיקת השחתת מצב ותופעות לוואי חיצוניות, מתקדם היטב כחקירת תקלות וניתוח שורש.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- מותר לתפוס חריגה בלתי צפויה עם catch (Exception), לרשום ללוג ולהמשיך?
- רישום ללוג בלבד והמשך פעולה הוא בדרך כלל מסוכן. זה מסתיר את הסיבה האמיתית, וגם נוטה להאריך חיים למצב שכבר נשבר. מותר להמשיך רק כששלושת התנאים הבאים מתקיימים יחד: אפשר לזרוק את היחידה שנכשלה, אפשר להחזיר את המצב המשותף למצב תקין, ואפשר להסביר את תופעת הלוואי החיצונית. הציר של ההחלטה הוא לא "אפשר לתפוס את החריגה הזו?" אלא "עדיין אפשר לסמוך על מצב האפליקציה אחרי זה?".
- אילו חריגות מחייבות סיום מיידי?
- StackOverflowException, AccessViolationException, ו-OutOfMemoryException חמור - חריגות שמעוררות חשד לגבי שלמות התהליך כולו - עדיף לא לטפל בהן מתוך הנחה שאפשר להמשיך. ב-StackOverflowException מחסנית הקריאות קרסה, וב-AccessViolationException מדובר בגישה לא חוקית לזיכרון מוגן שמעוררת חשד להשחתת זיכרון. גם חריגות שמקורן ב-callback של COM, P/Invoke או SDK של ספק חיצוני קשה לשפוט את בטיחותן רק מצד ה-managed, ולכן גם הן נוטות לכיוון סיום נחרץ.
- באילו תנאים מותר להמשיך להפעיל את האפליקציה גם אחרי חריגה?
- בדרך כלל צריך שיתקיימו יחד: יחידת כישלון ברורה (פעולה אחת, מסך אחד, job אחד, חיבור אחד וכדומה - כלומר ברור מה זורקים), אפשר לזרוק את המצב ולבנות אותו מחדש, אין הדבקה שמתפשטת למצב המשותף, אפשר להסביר את תופעת הלוואי החיצונית, אפשר להודיע למשתמש בכנות ש"הפעולה הזו נכשלה", ואפשר לחקור בדיעבד בעזרת לוגים ומדדים. כשגבול העיבוד ברור, כמו פעולה אחת בממשק המשתמש או job קליטה אחד, לפעמים אפשר להמשיך. לעומת זאת, חריגה באמצע עדכון מצב משותף, בלולאת האב, בעיבוד ההפעלה או בגבול הנייטיב נוטה לכיוון סיום.
- אם מגדירים Handled=true ב-DispatcherUnhandledException של WPF, אפשר להמשיך?
- אפשר טכנית להמשיך גם אחרי חריגה לא מטופלת, אבל האפשרות להמשיך והבטיחות שבהמשך הן שני דברים שונים. handlers כמו AppDomain.UnhandledException או DispatcherUnhandledException שימושיים כמקום תיעוד אחרון, אבל הם לא נקודת החלמה קסומה. עדיף להעדיף איסוף מידע על החריגה, הקשר הפעולה ומסלול לאיסוף dump כך שאפשר לחקור אחרי הקריסה - זה בדרך כלל מוביל ליציבות טובה יותר. בפרט בשירותים ואפליקציות ניטור שרצים לאורך זמן, קריסה עם הפעלה מחדש בדרך כלל בטוחה וקלה יותר לאבחון מאשר הישרדות במצב חצי-שבור.