Handle leak באפליקציית מצלמה תעשייתית שקורסת אחרי ריצה ארוכה

· עודכן בתאריך: · · פיתוח Windows, חקירת תקלות, מצלמה תעשייתית, handle leak, תכנון לוגים

היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 11 Mar 2026)
פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22173352)

מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.

Go Komura (2026). Handle leak באפליקציית מצלמה תעשייתית שקורסת אחרי ריצה ארוכה. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173352 https://comcomponent.com/he/blog/handle-leak-industrial-camera-long-run-crash-part1/

DOI (הגרסה האחרונה)
10.5281/zenodo.22173352
DOI (הגרסה הזו)
10.5281/zenodo.22173353

כשאפליקציית Windows קורסת פתאום אחרי ריצה ארוכה, נוטים מאוד לחשוד קודם ב-memory leak. עם זאת, לא מעטים המקרים שבהם האשם האמיתי הוא handle leak, שרק אחרי שבועות מתגלה סוף סוף כתקלה משנית.

המקרה שמוצג כאן הוא חקירה של אפליקציית Windows ששולטת במצלמה תעשייתית, וקורסת פתאום אחרי כחודש של הפעלה רציפה. אחרי פירוק, התברר שהסיבה הייתה handle leak ב-failure path סביב reconnect של המצלמה.

בפרק הראשון נסביר מה זה handle leak, איך פירקנו את המקרה הזה, ואילו לוגים כדאי להשאיר כדי שזה לא יחזור. בפרק השני נדבר על תשתית בדיקות למקרי קצה, תחת הכותרת תשתית בדיקות למקרי קצה ב-Windows עם Application Verifier.

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

תוכן עניינים

  1. השורה התחתונה
  2. מה זה handle leak
    • 2.1. ה-handle שמדובר בו כאן
    • 2.2. למה זה נוטה להתגלות רק אחרי ריצה ארוכה
    • 2.3. ההבדל מ-memory leak
  3. מקרה: אפליקציית בקרת מצלמה תעשייתית שקורסת פתאום אחרי חודש
    • 3.1. הסימפטומים שהופיעו
    • 3.2. המדדים הראשונים שנבדקו
    • 3.3. מקום הדליפה שהיה הגורם האמיתי
  4. איך פירקנו את זה
    • 4.1. לקצר את הזמן בלי לחכות לשחזור בסדר גודל של חודשים
    • 4.2. הסתכלות לפי ה-slope של Handle Count
    • 4.3. הסתכלות על ההתאמה בין create/open לבין close/dispose
    • 4.4. ב-handle leak מחפשים את “מקום הדליפה” ולא את “מקום הקריסה”
  5. הלוגים שצריך כדי שזה לא יחזור
    • 5.1. הסט המינימלי שכדאי להשאיר קודם
    • 5.2. הלוגים שחיזקנו בפועל
    • 5.3. באיזו רזולוציה לתעד
  6. מתי מה לבדוק, בגדול
  7. סיכום
  8. מקורות

ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 18, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle

1. השורה התחתונה

  • באפליקציית בקרה שקורסת רק אחרי ריצה ארוכה, יש להסתכל תמיד לא רק על Private Bytes אלא גם על Handle Count
  • handle leak נוטה להסתתר לא ב-success path, אלא במסלולים של timeout / reconnect / כשל באמצע הפעולה / early return
  • השורה שבה בפועל קורסת האפליקציה היא לרוב לא המקום שבו דלף ה-handle, אלא המקום שבו כבר לא ניתן היה ליצור handle חדש
  • הלוגים הראשונים שנדרשים הם: הקשר של operation/session, ה-handle count של ה-process, ההתאמה בין open ל-close של המשאב, ושגיאות Win32 / HRESULT / SDK
  • עדיף להריץ בלולאה קצרה אלפי פעמים חיבור, ניתוק, reconnect ומסלולי כשל, מאשר לחכות לשחזור בסדר גודל של חודשים
  • Application Verifier, שיוזכר בפרק השני, יעיל מאוד, אבל לפני כן הבסיס הוא לוודא שאפשר לעקוב אחרי קריסת lifetime באמצעות הלוגים שלכם

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

handle leak לרוב כבר נראה כתקלה משנית ברגע שמגלים אותו. לכן, אם מסתכלים רק על ה-exception ברגע הקריסה, קל ללכת בכיוון הלא נכון.

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

איור 1: לפני שמסתכלים על “עובדת הקריסה”, כדאי להפוך את קצב הגידול ומסלולי הכשל לניתנים לתצפית.

2. מה זה handle leak

2.1. ה-handle שמדובר בו כאן

ה-handle שמדובר בו כאן הוא מזהה שדרכו process ב-Windows מפנה למשאב של מערכת ההפעלה. דוגמאות למשאבים כאלה:

קטגוריה דוגמאות
kernel objects event, mutex, semaphore, thread, process, waitable timer
I/O open עבור file, pipe, socket, device
דברים נפוצים בבקרת התקנים event פנימי של ה-SDK של המצלמה, wait object שמקושר לרישום callback, handles שקשורים ל-thread הצילום

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

התהליך האופייני נראה כך:

  • בכל reconnect נוצר event אחד
  • רישום ה-callback או תחילת הצילום נכשלים באמצע
  • ב-success path מתבצע close, אבל ב-failure path לא
  • בבדיקות קצרות שגרתיות עוברים בעיקר ב-success path, ולכן זה מתפספס

דפוס מהסוג הזה מסתתר בקלות רבה, גם ב-code review וגם בהפעלה בפועל.

תבנית אופיינית של דליפה רק ב-failure pathבכל reconnect נוצר event, ואם רישום ה-callback או תחילת הצילום מצליחים מתבצע close ב-success path, אך ב-failure path באמצע לא מתבצע close והדליפה מתפספסת בבדיקות קצרות שעוברות בעיקר ב-success path.הצלחהכשל באמצעבכל reconnect נוצר eventהרישום וההתחלה הצליחו?close ב-success pathאין close ב-failure pathבדיקות קצרות מפספסות, כי הן עוברות ב-success path

איור 2: שכחת סגירת משאב שנפתח זמנית ב-failure path באמצע. צורה נפוצה במיוחד באפליקציות בקרה.

2.2. למה זה נוטה להתגלות רק אחרי ריצה ארוכה

handle leak לא בהכרח שובר הכול בבת אחת בפעם הראשונה. מה שבאמת מטריד הוא דליפה עם slope קטן, למשל דליפה של יחידה אחת בלבד בכל כשל בודד.

השרשרת מהפעלה רגילה ועד לקריסה בעקבות דליפה קטנהבהפעלה רגילה מתרחשים מדי פעם timeout או reconnect שיוצרים Event Handle ב-failure path בלי שנקרא CloseHandle, כך ש-Handle Count גדל מעט בכל פעם, וזה חוזר על עצמו מאות פעמים עד ש-CreateEvent או פתיחת ה-SDK נכשלים וגורמים לקריסה או עצירה במקום אחר.הפעלה רגילהמדי פעם timeout / reconnectיצירת Event Handle ב-failure pathCloseHandle לא נקראHandle Count גדל מעטחוזר על עצמו מאות פעמיםCreateEvent / פתיחת ה-SDK נכשליםקריסה / עצירה במקום אחר

איור 3: דליפה קטנה של יחידה אחת בכל פעם, מצטברת מאות פעמים בתנאי הגבול של הפעלת 24/7, ואז מתגלה.

אם בכל reconnect דולף רק handle אחד, לא קורה שום דבר תוך כמה דקות. אבל באפליקציית בקרת התקנים שרצה 24/7, תנאי גבול כמו timeout, אתחול מחדש והתאוששות מניתוק קורים שוב ושוב. כתוצאה מכך, נוצר מראה מוזר: התקלה מתגלה רק אחרי שבועות.

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

  • קריאת API שיוצרת event / file / thread חדש נכשלת
  • ה-SDK לא מצליח ליצור פנימית משאב נדרש, ומחזיר רק קוד כשל כללי
  • טיפול השגיאות אחרי הכשל דל, והאפליקציה דורכת על null או על handle לא תקין ונופלת
  • מספר ה-timeout גדל, ובסופו של דבר watchdog או בקרה מסדר גבוה יותר הורגים את ה-process

כלומר, נקודת הקריסה היא ה”קורבן האחרון”, ולאו דווקא “החשוד הראשון”.

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

איור 4: הדליפה עצמה לא בהכרח הופכת לשורת הקריסה. בדרך כלל השבירה היא תקלה משנית.

כאן עולה שאלה תמימה: למה זה קורס בגלל בסך הכול כמה אלפי handles?

אם מסתכלים רק על המספרים, הגבול העליון רחוק מאוד. הגבול העליון התיאורטי ל-handles של kernel objects הוא 2^24 (כ-16.77 מיליון) ל-process. אבל מכיוון שה-handles ממוקמים ב-page pool, המספר שבפועל ניתן ליצור נקבע לפי הזיכרון הזמין, וב-Windows בגרסת 32-bit המספר קטן בהרבה מהערך התיאורטי.

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

מה נתקע בתקרה קודם סדר גודל היכן זה משפיע
GDI objects תיאורטית 65,536 לכל session, ובנוסף יש גבול ברירת מחדל ל-process שניתן לשנות בערך GDIProcessHandleQuota ב-Registry, בטווח של 256 עד 65,536 אפליקציות שיש בהן גם GUI. בקלות רבה נתקעים בתקרה הזו כבר בסדר גודל של אלפים
טבלת הניהול הפנימית של ה-SDK תלוי ביצרן טבלת ה-handles או המערך באורך קבוע שה-SDK של המצלמה מחזיק פנימית מתמלאים קודם
משאבי kernel כמו ה-page pool משותפים לכל המכונה כשגם משאבים אחרים חוץ מ-handles נצרכים באותו זמן
מרחב הכתובות הווירטואלי של process ב-32-bit 2GB / 3GB לא ה-handle עצמו, אלא ה-buffer שמוקצה יחד איתו הוא מה שמשפיע

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

מה נתקע בתקרה לפני הגבול התיאורטיהמקרים שבהם מגיעים לגבול התיאורטי של kernel handles הם מיעוט, ובפועל נתקעים קודם בגבול GDI objects, בטבלת הניהול הפנימית של ה-SDK, או במרחב הכתובות של process ב-32-bit, ולכן יש להסתכל אם המשאב חוזר ולא על הגבול עצמו.הגבול התיאורטי של ה-kernel כ-16.77 מיליוןהגעה עד לשם היא המיעוטמה שבפועל נתקע קודםהגבול של GDIטבלת הניהול הפנימית של ה-SDKמרחב הכתובות של 32-bitשיפוט לפי האם המשאב חוזר

איור 5: “יש עוד מרווח עד הגבול” לא מרגיע. ברגע שנוצר slope, יש להתייחס לזה כאל חריגה.

2.3. ההבדל מ-memory leak

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

היבט memory leak handle leak
המדד הראשון שבודקים Private Bytes, Commit, Working Set Handle Count
סימפטום אופייני לחץ זיכרון, paging, האטה, OOM כשל ב-Create* / Open* / אתחול פנימי של ה-SDK, תקלה משנית
היכן זה נוטה להסתתר cache, החזקת הפניה, שכחת שחרור אי-סימטריה בין create/open לבין close/dispose
איך זה נראה הזיכרון גדל בהדרגה handle count גדל בהדרגה ולא חוזר

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

מדדים שכדאי להסתכל עליהם יחד בפירוק ריצה ארוכהmemory leak ו-handle leak נבדקים במדדים שונים, ולכן הסתכלות רק על מדדי זיכרון כמו Private Bytes אינה מספיקה. יש להסתכל יחד גם על Handle Count וגם על Thread Count כדי להימנע ממצב של תמונה חלקית.פירוק תקלה אחרי ריצה ארוכהמדדי זיכרון (כמו Private Bytes)Handle CountThread Countגדל ולא חוזר — סימן ל-handle leak

איור 6: הסתכלות רק על הזיכרון היא תמונה חלקית. יש לעקוב באותו מסך גם אחרי מספר ה-handles וה-threads.

3. מקרה: אפליקציית בקרת מצלמה תעשייתית שקורסת פתאום אחרי חודש

3.1. הסימפטומים שהופיעו

התופעה הייתה פשוטה:

  • אפליקציית Windows ששולטת במצלמה תעשייתית פועלת 24/7
  • בזמן רגיל היא פועלת כרגיל
  • אחרי כחודש, יום אחד היא קורסת פתאום
  • אחרי restart, שוב פועלת כרגיל למשך זמן מה

מה שמקשה בהתחלה הוא “שלוקח הרבה זמן עד שזה קורס”. לחכות חודש לכל שחזור בודד זה קשה מאוד לחקירה.

מה שהקשה עוד יותר הוא שמקום הקריסה לא היה בדיוק זהה בכל פעם. לפעמים זה קרה מיד אחרי תחילת reconnect, לפעמים בתחילת הצילום, ולפעמים אחרי כשל בקריאה ל-SDK.

עם הופעה כזו, בהתחלה אפשר לחשוד בכל אחד מאלה:

  • חוסר יציבות בצד ה-SDK של המצלמה
  • תקלה זמנית שמקורה בתקשורת או בניתוק ההתקן
  • memory leak
  • race סביב threads
  • כשל אתחול שלא נכנס ללוג

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

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

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

3.2. המדדים הראשונים שנבדקו

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

מדד המגמה שנצפתה הפרשנות
Handle Count גדל מעט בכל פעם אחרי reconnect או timeout, ולא חוזר חשד ל-handle leak
Private Bytes יש עליות וירידות, אבל ה-slope החד-כיווני חלש ייתכן שהאשם אינו ה-heap
Thread Count כמעט קבוע סיכוי נמוך ל-thread leak
מקום הקריסה קצת שונה בכל פעם סביר להניח שזו תקלה משנית

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

ההשערה שהצטמצמה מתצפית המדדים הראשוניתהתצפית שלפיה רק Handle Count גדל ולא חוזר, slope של Private Bytes חלש, Thread Count כמעט קבוע, ומקום הקריסה שונה בכל פעם, הובילה להצטמצמות ההשערה לכיוון של דליפה הדרגתית שמובילה לקריסה אחרי חודש.Handle Count גדל ולא חוזרנקודת המבט מצטמצמתslope של Private Bytes חלשThread Count כמעט קבועדליפה הדרגתית שקורסת אחרי חודש

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

3.3. מקום הדליפה שהיה הגורם האמיתי

בסופו של דבר, הגורם היה שכחת close של event handle שנוצר ב-failure path של האתחול בזמן reconnect של המצלמה.

אם מפשטים את התהליך, זה נראה כך:

התהליך שבו הדליפה ב-failure path של ה-reconnect מובילה לקריסההאפליקציה יוצרת Event, נכשלת ברישום ה-callback, יוצאת ב-failure path בלי לקרוא CloseHandle, ו-Handle Count גדל בהדרגה עם כל reconnect עד שקריאה חדשה ל-CreateEvent או ל-Open נכשלת וגורמת לקריסה כתקלה משנית.ה-SDK של המצלמהWindowsאפליקציית הבקרהה-SDK של המצלמהWindowsאפליקציית הבקרהיציאה (return) ב-failure pathCloseHandle לא נקראloop[reconnect שוב ושוב]CreateEventרישום callbackכשל באמצע / timeoutHandle Count גדל מעט בכל פעםCreateEvent / Open הבאכשלקריסה כתקלה משנית

איור 9: הגורם האמיתי היה שכחת close של event handle ב-failure path של ה-reconnect. אחרי הצטברות, הקריסה קורית במקום אחר.

כדי לתת מושג לגבי הקוד, זו הצורה של הדליפה:

handle = CreateEvent(...)

if (!RegisterCallback(handle))
{
    return Error;   // CloseHandle(handle) חסר כאן
}

if (!StartAcquisition())
{
    return Error;   // גם כאן חסר close
}

...
CloseHandle(handle)

גם הסיבה לכך שקל לפספס את זה בבדיקות קצרות די ברורה:

  • הפעלה תקינה -> סיום תקין: מתבצע close
  • הכשל קורה רק באמצע reconnect
  • אין בדיקה שעוברת בכמות גדולה ב-failure path הזה
  • בסביבת הייצור זה מצטבר בהדרגה במשך שבועות

כלומר, המבנה היה: “לא נראה אם מסתכלים רק על ה-success path, אבל דולף באופן שגרתי ב-failure path”.

כיוון התיקון לא מרשים במיוחד:

  • לקרב את האחריות של create/open לזו של close/dispose
  • להעביר את השחרור ל-finally / destructor / אובייקט ה-session, כך שהשחרור יתבצע תמיד גם בכשל באמצע
  • להבהיר את ה-ownership לפני ואחרי רישום ה-callback או תחילת הצילום
  • לבטא את “מי סוגר” באחריות הקוד עצמו, ולא ב-comments
עיקרי כיוון התיקוןכיוון התיקון הוא לקרב את האחריות של create לזו של close, להעביר את השחרור ל-finally, לדסטרוקטור או לאובייקט ה-session כדי שהוא יתבצע תמיד גם בכשל באמצע, ולבטא את מי שסוגר באחריות הקוד ולא בהערות.כיוון התיקוןקירוב האחריות של create ו-closeהעברת השחרור ל-finally או לדסטרוקטורביטוי הבעלות באחריות הקודלא להסתמך על מוסכמה שמבוססת רק על comments

איור 10: זה לא תיקון מרשים. משתילים את מחזור החיים של המשאב במבנה הקוד עצמו.

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

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

// C++17 / Windows
#include <windows.h>
#include <utility>

class UniqueHandle
{
public:
    UniqueHandle() noexcept = default;
    explicit UniqueHandle(HANDLE h) noexcept : h_(h) {}

    UniqueHandle(const UniqueHandle&) = delete;
    UniqueHandle& operator=(const UniqueHandle&) = delete;

    UniqueHandle(UniqueHandle&& other) noexcept
        : h_(std::exchange(other.h_, nullptr)) {}

    UniqueHandle& operator=(UniqueHandle&& other) noexcept
    {
        if (this != &other)
        {
            reset(std::exchange(other.h_, nullptr));
        }
        return *this;
    }

    ~UniqueHandle() { reset(); }

    HANDLE get() const noexcept { return h_; }
    explicit operator bool() const noexcept { return h_ != nullptr; }

    void reset(HANDLE h = nullptr) noexcept
    {
        if (h_ != nullptr)
        {
            ::CloseHandle(h_);
        }
        h_ = h;
    }

private:
    HANDLE h_ = nullptr;
};

בעזרת זה, אין צורך להוסיף CloseHandle באופן ידני בכל failure path.

// שדה ב-CameraSession: UniqueHandle frameReady_;
bool CameraSession::Reconnect()
{
    UniqueHandle frameReady{ ::CreateEventW(nullptr, TRUE, FALSE, nullptr) };
    if (!frameReady)
    {
        return false;   // היצירה עצמה נכשלה. אין מה לסגור
    }

    if (!RegisterCallback(frameReady.get()))
    {
        return false;   // גם אם עושים return כאן, הדסטרוקטור יסגור
    }

    if (!StartAcquisition())
    {
        // כשל אחרי שהרישום כבר הושלם: קודם מבטלים את הרישום ורק אז סוגרים.
        // אם יוצאים בלי לבטל את הרישום, הדסטרוקטור כן יקרא ל-CloseHandle,
        // אבל ה-SDK עדיין מחזיק את ה-handle שהועבר אליו. בפריים הבא הוא
        // עשוי לאותת למספר שכבר שוחרר, ואם אותו מספר מוקצה מחדש למשאב
        // אחר, זה יתגלה כ"אירוע לא קשור שקם מעצמו"
        UnregisterCallback();
        return false;
    }

    // רק כשמצליחים, מעבירים את הבעלות לצד ה-session
    frameReady_ = std::move(frameReady);
    return true;
}

ב-C#, במקרים רבים לא מספיק using בודד, ולכן מציבים דגל של “האם הועברה הבעלות”, וזורקים את המשאב ב-finally רק כשהיא לא הועברה. אם כותבים סתם using var, המשאב יושמד גם כשההצלחה מתרחשת.

// C# / .NET 8
// שדה ב-CameraSession: private ManualResetEvent? _frameReady;
public bool Reconnect()
{
    var frameReady = new ManualResetEvent(false);
    var handedOver = false;
    var registered = false;

    try
    {
        if (!RegisterCallback(frameReady))
        {
            return false;
        }

        registered = true;

        if (!StartAcquisition())
        {
            return false;
        }

        _frameReady?.Dispose();
        _frameReady = frameReady;
        handedOver = true;
        return true;
    }
    finally
    {
        if (!handedOver)
        {
            // לפני שזורקים, קודם מבטלים את ההפניה שהצד החיצוני מחזיק בה.
            // ה-SDK מחזיק את ה-handle שהועבר אליו בעת הרישום, ולכן אם הופכים
            // את הסדר, יגישו קריאה ל-handle שכבר שוחרר
            if (registered)
            {
                UnregisterCallback();
            }

            frameReady.Dispose();
        }
    }
}

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

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

איור 11: לא כותבים “אם נכשל, לסגור” בכל מקום מחדש. יעד הבעלות קובע מי אחראי לשחרור.

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

4. איך פירקנו את זה

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

מונח בעברית המשמעות במאמר הזה
baseline ערך בסיס הערך שמתייצב אחרי שה-warm-up מסתיים. משם מסתכלים על ההפרש
leakSlope slope של הדליפה כמה גדל בכל מחזור. מדד עצמאי שמייצג את קצב הגידול
structured log לוג מובנה לוג שבו הפריטים קבועים בסגנון key=value, במקום פרוזה. אפשר לרכז אותו אחר כך באופן אוטומטי
heartbeat דיווח תקופתי לוג שמוציא בקצב קבוע אישור חיים וערכי משאבים
harness מסגרת בדיקה תוכנית הרצה קטנה שמריצה שוב ושוב רק את הפעולה שרוצים לבדוק, במקום האפליקציה המלאה
phase שלב סימון כמו OpenStart או ReconnectStart, שמראה באיזה שלב של העיבוד נמצאים כרגע

4.1. לקצר את הזמן בלי לחכות לשחזור בסדר גודל של חודשים

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

במקרה הזה, כיווצנו את השחזור בעזרת לולאה כזו:

לולאת השחזור המכווץלולאת השחזור מתחילה בהפעלה, פתיחת מצלמה והתחלת צילום, ואז מדמה timeout או ניתוק, מבצעת reconnect וחידוש צילום, וחוזרת על כך N פעמים לפני בדיקת ההפרש בסיום.כןלאהפעלהopen של המצלמהתחילת צילוםדימוי timeout / ניתוקreconnectחידוש הצילוםלחזור N פעמים?בדיקת ההפרש בסיום

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

הנקודה היא להשקיע זמן בפעולות הגבול של ה-lifetime, ולא בזמן ה”צילום הרגיל”.

התרחישים שבאמת עוזרים הם כאלה:

  • להריץ open -> start -> stop -> close בכמות גדולה
  • לגרום ל-timeout בכוונה כדי להריץ reconnect
  • לגרום לכשל מיד אחרי רישום ה-callback
  • להכניס הפרעת ניתוק, הפרעת reconnect ו-race בזמן shutdown

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

4.2. הסתכלות לפי ה-slope של Handle Count

לפני זה, נכתוב איפה בכלל מסתכלים על Handle Count. בלי זה, כל הסעיף הזה נשאר תיאורטי בלבד.

שיטה פעולה מתאים למקרה
Task Manager פותחים את לשונית Details, לוחצים ימני על כותרת העמודה → Select columns → מסמנים Handles רוצים לראות מהר כמה יש כרגע
Process Explorer בוחרים את ה-process, פותחים Properties, ורואים את Handle Count בלשונית Process Performance. סידור חלונית ה-Handles התחתונה לפי Type מראה גם פירוט לפי סוג רוצים לדעת אילו handles גדלים
handle.exe handle -s -p CameraApp נותן ריכוז טקסטואלי לפי סוג רוצים לתעד תצפית קבועה בלוג
PowerShell Get-Process -Name CameraApp \| Select-Object Name, Id, HandleCount רוצים לאסוף באופן תקופתי בסקריפט
typeperf typeperf "\Process(CameraApp)\Handle Count" -si 60 -sc 1440 -o handles.csv רוצים לתעד CSV לאורך זמן רב
האפליקציה עצמה הטמעת GetProcessHandleCount או Process.HandleCount בתוך לוג ה-heartbeat רוצים לאסוף רק לוגים ממכונת הייצור

פירוט לפי סוג ואיך לעקוב אחרי גידול של events בלי שם מסוכם כשלבים בכתובת Process Explorer / Handle / VMMap בפועל.

בחקירה של ריצה ארוכה, השיטה המרכזית היא זו האחרונה בטבלה — “האפליקציה עצמה מוציאה”. אדם שמסתכל על Task Manager לא יכול להמשיך בכך 24/7.

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

בתור שיטת הסתכלות, הסדר הבא בדרך כלל ברור:

  1. קביעת baseline אחרי ה-warm-up
  2. תיעוד Handle Count אחרי reconnect / start-stop / close
  3. בדיקת ההפרש בכל מחזור
  4. בדיקת ה-slope המצטבר על פני כמה מחזורים

לדוגמה, כך אפשר להסתכל:

leakSlope =
    (currentHandleCount - baselineHandleCount)
    / reconnectCount

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

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

  • מיד אחרי ההפעלה יש עלייה — את זה לא קוראים
  • אחרי שה-warm-up נגמר, הערך אמור לעלות ולרדת בהתאם לפעולות, אבל לנוע בתוך טווח קבוע
  • אחרי מחזור אחד של open -> start -> stop -> close, נורמלי שהערך יחזור כמעט לאותו ערך שהיה לפני המחזור
  • אם אחרי 100 מחזורים ההפרש מה-baseline הוא בסדר גודל של יחידות בודדות, זה בריא ברובו
  • לעומת זאת, אם זה עולה בקו ישר ביחס ישר למספר המחזורים, אז זה דולף בדיוק בקצב ה-slope הזה בכל מחזור

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

איך קוראים את ה-slope של Handle Countאחרי warm-up קובעים baseline, בודקים את ההפרש בכל מחזור, ואם הערך חוזר אחרי המחזור זה בריא ברובו, ואם הוא עולה בקו ישר ביחס למספר המחזורים אז יש דליפה קבועה בכל מחזור.חוזרעולה בקו ישרקביעת baseline אחרי warm-upבדיקת ההפרש בכל מחזורהערך חוזר אחרי המחזור?בריא ברובודליפה קבועה בכל מחזור

איור 13: לא שופטים לפי הערך המוחלט, אלא לפי הצורה — “חוזר או לא חוזר”.

הטיפ כאן הוא לא להסתכל רק על Handle Count, אלא לתעד לפחות גם את הבאים:

  • Handle Count
  • Private Bytes
  • Thread Count
  • ReconnectCount
  • ה-phase הנוכחי

כך אפשר להבין הרבה יותר מהר האם “הזיכרון גדל”, “ה-threads גדלים”, או “המשאב לא חוזר בכל reconnect”.

4.3. הסתכלות על ההתאמה בין create/open לבין close/dispose

גם אם מתגלה ש-Handle Count של ה-process כולו חשוד, זה לבד לא מגיע עד מקום הדליפה. מה שצריך אחר כך הוא לוג שמראה את מחזור חיי המשאב כזוגות.

בתור דוגמה, זה נראה כמו structured log כזה:

CameraSession session=421 cameraId=CAM01 phase=ReconnectStart reason=FrameTimeout handleCount=1824 privateBytesMB=418

CameraResource session=421 resourceId=evt-884 kind=Event name=FrameReady action=Create osHandle=0x00000ABC handleCount=1825

CameraResource session=421 resourceId=evt-884 kind=Event name=FrameReady action=Close osHandle=0x00000ABC handleCount=1824

החשוב כאן הוא לא להסתמך רק על osHandle. מכיוון שערך ה-handle ב-Windows עשוי לעבור reuse בהמשך, קל יותר לעקוב אם מוסיפים בלוג לפחות את הבאים:

  • sessionId
  • resourceId
  • kind
  • action(Create/Open/Register/Close/Dispose/Unregister)
  • osHandle
  • phase

כך קל יותר למצוא זרימה חד-צדדית — יש Create, אבל אין Close.

לוג שעוקב אחרי מחזור חיי המשאב כזוגותרישום Create ו-Close כזוגות וקישורם באמצעות sessionId ו-resourceId מאפשר למצוא Create שלא קיבל Close, וה-osHandle עובר reuse ולכן אי אפשר לעקוב לפיו בלבד.רישום Create ו-Close כזוגותקישור לפי sessionId ו-resourceIdגילוי Create שלא קיבל CloseosHandle עובר reuse, אי אפשר להסתמך עליו לבדו

איור 14: כדי לרדת ממספר כולל ב-process אל מקום הדליפה, נדרש לוג שמזווג את מחזור חיי המשאב.

4.4. ב-handle leak מחפשים את “מקום הדליפה” ולא את “מקום הקריסה”

הנקודה הזו חשובה מאוד.

handle leak לרוב נראה כך:

  • השורה שקורסת: כשל ב-CreateEvent
  • הדליפה האמיתית: כמה ימים לפני כן, CloseHandle היה חסר ב-failure path

כלומר, ה-API שקרס לבסוף הוא יציאת הנזק, ולא בהכרח כניסת הגורם.

לכן, סדר החקירה הוא כזה:

  1. לבדוק אילו משאבים ממשיכים לגדול
  2. לבדוק באיזה גבול פעולה הם לא חוזרים
  3. לחפש איפה ההתאמה בין create/open לבין close/dispose התקלקלה
  4. בסוף לקרוא את נקודת הקריסה

בסדר הזה, קל בהרבה שלא ללכת לאיבוד.

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

איור 15: נקודת הקריסה היא רק היציאה. יש להתחיל מ”מקום הדליפה” שהוא הכניסה.

5. הלוגים שצריך כדי שזה לא יחזור

5.1. הסט המינימלי שכדאי להשאיר קודם

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

כמינימום, כדאי להשאיר את הבאים:

קטגוריה פריטים מינימליים רצויים הסיבה
הקשר הפעולה cameraId, sessionId, operationId, reconnectCount, phase כדי לקשר איזו פעולה ובאיזה מספר סיבוב זה קרה
משאבי ה-process handleCount, privateBytes, workingSet, threadCount כדי לפרק קודם מה בעצם גדל
מחזור חיי המשאב action, resourceId, kind, osHandle, owner כדי לעקוב אחרי ההתאמה בין create/open לבין close/dispose
תוצאת קריאות חיצוניות win32Error, HRESULT, sdkError, timeoutMs כדי להשוות אחר כך בין סוגי הכשל
מעברי מצב OpenStart, OpenDone, ReconnectStart, ReconnectDone, ShutdownStart וכדומה כדי לדעת באיזה שלב באמצע ה-phase המצב התקלקל
סביבת ההרצה pid, tid, buildVersion, machineName כדי להתאים בין dump, symbol וחומר ההפצה

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

5.2. הלוגים שחיזקנו בפועל

במקרה הזה, חיזקנו את הלוגים בכיוונים הבאים:

  1. heartbeat תקופתי
    • הוצאת Handle Count / Private Bytes / Thread Count / ReconnectCount כל 1–5 דקות
  2. לוג גבולות לפי session של המצלמה
    • OpenStart
    • CallbackRegistered
    • AcquisitionStart
    • TimeoutDetected
    • ReconnectStart
    • ReconnectDone
    • CloseStart
    • CloseDone
  3. לוג מחזור חיי המשאב
    • Create/Open/Register ו-Close/Dispose/Unregister עבור event / thread / file / timer / SDK registration token
  4. נרמול השגיאות
    • לא להסתפק בהודעת ה-exception לבדה, אלא להוציא יחד win32Error, HRESULT, sdkError, phase

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

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

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

5.3. באיזו רזולוציה לתעד

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

בתור רזולוציה, החלוקה הבאה בערך ריאלית:

  • ניטור תקופתי
    • Handle Count, Private Bytes, Thread Count, ReconnectCount
  • גבולות פעולה
    • התחלה, סיום וכישלון של ה-session
  • גבולות משאב
    • create/open/register מול close/dispose/unregister
  • פירוט בזמן חריגה
    • error code, stack, טריגר לאיסוף dump

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

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

איור 17: במקום פירוט לכל פריים, מסדרים רזולוציה שממנה אפשר לקרוא “מי פתח ומי סגר”.

6. מתי מה לבדוק, בגדול

  • קורס רק אחרי ימים עד שבועות
    • קודם כול, להוסיף heartbeat של Handle Count / Private Bytes / Thread Count
  • יש retry / reconnect / shutdown
    • קודם להכין harness שמריץ בכמות גדולה רק את הגבולות האלה
  • שימוש נרחב ב-native SDK / P/Invoke / Win32
    • שווה מאוד להפעיל את Application Verifier מהפרק השני
  • יש גם GUI
    • כדאי להסתכל, מלבד Handle Count, גם על GDI Objects / USER Objects
  • ה-exception ברגע הקריסה לבדו לא אומר כלום
    • מהיר יותר לסדר קודם structured log של operation / session / מחזור חיי המשאב

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

7. סיכום

באפליקציה שקורסת רק אחרי ריצה ארוכה, יש להסתכל לא רק על הזיכרון אלא גם על Handle Count. handle leak נוטה להסתתר לא ב-success path אלא ב-failure path של המסלול החריג, ונקודת הקריסה היא לרוב יציאה של תקלה משנית ולא נקודת הדליפה עצמה. כשמסתכלים על הסימפטומים, זה בעצם מצטמצם לשלוש הנקודות האלה.

כדי שזה לא יחזור, מקרבים את האחריות של create/open לזו של close/dispose, משאירים לוג עם הקשר לפי session / operation, ומתעדים גם את משאבי ה-process וגם את מחזור חיי המשאב. בבדיקות, בלי לחכות לשחזור בסדר גודל של חודשים, מריצים timeout / reconnect / shutdown בלולאה קצרה, וקובעים כתנאי הצלחה לא רק “לא להישבר” אלא גם “אפשר לעקוב כשזה נשבר”. זה השילוב שעזר במקרה הזה. בפרק השני נשתמש ב-Application Verifier כדי לחשוף מראש שברים קשים להתגלות, כמו מחסור בזיכרון או תקלת handle.

באפליקציות בקרה, חשוב שה-success path יעבוד, אבל היכולת “לדעת מה קרה” כשזה נשבר יעילה מאוד בהפעלה ארוכת טווח.

handle leak הוא בדיוק סוג התקלה שבו ההבדל הזה משפיע. במקום להסתכל רק על רגע ההתרחשות, הסתכלות על קצב הגידול, הגבולות והזוגות של האחריות הופכת את המעקב לקל בהרבה.

פרק שני: תשתית בדיקות למקרי קצה ב-Windows עם Application Verifier

8. מקורות

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

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

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

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

שאלות נפוצות

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

מה זה handle leak?
מצב שבו process ב-Windows שוכח לסגור handle שמפנה למשאב של מערכת ההפעלה כמו event, mutex, file או socket, וה-Handle Count ממשיך לגדול. הדפוס הנפוץ במיוחד הוא שכחת סגירה של משאב שנפתח זמנית עבור פעולה מסוימת, ב-failure path באמצע הפעולה כמו timeout, reconnect או early return. בבדיקות קצרות שגרתיות עוברים בעיקר ב-success path, ולכן קל לפספס את זה.
איך מבחינים בין memory leak ל-handle leak?
המדדים שונים. ב-memory leak, Private Bytes או Commit גדלים בהדרגה. ב-handle leak, Handle Count גדל בהדרגה ולא חוזר. בפירוק תקלה אחרי ריצה ארוכה, הסתכלות רק על הזיכרון היא כמו לראות רק חצי מהתמונה, ולכן הבסיס הוא להסתכל גם על Handle Count וגם על Thread Count. אם יש גם GUI, כדאי להסתכל גם על GDI Objects / USER Objects.
אם יש handle leak, למה זה קורס רק אחרי ריצה ארוכה?
דליפה עם slope קטן — למשל handle אחד בודד שדולף בכל כשל — לא גורמת לשום דבר תוך כמה דקות. אבל בהפעלה 24/7, תנאי גבול כמו timeout או reconnect קורים שוב ושוב, ומצטברים במשך שבועות. בסוף, ברגע שקריאת API שיוצרת event/file/thread חדש נכשלת, זה מתגלה כתקלה משנית. חשוב גם שנקודת הקריסה לרוב אינה המקום שבו דלף ה-handle, אלא הקורבן האחרון בשרשרת.
איך חוקרים handle leak?
בלי לחכות לשחזור בסדר גודל של חודשים, מכווצים את זמן השחזור על ידי הרצה של אלפי איטרציות בלולאה קצרה על גבולות lifetime חשודים, כמו open -> start -> stop -> close או timeout ו-reconnect. קובעים baseline אחרי warm-up, בודקים את ההפרש וה-slope של Handle Count בכל מחזור, ומחפשים איפה ההתאמה בין create/open ל-close/dispose התקלקלה בעזרת structured log שמכיל sessionId, resourceId ו-action. קריאת נקודת הקריסה בסוף — ולא בהתחלה — מקטינה את הסיכוי ללכת לאיבוד בדרך.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג