Exception לא צפוי ב-.NET: מתי מסיימים תהליך ומתי ממשיכים
· עודכן בתאריך: · Go Komura · פיתוח Windows, טיפול בחריגות, תכנון, C# / .NET, אמינות
היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 16 Mar 2026)
- פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22173540)
מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.
Go Komura (2026). Exception לא צפוי ב-.NET: מתי מסיימים תהליך ומתי ממשיכים. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173540 https://comcomponent.com/he/blog/unexpected-exception-exit-or-continue-decision-table/
- DOI (הגרסה האחרונה)
- 10.5281/zenodo.22173540
- DOI (הגרסה הזו)
- 10.5281/zenodo.22173541
הורידו את רשימת הבדיקה ב-Excel עם גיליון עברי ואנגלי
הקובץ בנוי משני גיליונות, Checklist-ja ו-Checklist-en, ומפרק את ציר המאמר ל-4 קטגוריות — תנאי המשך / תנאי סיום / כיוון מימוש / סדר ההחלטה — ב-27 סעיפים. העמודות Status ו-Notes נשארות ריקות, כך שאפשר להשתמש בקובץ ישירות כטבלת בדיקה לתיעוד טיפול בתקלות או ל-design review.
כשמדברים על exception לא צפוי, נוטים לחשוב בשתי אפשרויות: להפיל את האפליקציה, או לתפוס ב-catch ולהמשיך. בפועל החלוקה הבינארית הזו גסה מדי.
מה שבאמת רוצים לבדוק הוא האם אפשר לכלוא את הטווח שאולי נשבר.
- אפשר לסיים רק את הפעולה הזו בכישלון?
- מספיק לאתחל מחדש רק את המסך / החיבור / ה-worker הזה?
- או שכבר יש חשד לגבי העקביות של התהליך כולו?
כשבודקים בסדר הזה, קל יותר לסדר את המחשבה.
flowchart TB
accTitle: הסדר לבדיקת הטווח שנשבר
accDescr: כשמתרחש exception לא צפוי, בודקים בסדר הזה: אפשר לסיים רק את הפעולה הזו בכישלון, האם מספיק לאתחל מחדש רק תת-מערכת, ואם יש חשד לגבי התהליך כולו.
q1["אפשר להפיל רק את הפעולה?"] --> q2["לאתחל רק מסך או חיבור?"]
q2 --> q3["יש חשד לגבי כל התהליך?"]
איור 1: בודקים את הטווח שאולי נשבר בסדר של פעולה, תת-מערכת, ואז התהליך כולו.
המאמר מניח בסיס של אפליקציות Windows, C# / .NET, אפליקציות רקע, Windows Service וכלי חיבור לציוד, ומסכם כטבלת החלטה מתי מותר להמשיך אחרי exception לא צפוי, ומתי עדיף לסיים.
1. המסקנה בקצרה
catch (Exception)שבולע הכול וממשיך הוא בדרך כלל מסוכן.- ממשיכים רק כששלושת התנאים מתקיימים: אפשר לוותר על היחידה שנכשלה, אפשר להחזיר את ה-shared state למצב תקין, אפשר להסביר את ה-side effect החיצוני.
- כשגבול העיבוד ברור — פעולה אחת ב-UI, קלט אחד, job אחד — לפעמים אפשר להמשיך.
- לעומת זאת, כשמעורב shared state שניתן לכתיבה, לולאה שרצה ברקע, ה-thread הראשי, startup, או סימנים ל-memory corruption בגבול native — עדיף לסיים.
- exceptions כמו
StackOverflowException,AccessViolationExceptionו-OutOfMemoryExceptionשמעלים חשד לגבי “שלמות התהליך כולו” עדיף לא לטפל בהם מתוך הנחה שאפשר להמשיך. - ל-WPF ול-Windows Forms יש דרך לתפוס unhandled exception ולהיראות כאילו ממשיכים, אבל האפשרות להמשיך ו-הבטיחות שבהמשך הם שני דברים שונים.
- אפליקציות ושירותים שרצים לאורך זמן: קריסה עם restart בדרך כלל בטוחה וקלה יותר לאבחון מאשר הישרדות במצב חצי-שבור.
בקיצור, ציר ההחלטה הוא האם אפשר לשחזר את ה-invariant.
flowchart TB
accTitle: שלושה תנאים שמאפשרים להמשיך
accDescr: ממשיכים רק כששלושת התנאים מתקיימים יחד: אפשר לוותר על היחידה שנכשלה, אפשר להחזיר את ה-shared state, ואפשר להסביר את ה-side effect. הציר הוא האם אפשר לשחזר את ה-invariant.
c1["אפשר לוותר על ה-failure unit"] --> ok3["3 תנאים יחד = מותר להמשיך"]
c2["אפשר להחזיר את ה-shared state"] --> ok3
c3["אפשר להסביר את ה-side effect"] --> ok3
ok3 -.-> jiku["הציר: אפשר לשחזר את ה-invariant?"]
איור 2: ממשיכים רק כששלושת התנאים — failure unit, shared state, side effect חיצוני — מתקיימים יחד.
1.1 מונחים במאמר
לפני טבלת ההחלטה, חמש מילים שחוזרות שוב ושוב.
| מונח | המשמעות במאמר |
|---|---|
| invariant | הבטחה למצב שחייבת להתקיים לפני ואחרי הפעולה. למשל “סכום השורות תואם לערך המצטבר”, “תוכן ה-cache ותוכן ה-DB תואמים”, “כל חיבור פתוח רשום ברשימת הניהול”. כשההבטחה הזו נשברת וממשיכים לרוץ, כל עיבוד הבא הופך לחשוד |
| failure unit | הטווח שאפשר לוותר עליו כולו כשמשהו נכשל. פעולה אחת, מסך אחד, job אחד, חיבור אחד |
| side effect חיצוני | שינוי שכבר יצא אל מחוץ לתהליך. עדכון DB, כתיבה לקובץ, שליחת מייל, שליחת פקודה לציוד — דברים שאי אפשר להחזיר עם catch |
| תת-מערכת | היחידה שאפשר לעצור ולאתחל מחדש יחד. חיבור, מסך, worker, תהליך-בן |
FailFast |
הכוונה ל-Environment.FailFast. API שמסיים את התהליך מיד בלי להריץ try / finally או finalizer, ומפורט בסעיף 9.7 |
ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 21, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle
2. מה נחשב “exception לא צפוי” במאמר
2.1 ההבדל בין צפוי ללא צפוי
חריגה נדירה ו-exception לא צפוי הם לא אותו דבר.
למשל, הדברים הבאים אפשר להחשיב כצפויים גם אם התדירות נמוכה.
- המשתמש בחר קובץ שלא קיים
- צד השרת עבר timeout זמני
- שורה אחת ב-CSV שנקלט הייתה פגומה
- פעולת ביטול זרקה
OperationCanceledException - הפרת חוק עסקי שרוצים להכשיל רק בגללה את הפעולה הזו
אלה מהסוג שבו אפשר להחליט מראש בתכנון איך לטפל בכישלון.
לעומת זאת, ה-exceptions הלא צפויים שהמאמר עוסק בהם בעיקר הם כאלה:
- ההנחות של הקוד שלכם נשברו וקיבלתם
NullReferenceExceptionאוInvalidOperationException - exception נזרק באמצע עדכון shared state, ולא ברור עד כמה השינוי כבר נכתב
- לולאת האב של לולאת ניטור או עיבוד הודעות קרסה
- exception בגבול COM / P/Invoke / vendor SDK
- מצבים כמו
AccessViolationExceptionאוStackOverflowException, שבהם “בדיקת הבריאות” של התהליך עצמה כבר אדומה
במילים אחרות, אלה מקרים שבהם “לא ברור אם עדיין אפשר לסמוך על מצב האפליקציה אחרי ה-exception הזה”.
flowchart TB
accTitle: הגבול בין צפוי ללא צפוי
accDescr: exception שאפשר להחליט מראש בתכנון איך לטפל בו נחשב צפוי גם אם התדירות נמוכה, ו-exception שאחריו לא ברור אם אפשר לסמוך על מצב האפליקציה נחשב לא צפוי במאמר.
e1["התרחש exception"] --> q1{"אפשר להחליט מראש איך מטפלים?"}
q1 -->|"אפשר"| soutei["צפוי (גם אם נדיר)"]
q1 -->|"אי אפשר"| sotogai["לא צפוי (לא ברור אם אפשר לסמוך על ה-state)"]
איור 3: חריגה נדירה ו-exception לא צפוי הם דברים שונים, וההבדל הוא אם אפשר להחליט מראש איך מטפלים.
2.2 נראה כמו שתי אפשרויות, אבל בעצם יש שלוש
מה שמסבך את הדיון הוא ההתייחסות ל”המשך” כאל סוג אחד.
בפועל, בדרך כלל מתחלקים ל-3 שלבים.
| בחירה | משמעות |
|---|---|
| להכשיל רק את הפעולה ולהמשיך | המסך נשאר, אבל רק השמירה או הקליטה הנוכחית מסומנת ככישלון |
| לעצור רק את תת-המערכת ולהמשיך | מאתחלים מחדש רק את החיבור, המסך, ה-worker או תהליך-הבן |
| לסיים את התהליך | טווח ה-state corruption לא ברור, ולכן מניחים restart |
“להמשיך להריץ את האפליקציה” יכול להישמע כמו דבר אחד, אבל יש הבדל משמעותי בין להמשיך כאילו כלום לא קרה לבין לבודד את החלק שנשבר ואז להמשיך.
flowchart TB
accTitle: אותו המשך, משקל שונה
accDescr: למרות שנשמע כמו "להמשיך להריץ את האפליקציה", יש הבדל משמעותי בין המשך כאילו כלום לא קרה לבין המשך שבו בודדים את החלק שנשבר.
keizoku["להמשיך להריץ את האפליקציה"] --> k1["להמשיך כאילו כלום לא קרה"]
keizoku --> k2["לבודד את מה שנשבר ואז להמשיך"]
k1 -.-> omomi["אותו המשך, משקל שונה"]
k2 -.-> omomi
איור 4: לא כדאי לחשוב על “המשך” כסוג אחד — יש להבחין בין המשך שמבודד את החלק השבור.
3. טבלת ההחלטה שכדאי לבדוק ראשונה
3.1 מבט כללי
אם מתחילים מהטבלה הזו, קל להגיע לכיוון מדיניות כללי.
| מצב | הבחירה הראשונית | הסיבה |
|---|---|---|
| קלט אחד, פעולת מסך אחת, או job אחד נכשל, ואפשר לוותר על ה-state | נטייה להמשיך | אפשר לכלוא את ה-failure unit |
| אחרי ה-exception אפשר למחוק את האובייקט או החיבור ולבנות אותם מחדש | נטייה לאתחל מחדש תת-מערכת | אפשר להגביל את הטווח שנשבר לאזור מקומי |
| shared state עודכן חלקית ולא ברור עד כמה השינוי נכתב | נטייה לסיום | ייתכן שה-invariant נשבר |
| side effect חיצוני — DB / קובץ / פקודה לציוד — נשאר באמצע, ואי אפשר להסביר כפילות או אי-כתיבה | נטייה לסיום | לא ברור מה מצב העקביות מול העולם החיצוני |
| לולאת ניטור, לולאת reconnect או לולאת האב של עיבוד הודעות קרסו מ-exception לא צפוי | נטייה לסיום | נטייה להפוך ל-zombie כשרק חלק מהתפקוד מת בשקט |
| כישלון ב-startup, טעינת הגדרות, בניית DI, או אתחול תלות חובה | נטייה לסיום עם כישלון הפעלה | הפעלה חלקית מסוכנת יותר |
AccessViolationException, StackOverflowException, OutOfMemoryException חמור, או סימנים ל-corruption בצד native |
נטייה לסיום מיידי | חשד לגבי שלמות התהליך כולו |
| עיבוד מסוכן מבודד בתהליך נפרד, ותהליך האב נשאר שלם | תהליך האב ממשיך, תהליך הבן מופעל מחדש | אפשר לבודד את אזור התקלה |
flowchart TD
accTitle: הזרימה המלאה לבחירת המשך או סיום
accDescr: בודקים בסדר של סימני memory corruption, failure unit, shared state ו-side effect חיצוני, ולפי זה קובעים סיום מיידי, נטייה לסיום, עצירת תת-מערכת, או המשך עם הפעולה בלבד כנכשלת.
A["exception לא צפוי"] --> B{"סימנים ל-memory corruption / דלדול מחסנית / דלדול משאבים קריטי?"}
B -- "כן" --> Z["סיום / FailFast / restart"]
B -- "לא" --> C{"אפשר לוותר על ה-failure unit?"}
C -- "לא" --> Y["נטייה לסיום"]
C -- "כן" --> D{"אפשר לשחזר או לאתחל מחדש את ה-shared state?"}
D -- "לא" --> X["עצירת תת-מערכת או סיום"]
D -- "כן" --> E{"אפשר להסביר את ה-side effect החיצוני?"}
E -- "לא" --> X
E -- "כן" --> W["המשך עם הפעולה הזו בלבד כנכשלת"]
איור 5: בודקים בסדר של סימני corruption, failure unit, shared state, side effect חיצוני, ולפי זה קובעים מדיניות.
הענף הראשון בתרשים, “סימנים ל-memory corruption”, מפורט בסעיף 3.4, ו-FailFast בפינה הימנית העליונה מפורט בסעיף 9.7.
3.2 מה בודקים לפני סוג ה-exception
לא כדאי להחליט מיד לפי סוג ה-exception בלבד. כדאי לבדוק קודם את הדברים הבאים.
| מה בודקים | מה בודקים בפועל |
|---|---|
| איפה זה קרה | אירוע UI, job בודד, לולאת האב, startup, או גבול native |
| כמה התקדם | האם מצב הזיכרון, ה-DB, הקובץ או מצב הציוד השתנו באמצע |
| הטווח האפשרי של הנזק | רק האובייקט הזה, כל המסך, או כל התהליך |
| אפשר rollback? | אפשר למחוק ולבנות מחדש, או להחזיר עם טרנזקציה |
| side effect חיצוני | נשלח או לא נשלח, האם הרצה כפולה בטוחה, אפשר עיבוד פיצוי |
| ניטור ו-restart | יש restart אוטומטי או מסלול שחזור אחרי הנפילה |
3.3 exceptions ברמת סיכון גבוהה
לא צריך לדון בכל סוג exception בפירוט, אבל יש כאלה שעדיף לא להתייחס אליהן מתוך הנחה שאפשר להמשיך.
| exception / תסמין | הבחירה הראשונית | למה בודקים את זה |
|---|---|---|
StackOverflowException |
נטייה לסיום מיידי | מחסנית הקריאות קרסה, קשה להניח recovery רגיל |
AccessViolationException |
נטייה לסיום מיידי | גישה לא חוקית לזיכרון מוגן, חשד לגבול native או memory corruption |
OutOfMemoryException |
נטייה לסיום | גם עיבוד ה-recovery עצמו, שמניח הקצאה נוספת, נוטה להיות לא יציב |
NullReferenceException / InvalidOperationException לא צפויים |
תלוי בהקשר, אך נטייה לסיום | ההנחות שלכם נשברו, ייתכן שנשאר שינוי חלקי |
| exception לא צפוי שדלף מלולאת האב | נטייה לסיום | ליבת הפונקציונליות מתה בזמן שהתהליך עצמו נשאר, מסוכן |
| exception שמקורו ב-callback של COM / P/Invoke / vendor SDK | נטייה לסיום מיידי עד נחרץ | קשה לשפוט בטיחות מצד ה-managed בלבד |
3.4 מה עומד מאחורי “סימני memory corruption” ו”תהליך zombie”
שני הביטויים האלה נשמעים אינטואיטיביים, אבל בפועל יש להם סימנים ברורים שבודקים.
קודם כל, תסמינים שמעלים חשד ל-memory corruption.
| מה רואים | איפה רואים את זה |
|---|---|
התהליך קורס עם קוד חריגה 0xc0000005 (access violation) או 0xc0000374 (heap corruption) |
Event Viewer > Windows Logs > Application, תחת “Application Error” (Event ID 1000) |
| מקום הקריסה משתנה בכל פעם. קורס בקוד שלא נגעו בו ממש לפני כן | לוגים, call stack ב-dump |
| קורס רק כשנוגעים באובייקט או handle שכבר שוחררו | תהליך שחזור, dump |
קריסה בעיבוד שחרור בצד native (free / delete / שחרור COM) |
call stack ב-dump (למשל קריסה בתוך ntdll.dll) |
| ערך שלא נגעו בו השתנה. אותו קלט, אבל התוצאה משתנה | לוגי קלט/פלט, השוואת תוצאות בין הרצות |
0xc0000005 הוא STATUS_ACCESS_VIOLATION ו-0xc0000374 הוא STATUS_HEAP_CORRUPTION, שניהם ערכים המוגדרים ברשימת NTSTATUS של Microsoft. במיוחד heap corruption, הקריסה קורית לא ברגע ההשחתה עצמו אלא בצד שנוגע ב-heap השבור בפעם הבאה, ולכן מקום הקריסה לא בהכרח “האשם”. אם רואים תסמין מהסוג הזה, בטוח יותר להניח ש-catch בצד ה-managed והמשך ריצה לא באמת עוזרים.
flowchart TB
accTitle: הפער במקום הקריסה ב-heap corruption
accDescr: ב-heap corruption הקריסה לא קורית ברגע ההשחתה, אלא בצד שנוגע ב-heap השבור בפעם הבאה, ולכן מקום הקריסה לא בהכרח האשם.
h1["ה-heap נשבר במקום כלשהו"] --> h2["לא קורסים באותו רגע"]
h2 --> h3["קורסים בצד שנוגע בו בפעם הבאה"]
h3 -.-> h4["מקום הקריסה לא בהכרח האשם"]
איור 6: heap corruption גורם לקריסה בצד שנוגע בה בפעם הבאה, כך שיש פער בין מקום הקריסה למקום ההשחתה.
עכשיו, תסמינים שמעלים חשד לתהליך zombie.
| מה רואים | איפה רואים את זה |
|---|---|
| התהליך חי, אבל זמן העיבוד האחרון לא מתעדכן | לוג של זמן עיבוד אחרון, heartbeat |
| רק מספר הפריטים הממתינים בתור או בתיקיית הקליטה ממשיך לגדול | אורך התור, מספר קבצים שלא טופלו |
| הלוג פשוט נעצר מנקודת זמן מסוימת | לוג האפליקציה |
| המסך פועל אך העדכון מאחורי הקלעים נעצר | השוואה בין תצוגת המסך לנתונים בפועל |
| מספר ה-worker threads נמוך מהצפוי | לוג אבחון, רשימת threads ב-Process Explorer |
תהליך zombie הוא לא “התהליך קרס” אלא “התהליך חי אך לא עושה עבודה”. אם מכינים מראש מדדים שמראים “שהעבודה מתבצעת” — זמן עיבוד אחרון, מספר פריטים ממתינים, heartbeat — גם ההחלטה בין המשך לסיום וגם החקירה בדיעבד נעשות הרבה יותר קלות.
flowchart TB
accTitle: מדדים לזיהוי תהליך zombie
accDescr: תהליך zombie הוא מצב שבו התהליך חי אך לא עושה עבודה, והכנת מדדים כמו זמן עיבוד אחרון, מספר פריטים ממתינים ו-heartbeat מקלה גם על ההחלטה וגם על החקירה בדיעבד.
z1["זמן עיבוד אחרון"] --> z4["הכנת מדד שמראה שהעבודה מתבצעת"]
z2["מספר פריטים ממתינים"] --> z4
z3["heartbeat"] --> z4
z4 --> z5["מקלה על ההחלטה: המשך או סיום"]
z4 --> z6["מקלה על החקירה בדיעבד"]
איור 7: זיהוי תהליך zombie לא נעשה לפי “לא קרס” אלא לפי מדד שמראה שהעבודה מתבצעת.
4. ההחלטה לפי המקום שבו זה קרה
4.1 אירועי UI
אירועי UI כמו לחיצת כפתור, מעבר בין מסכים, חיפוש או בחירת קובץ — יש בהם יחסית הרבה מרחב להמשך. אבל יש תנאים.
קל להמשיך במקרים כאלה:
- הכישלון קרה לפני הטעינה, ומצב העסק עוד לא נגע בו
- רק מצב זמני בתוך דיאלוג נשבר, ואפשר לוותר עליו בסגירת המסך
- אפשר לבנות מחדש את ה-ViewModel או החיבור אחרי ה-exception
- אפשר להודיע למשתמש בכנות ש”הפעולה הזו נכשלה”
לעומת זאת, במצבים הבאים כדאי לנטות לכיוון סיום:
- גם המסך וגם מצב הדומיין עודכנו חלקית
- נגעו ב-shared state שגם מסכים אחרים רואים — static / singleton / cache
- אחרי ה-exception נשארו רק מצב הכפתור הפעיל או הבחירה, ולא ברור מה מצב העקביות
- exception לא צפוי קרה ב-UI thread, ולא ברור עד כמה הרינדור או ההודעה כבר התקדמו
flowchart TB
accTitle: הגבול בחריגות בפעולות UI
accDescr: exception באירוע UI שנוגע רק במצב זמני שאפשר לוותר עליו קל יותר להמשך, ואם נגע ב-shared state שמסכים אחרים רואים או בעדכון חלקי, נוטה לכיוון סיום.
u1["exception לא צפוי בפעולת UI"] --> q1{"באיזה state נגעו?"}
q1 -->|"רק מצב זמני שאפשר לוותר עליו"| u2["המשך עם הפעולה הזו בלבד כנכשלת"]
q1 -->|"shared state או עדכון חלקי"| u3["נטייה לסיום"]
איור 8: לאירועי UI יש מרחב גדול להמשך, אבל אם נגעו ב-shared state התמונה משתנה.
4.2 Job / בקשה שמעובדים אחד-אחד
זה גבול שקל יחסית להמשיך ממנו.
- הודעה אחת
- קובץ אחד
- בקשת HTTP אחת
- job קליטה אחד
- פריט batch אחד
כשיחידה כזו ברורה, אפשר להכשיל רק את הפריט הזה ולהמשיך לפריט הבא.
אבל יש תנאי מוקדם.
- ה-failure unit ברורה מבחוץ
- שינוי חלקי מתעדכן כראוי בעזרת טרנזקציה או עיבוד פיצוי
- יש תכונת “בטוח להריץ שוב” — הרצה חוזרת של אותו עיבוד לא שוברת את התוצאה
- אפשר להעביר את הכישלון לתור בידוד או ללוג שגיאות
4.3 לולאה שרצה ברקע / ניטור / עיבוד תור
זה המקום שבו המשך רשלני מסוכן ביותר.
לדוגמה:
- לולאת reconnect
- לולאת ניטור
- לולאת צריכת תור
- polling תקופתי
- ניטור מצב ציוד
- עיבוד שרץ ברקע באפליקציית tray
מה שמפחיד בסוג העיבוד הזה הוא שלולאת האב מתה מ-exception לא צפוי אחד, והתהליך נשאר בחיים לבדו.
כאן כדאי לחלק את המדיניות.
- בגבול העיבוד של כל פריט — לתפוס exceptions צפויים
- בלולאת האב — אם דלף exception לא צפוי, לנטות לכיוון סיום התהליך
flowchart TB
accTitle: חלוקת המדיניות בלולאה שרצה ברקע
accDescr: בגבול העיבוד של כל פריט תופסים exceptions צפויים, ואם דלף exception לא צפוי מלולאת האב נוטים לכיוון סיום התהליך, כדי למנוע מהתהליך להישאר לבדו בחיים.
loop1["לולאה שרצה ברקע"] --> b1["גבול עיבוד של כל פריט"]
loop1 --> b2["לולאת האב"]
b1 --> r1["תופסים exception צפוי"]
b2 --> r2["exception לא צפוי דולף = נטייה לסיום"]
r2 -.-> zb["מונע מהתהליך להישאר לבדו בחיים"]
איור 9: מחלקים מדיניות בין גבול הפריט ללולאת האב, ו-exception לא צפוי בלולאת האב מוביל לסיום.
4.4 startup
אם מתייחסים לכישלון בהפעלה כ”נעלה קודם ואחר כך נחשוב”, האפליקציה תמשיך לרוץ כשחלק מהפונקציונליות חסר, ובהמשך יהיה קשה מאוד לבודד את הסיבה.
- לא ניתן לקרוא הגדרה חובה
- מעבר גרסה / migration נכשל
- תיקייה חובה או תעודה חסרים
- אתחול שירות ליבה נכשל
- הרכב התלות שבור
במקרים כאלה, ברור יותר לסיים את התהליך כתוצאה מכישלון הפעלה.
4.5 גבול native / COM / P/Invoke / unsafe
כאן כדאי להתייחס בחומרה מעט יותר גדולה, כקטגוריה נפרדת.
- COM
- P/Invoke
- מעבר דרך C++/CLI
- vendor SDK
- קוד native שחוזר דרך callback
- עיבוד שכולל
unsafe
בפרט, אם רואים את אלה כדאי לנטות לכיוון סיום.
AccessViolationException- תסמין שמעלה חשד ל-heap corruption או double free
- תקלת handle, או סימנים לגישה אחרי שחרור
- קריסה פתאומית בגבול callback
flowchart TB
accTitle: הטיפול ב-exception בגבול native
accDescr: בגבול native, כמו COM ו-P/Invoke, אם רואים AccessViolationException, תסמין שמעלה חשד ל-heap corruption, או קריסה פתאומית בגבול callback, נוטים לכיוון סיום, וגבול זה עצמו נבדק בחומרה גבוהה יותר.
n4["AccessViolationException"] --> n3["נטייה לסיום"]
n5["תסמין של heap corruption או double free"] --> n3
n6["קריסה פתאומית בגבול callback"] --> n3
n3 -.-> n2["גבול native נבדק בקטגוריה נפרדת ובחומרה"]
איור 10: כשרואים תסמינים כאלה בגבול native, נוטשים את הנחת ההמשך ופונים לכיוון סיום.
5. תנאים שמתחתם מותר להמשיך
התנאים שמותר להמשיך תחתם, מסודרים ביחד, נראים כך. בדרך כלל הם צריכים להתקיים כמעט כולם יחד.
| תנאי | משמעות |
|---|---|
| failure unit ברורה | ברור מה זורקים — פעולה אחת, מסך אחד, job אחד, חיבור אחד |
| אפשר לוותר על ה-state | אפשר לבנות אותו מחדש, או להתייחס אליו כלא-נכתב |
| ה-shared state מוגן | אין התפשטות של זיהום לפונקציונליות אחרת |
| אפשר להסביר את ה-side effect החיצוני | ברור אם נשלח / לא נשלח / מותר לשלוח שוב |
| אפשר להודיע למשתמש בכנות | אפשר להציג “הפעולה הזו נכשלה” |
| אפשר לנטר | אפשר לחקור בדיעבד עם לוגים, מדדים ו-dump |
6. תנאים שמתחתם עדיף לסיים
לעומת זאת, כשמתקיים אחד מהתנאים הבאים, עדיף לנטות לכיוון סיום.
- לא ברור מה השתנה עד כה
- נגעו ב-shared state שניתן לכתיבה, ולא ברור מה מצב העקביות
- ניהול חיי הנעילה, התור, ה-thread או לולאת הניטור נשבר
- לא ניתן להסביר כפילות / חסר / חלקיות ב-side effect חיצוני
- כישלון ב-startup או באתחול תשתית ליבה
- חשד ל-memory corruption או תקלה בגבול native
ברמה כזו, מאמץ לשמר המשך נקי פחות אפקטיבי מ-מאמץ להפיל ולהקל על ה-recovery.
flowchart TB
accTitle: המאמץ שמועיל בנטייה לסיום
accDescr: ברמה שמתאימה לתנאי הסיום, מאמץ לשמר המשך נקי פחות מועיל מהשקעה במאמץ להפיל ולהקל על ה-recovery.
j1["מתקיים תנאי הנוטה לסיום"] --> j2["מאמץ לשמר המשך נקי"]
j1 --> j3["מאמץ להפיל ולהקל על recovery"]
j2 -.-> j4["פחות אפקטיבי ברמה הזו"]
j3 -.-> j5["זה יותר אפקטיבי"]
איור 11: במצב שנוטה לסיום, כדאי להשקיע בקלות recovery ולא במאמץ להמשיך בצורה נקייה.
7. המלצות לפי דפוסים אופייניים
טבלת ההחלטה בסעיף 3.1 כתובה בצורת תנאים, ולכן לפעמים קשה להתאים אותה למקרה קונקרטי. הטבלה הזו לוקחת את אותם תנאים ומתאימה אותם למצבים שנתקלים בהם בפועל. כשמתלבטים, הכי מהיר לבדוק קודם את התנאים בסעיף 3.1, ואז לחפש את השורה הקרובה ביותר בטבלה הזו.
flowchart TB
accTitle: איך משתמשים בטבלה כשמתלבטים
accDescr: כשמתלבטים איך להתאים את המצב, קודם בודקים את התנאים בטבלת ההחלטה בסעיף 3.1, ואז מחפשים בטבלת הפרק הזה את השורה הקרובה ביותר למצב.
m1["התלבטות בהתאמה למצב"] --> m2["בדיקת תנאים בטבלה של 3.1"]
m2 --> m3["חיפוש שורה קרובה בטבלת הפרק הזה"]
איור 12: לחפש קודם בטבלת התנאים ואז בטבלת המצבים — הסדר הזה מקטין את ההתלבטות.
| דפוס | המלצה | הסיבה |
|---|---|---|
| כפתור פתיחת קובץ קיבל נתיב שלא קיים | המשך עם הפעולה הזו בלבד כנכשלת | ה-state corruption מקומית |
| שורה אחת בקליטת CSV הייתה פגומה | המשך עם כישלון שורה אחת או קובץ אחד | קל לכלוא את ה-failure unit |
NullReferenceException לא צפוי קרה באמצע שמירת מסך |
בניית המסך מחדש עד נטייה לסיום | לא ברור עד כמה ה-ViewModel או מצב העסק כבר השתנו |
| הודעה אחת בתור הפרה חוק עסקי | המשך עם כישלון ההודעה הזו בלבד | אפשר להעביר אותה לתור בידוד |
| לולאת האב של צריכת תור קרסה מ-exception לא צפוי | נטייה לסיום התהליך | חיי כל ה-worker נשברים |
| הגדרה חובה לא נקראת ב-startup | סיום עם כישלון הפעלה | הפעלה חלקית מסוכנת יותר |
AccessViolationException בסביבת callback של vendor SDK |
נטייה לסיום מיידי | אי אפשר להתעלם מהאפשרות של memory corruption |
| שליחת telemetry לא חיונית נכשלה בלבד | ביטול הפונקציה הזו בלבד, המשך | אפשר להפריד בין הפונקציה הראשית לאזור התקלה |
8. טעויות נפוצות
8.1 catch (Exception) שרק רושם ללוג וממשיך
זה מסוכן למדי. זה מסתיר את הסיבה, ונוטה גם להאריך חיים למצב שכבר נשבר.
8.2 ניסיון לעשות recovery דרך handler ה-unhandled exceptions האחרון
AppDomain.UnhandledException, Application.ThreadException, DispatcherUnhandledException וכדומה שימושיים כמקום תיעוד אחרון, אבל הם לא נקודת recovery קסומה.
8.3 retry קליל כשיש side effect חיצוני
אם מריצים retry על עיבוד עם side effect חיצוני — פקודה לציוד, שליחת מייל, חיוב, העברת קובץ, עדכון DB — בלי שיש בטיחות להרצה חוזרת, התאונה של הרצה כפולה תופסת את המרכז.
flowchart TB
accTitle: retry קליל שמוביל להרצה כפולה
accDescr: כשמריצים retry על עיבוד עם side effect חיצוני בלי בטיחות להרצה חוזרת, תאונת ההרצה הכפולה תופסת את המרכז.
y1["עיבוד עם side effect חיצוני נכשל"] --> y2["retry בלי בטיחות להרצה חוזרת"]
y2 --> y3["תאונת הרצה כפולה תופסת את המרכז"]
y1 -.-> y4["למשל פקודה לציוד, חיוב, שליחה"]
איור 13: retry בלי בטיחות להרצה חוזרת מוביל לתאונת הרצה כפולה.
8.4 להשאיר את ה-UI כשלולאת הניטור מתה
אפליקציה שנראית חיה למראית עין, אבל לא עושה עבודה, נראית תקינה מנקודת מבט המשתמש — ולכן מתגלה באיחור. כדאי שצד הניטור יוכל לתפוס את תסמיני ה-zombie שתוארו בסעיף 3.4.
8.5 לומר “לא רוצים שיפול” בלי לתכנן את הנפילה
אם לא רוצים שיפול, יש דברים שצריך להכניס לפני כן.
- restart אוטומטי
- שחזור session
- שמירת תוצאות ביניים
- בטיחות להרצה חוזרת
- בידוד אזור תקלה
9. נקודות סידור בזמן המימוש
9.1 להצמיד את מקום ה-catch לגבול
עדיף לתפוס במקום שבו אפשר להגדיר failure unit, ולא לתפוס כל דבר בשכבה עמוקה.
- גבול פעולת UI
- גבול בקשה אחת
- גבול job אחד
- גבול חיבור אחד
- גבול התהליך
לדוגמה, בקליטה שמעבדת פריט אחר פריט, ה-catch ממוקם בתוך הלולאה. כאן זו ה-failure unit.
// C# / .NET 8. לולאה שמעבדת פריט אחר פריט, כשה-catch כולא את ה-failure unit בתוך הלולאה.
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>
/// כישלון של פריט בודד (שגיאת validation, כפילות, פורמט לא תקין וכדומה) נזרק כ-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)
{
// אפשר לוותר על ה-state של הפריט הזה, לכן רושמים ועוברים לפריט הבא.
//
// אסור להפוך את זה ל-catch (Exception). אם בולעים גם
// NullReferenceException או OutOfMemoryException בתור "סתם נתונים פגומים",
// אז ה-exception הלא צפוי שהוחלט בסעיף 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, המצב הכי מסוכן הוא שלולאת האב מתה והתהליך נשאר לבדו, ולכן exception לא צפוי מוביל לעצירת ה-host.
// צד הלולאה שרצה ברקע. בקשת עצירה יוצאת כמסלול תקין, וכל exception לא צפוי אחר עוצר גם את ה-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 כולו נשברו.
// לא בולעים כדי ליצור מצב של "רק התהליך נשאר חי" — עוצרים ומעבירים ל-restart.
logger.LogCritical(ex, "Worker loop failed unexpectedly. Stopping the host.");
// StopApplication היא בקשה "לסגור כרגיל". רק זה לא מספיק, כי הצד המנטר
// שמפעיל מחדש רק בכישלון (recovery של השירות, Restart=on-failure
// של systemd, מדיניות restart של container) לא יבחין בין זה לבין
// "העבודה הסתיימה בהצלחה", ו-worker לא יקום שוב.
//
// גם עצם קביעת Environment.ExitCode לא מספיקה. אם רצים כ-Windows Service,
// כשה-host נעצר כרגיל, ה-SCM מקבל SERVICE_STOPPED, ו-ExitCode לא
// משתקף במצב הסיום של השירות, ולכן פעולת ה-recovery לא תופעל.
// מסיימים את התהליך עם קוד סיום שונה מ-0
Environment.Exit(1);
}
}
}
השימוש ב-Environment.Exit(1) הוא הנקודה המרכזית כאן. IHostApplicationLifetime.StopApplication() היא בקשה לעצירה תקינה, ולכן כשמריצים כ-Windows Service, ה-SCM מקבל SERVICE_STOPPED. Environment.ExitCode של התהליך לא משתקף במצב הסיום של השירות, ולכן פעולת ה-recovery שהוגדרה במאפייני השירות (“הפעל מחדש את השירות”) לא תופעל. גם המדריך הרשמי ל-worker service מציין שהברירת מחדל BackgroundServiceExceptionBehavior.StopHost “עוצרת בצורה נקייה”, ולכן ניהול השירות של Windows לא יפעיל מחדש; כדי שפעולת ה-recovery תיכנס לפעולה צריך לקרוא ל-Environment.Exit עם קוד סיום שונה מ-0 (ראו את המקורות בפרק 11).
Environment.Exit מסיים את התהליך הנוכחי, ולכן כל לוג שרוצים להוציא לפני הנפילה צריך להיכתב לפני השורה הזו. אם משתמשים ב-logger עם buffering, צריך להוסיף flush. לעומת זאת, אם לא רצים כ-Windows Service אלא רק מול systemd או container, אפשר לקבוע Environment.ExitCode ואז לסגור עם StopApplication() — גם כך הצד המנטר יזהה את זה ככישלון. הבחירה תלויה בסביבה שבה רצים.
חשוב גם לזכור שתפקיד ה-catch משתנה בין פנים הלולאה לחוץ שלה. הפנים — רישום failure unit, החוץ — סיום חיי המערכת. אם מתבלבלים בין השניים, פריט אחד שנכשל עלול להפיל את כל האפליקציה, ולהפך — ה-worker יכול למות בזמן שהתהליך נשאר בחיים.
flowchart TB
accTitle: תפקיד ה-catch בפנים ובחוץ
accDescr: catch בפנים הלולאה תפקידו לרשום failure unit, ו-catch בחוץ הלולאה תפקידו לסיים את חיי המערכת. טעות בבלבול ביניהם מובילה או לנפילה בכישלון פריט בודד או להישארות התהליך בחיים אחרי מות ה-worker.
c1["catch בפנים הלולאה"] --> c2["רישום failure unit"]
c3["catch בחוץ הלולאה"] --> c4["סיום חיי המערכת"]
c2 -.-> ng1["בלבול = נפילה מכישלון פריט אחד"]
c4 -.-> ng2["בלבול = התהליך נשאר בחיים אחרי שה-worker מת"]
איור 14: תפקיד ה-catch שונה בין פנים הלולאה לחוץ שלה, ובלבול ביניהם גורם לתקלה בשני הכיוונים.
9.2 להפריד בין exception צפוי ללא צפוי
- צפוי: validation, not found, timeout, cancel, הפרת חוק עסקי
- לא צפוי: קריסת הנחות, דליפה מלולאת האב, exception בגבול native, סימנים ל-memory corruption
9.3 לצמצם את ה-shared state
ככל שה-shared state שניתן לכתיבה גדול יותר, כך ההחלטה בין המשך לסיום נעשית קשה יותר. לעומת זאת, ככל שאפשר לכלוא אותו במסך אחד, session אחד, worker אחד — כך קל יותר לכלוא גם את הכישלון.
9.4 להעביר עיבוד מסוכן לתהליך נפרד
עבור COM / ActiveX / vendor SDK / unsafe / עיבוד תמונה כבד / שליטה בציוד חיצוני — כל דבר שרוצים למנוע שהנזק מקריסתו יתפשט — הפרדה לתהליך נפרד עוזרת מאוד.
9.5 handler ל-unhandled exception — “תיעוד” ולא “recovery”
- מידע על ה-exception
- הקשר הפעולה
- הלוגים החשובים שקדמו לכך
- הגדרות / גרסה / יעד חיבור
- מסלול לאיסוף dump
עדיף להעדיף גיבוש כזה של המידע, כדי לאפשר להתחקות אחרי הכישלון אחרי שהוא קרה — זה בדרך כלל מוביל ליציבות טובה יותר.
ב-WPF, ה-handler לתיעוד יכול להיראות בערך כך.
// App.xaml.cs ב-WPF (.NET 8). ה-handler משמש ל"תיעוד" ולא ל"recovery".
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)
{
// רישום ה-handlers ממוקם לפני base.OnStartup(e).
// base.OnStartup מפעיל את אירוע Startup, ולכן אם קורה exception בצד
// המנוי לאירוע הזה, ה-handlers שנכתבו אחריו עדיין לא נרשמו —
// ומקבלים את המצב הכי גרוע: "נפל בהפעלה, ואין אפילו שורת לוג אחת".
// בגלל שרוצים לתעד דווקא כישלון של עיבוד ה-startup עצמו, רושמים קודם.
// exception שהגיע לא מטופל מה-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);
// exception מ-Task שלא חיכו לו ונאסף
TaskScheduler.UnobservedTaskException += (_, args) =>
{
Record("UnobservedTaskException", args.Exception);
args.SetObserved();
};
// רק אחרי שכל הרישום הסתיים, ממשיכים לעיבוד ה-startup הרגיל (הפעלת אירוע 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 רושמים את ה-handlers לפני base.OnStartup, ואז ממשיכים לעיבוד ה-startup, ובתוך ה-handler עצמו רק מתעדים exception לא צפוי בלי לנסות לתקן את המצב.
w1["רישום ה-handlers קודם"] --> w2["המשך ל-base.OnStartup"]
w2 --> w3["תיעוד unhandled exception"]
w3 -.-> w4["לא מנסים לתקן את ה-state"]
איור 15: מסיימים את הרישום לפני עיבוד ה-startup, וה-handler משמש רק לתיעוד.
9.6 לא לסמוך יתר על המידה על אירועי unhandled exception ב-WPF / WinForms
ב-WPF, קביעת Handled = true ב-DispatcherUnhandledException מאפשרת להמשיך גם אחרי unhandled exception.
גם ב-Windows Forms, ב-UI thread הראשי אפשר לבחור את דרך העצירה לפי Application.ThreadException וההגדרה של SetUnhandledExceptionMode.
אבל האם אפשר להמשיך זה עניין אחד, ו-האם תנאי ה-recovery מתקיימים זה עניין נפרד.
9.7 Environment.FailFast רק כש”לסדר בעצמו מסוכן יותר”
FailFast שמופיע בתרשים הזרימה של 3.1 הוא Environment.FailFast. ההתנהגות המתועדת רשמית היא זו:
- מסיים את התהליך בלי להריץ
try/finallyשרצים כרגע, וגם לא finalizer - ב-Windows, כותב את ההודעה שמעבירים אל Application Event Log של Windows, יוצר dump של האפליקציה ורק אז מסיים
- ההודעה ומידע ה-exception נכללים גם בדיווח שגיאות ל-Microsoft דרך Windows Error Reporting
- קריאה תחת debugger של Visual Studio מייצרת
ExecutionEngineExceptionומפעילה את ה-managed debugging assistant של fatalExecutionEngineError
זה שלא מריצים finally הוא לא חיסרון אלא המטרה של ה-API הזה. אם מריצים קוד סידור אחרי שהמצב כבר שבור, יש סיכוי לכתוב את התוכן השבור ישירות לקובץ או ל-DB. גם בתיעוד הרשמי מוסבר: כשמצב האפליקציה שבור באופן בלתי הפיך, וביצוע try / finally או finalizer עלול לשבור משאבים, משתמשים ב-FailFast ולא ב-Environment.Exit.
חלוקת השימוש נראית כך.
| מצב | מה בוחרים |
|---|---|
| ה-invariant נשבר, והרצת קוד סידור מסוכנת יותר | Environment.FailFast |
| המצב תקין, ורוצים לסדר לפני שמסיימים | עיבוד סיום תקין (למשל IHostApplicationLifetime.StopApplication) |
| רוצים פשוט להחזיר קוד סיום ולסיים | Environment.Exit או return מ-Main |
בקוד, זה נראה כך:
// C# / .NET 8. מקום שבו התגלה שה-invariant של ה-shared state נשבר.
// מכאן והלאה, אי אפשר לסמוך על אף פעולת סידור.
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 כוללת איזה invariant נשבר, ובאילו ערכים, זה מקל מאוד על החקירה בהמשך. לעומת זאת, קריאה ל-FailFast בגלל כישלון צפוי, כמו טעות קלט או שגיאת תקשורת, היא מוגזמת. זה חוזר לדיון על failure unit בסעיף 9.1.
flowchart TB
accTitle: למה FailFast לא מריץ עיבוד סידור
accDescr: כשמצב שבור באופן בלתי הפיך והרצת קוד סידור עלולה לכתוב תוכן שבור, Environment.FailFast מסיים בלי להריץ try, finally או finalizer, וב-Windows משאיר dump ותיעוד ב-Event Log.
f1["invariant נשבר"] --> f2["סיום מיידי עם FailFast"]
f2 --> f3["לא מריץ finally ולא finalizer"]
f2 --> f4["משאיר dump ותיעוד ב-Event Log (Windows)"]
f3 -.-> f5["מונע כתיבת תוכן שבור"]
איור 16: FailFast מונע כתיבת מצב שבור על ידי כך שהוא לא מריץ קוד סידור.
10. סיכום
מה שצריך לבדוק כשקורה exception לא צפוי הוא לא “אפשר לתפוס את ה-exception הזה?” אלא עדיין אפשר לסמוך על מצב האפליקציה אחרי זה?
סדר ההחלטה נראה כך, וברוב המקרים זה מספיק.
- אפשר לוותר על ה-failure unit?
- אפשר להחזיר את ה-shared state, או לבנות אותו מחדש?
- אפשר להסביר את ה-side effect החיצוני?
- אפשר לסמוך על שלמות הזיכרון / ה-threads / גבול native?
אם יש ביטחון בארבעת הנקודות האלה — אפשר להמשיך. אם אין ביטחון — עדיף לנטות לכיוון סיום.
flowchart TB
accTitle: סדר ההחלטה בסיכום
accDescr: בודקים בסדר: אפשר לוותר על ה-failure unit, אפשר להחזיר את ה-shared state, אפשר להסביר את ה-side effect, ואפשר לסמוך על שלמות המערכת כולל גבול native. אם יש ביטחון בכל ארבעתם ממשיכים, אחרת נוטים לסיום.
g1["אפשר לוותר על ה-failure unit?"] --> g2["אפשר להחזיר את ה-shared state?"]
g2 --> g3["אפשר להסביר את ה-side effect?"]
g3 --> g4["אפשר לסמוך על שלמות המערכת?"]
g4 -->|"ביטחון בכל 4"| g5["אפשר להמשיך"]
g4 -->|"אין ביטחון"| g6["נטייה לסיום"]
איור 17: עונים על ארבע השאלות בסדר, וממשיכים רק אם יש ביטחון בכולן.
בפרט באפליקציות, אפליקציות ניטור, שירותים וכלי חיבור לציוד שרצים לאורך זמן, יש הרבה מצבים שבהם להישאר בחיים במצב שבור מסוכן יותר מ-ליפול בפשטות.
טיפול בחריגות הוא לא “טכניקה למניעת נפילה”. זה תכנון שמקטין את היקף השבירה, נופל בכנות כשמשהו נשבר, ומקל על ה-recovery.
11. מקורות
- .NET: Best practices for exceptions
- .NET: Create a Windows Service using BackgroundService (הברירת מחדל
BackgroundServiceExceptionBehavior.StopHostעוצרת את ה-host בצורה נקייה, ולכן ניהול השירות של Windows לא יפעיל מחדש; כדי שפעולת ה-recovery תיכנס לפעולה, צריך לקרוא ל-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
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
למה Sleep(1) ב-Windows לא מדויק, ולמה עדיף event wait
ב-Windows דיוק של timed wait קצר תלוי ב-system clock resolution ובתזמון. כשמחכים להגעת עבודה, השלמת I/O או בקשת עצירה, עדיף event-driven ...
UAC ב-Windows: איך מפרידים רק פעולות שדורשות Administrator
באפליקציית Windows משאירים את ה-UI ב-asInvoker ומפרידים רק פעולות שדורשות הרשאות Administrator ל-helper EXE. המאמר עובר בפירוט על UAC, ru...
Secrets ביישומי Windows: DPAPI במקום plaintext ב-config
איך לא לשמור connection strings ו-API tokens ב-plaintext בקובץ config של יישום Windows. המאמר עובר על DPAPI / ProtectedData, על ההבדל בין...
Checklist מינימלי לאבטחת אפליקציות Windows
Checklist לאפליקציות WPF / WinForms / WinUI / C++ / C#: admin rights, code signing, updates, secrets, HTTPS, validation של קלט, טעינת DLL...
למה להכניס Generic Host ו-BackgroundService לאפליקציית desktop ב-.NET
בכלי Windows ובאפליקציות long-running, איך להשתמש ב-Generic Host וב-BackgroundService כדי לרכז הפעלה, עיבוד תקופתי, shutdown, לוג, הגדרות...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
ייעוץ טכני וסקירת תכנון
מדיניות exception handling, גבולות תקלה, אסטרטגיית restart וקריטריונים להמשך ריצה מתאימים לייעוץ טכני ול-design review.
חקירת תקלות ואיתור גורמים
ההחלטה בין המשך לסיום אחרי exception לא צפוי, כולל בדיקת state corruption ו-side effect חיצוני, מתקדמת היטב כחקירת תקלות וניתוח שורש.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- מותר לתפוס exception לא צפוי עם catch (Exception), לרשום ללוג ולהמשיך?
- לוג בלבד והמשך ריצה הוא בדרך כלל מסוכן. זה מסתיר את הסיבה, וגם משאיר בחיים מצב שכבר נשבר. ממשיכים רק כששלושת התנאים מתקיימים יחד: אפשר לוותר על היחידה שנכשלה, אפשר להחזיר את ה-shared state למצב תקין, ואפשר להסביר את ה-side effect החיצוני. הציר הוא לא "אפשר לתפוס את ה-exception הזה?" אלא "אפשר עדיין לסמוך על מצב האפליקציה אחרי זה?".
- אילו exceptions מחייבים סיום מיידי?
- StackOverflowException, AccessViolationException, ו-OutOfMemoryException חמור — exceptions שמעלים חשד לגבי שלמות התהליך כולו — עדיף לא לטפל בהם מתוך הנחה שאפשר להמשיך. ב-StackOverflowException מחסנית הקריאות כבר לא תקינה, וב-AccessViolationException מדובר בגישה לא חוקית לזיכרון מוגן, כלומר יש חשד ל-memory corruption. גם exception שמגיע מ-callback של COM, P/Invoke או vendor SDK קשה לשפוט רק מצד ה-managed, ולכן גם שם נוטים לסיים.
- באילו תנאים מותר להמשיך להריץ את האפליקציה גם אחרי exception?
- בדרך כלל צריך שיתקיימו יחד: failure unit ברורה (פעולה אחת, מסך אחד, job אחד, חיבור אחד — ברור מה זורקים), אפשר למחוק את ה-state ולבנות אותו מחדש, אין זיהום שמתפשט ל-shared state, אפשר להסביר את ה-side effect החיצוני, אפשר לומר למשתמש בכנות שהפעולה נכשלה, ואפשר לחקור אחר כך עם לוגים ומדדים. כשגבול העיבוד ברור, כמו פעולה אחת ב-UI או job קליטה אחד, לפעמים אפשר להמשיך. לעומת זאת, exception באמצע עדכון shared state, בלולאת האב, ב-startup או בגבול native נוטה לכיוון סיום.
- אם מגדירים Handled=true ב-DispatcherUnhandledException של WPF, אפשר להמשיך?
- אפשר טכנית להמשיך גם אחרי unhandled exception, אבל האפשרות להמשיך והבטיחות שבהמשך הן שני דברים שונים. handlers כמו AppDomain.UnhandledException או DispatcherUnhandledException שימושיים כמקום תיעוד אחרון, אבל הם לא נקודת recovery קסומה. עדיף לאסוף מידע על ה-exception, הקשר הפעולה ומסלול לאיסוף dump, כדי שאפשר יהיה לחקור אחרי הקריסה — זה בדרך כלל מוביל ליציבות טובה יותר. בפרט בשירותים ואפליקציות ניטור שרצות לאורך זמן, קריסה עם restart בדרך כלל בטוחה וקלה יותר לאבחון מאשר הישרדות במצב חצי-שבור.