לדלג לתוכן

השחתת זיכרון וחקירת תקיעות

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

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

משחזרים כתיבה מעבר להקצאה

התוכנית הבאה כוללת שגיאה מכוונת, ולכן אינה דוגמה להעתקה תקינה. שמרו כcourse_heap_lab.cpp ובנו עם MSVC במצב /Od לצורך החקירה. היא מקצה 32 בתים וכותבת 40. זוהי חריגה מחוזה הזיכרון של C++, ולא התנהגות שהשפה מבטיחה שתסתיים בצורה מסוימת ללא כלי אבחון.

#include <windows.h>
#include <cstdio>

__declspec(noinline) void corrupt(volatile unsigned char* destination) {
    for (unsigned i = 0; i != 40; ++i)
        destination[i] = static_cast<unsigned char>(i);
}

int main() {
    HANDLE heap = GetProcessHeap();
    auto memory = static_cast<unsigned char*>(HeapAlloc(heap, HEAP_ZERO_MEMORY, 32));
    if (!memory) return 1;
    std::printf("allocation=%p bytes=32\n", static_cast<void*>(memory));
    std::fflush(stdout);
    corrupt(memory);
    std::puts("write returned; freeing now");
    if (!HeapFree(heap, 0, memory)) return 2;
}
cl /std:c++20 /EHsc /Zi /Od /W4 course_heap_lab.cpp /link /DEBUG:FULL

בהרצה רגילה התוכנית יכולה לקרוס מאוחר, להיראות תקינה או להשחית metadata. אף אחת מהתוצאות אינה הופכת את הכתיבה לחוקית. לכן נבחר כלי שמקרב את הגילוי לכתיבה.

מציבים גבול שאפשר לפגוע בו

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

gflags /p /enable course_heap_lab.exe /full
gflags /p

נפתח את תוכנית המעבדה בWinDbg, נעצור על access violation ונריץ. בfull PageHeap אפשר להציב דף לא נגיש ליד ההקצאה, כך שהחריגה תיתפס סמוך לגבול. נבדוק את ההקצאה עם !heap -p -a ואת ההוראה שנעצרה, במקום להניח שכל תצורת allocator תפיק אותה כתובת.

sxe av
g
.exr -1
.ecxr
kv

בסיום נסיר את ההגדרה עבור image המעבדה ונבדוק את הרשימה שוב.

gflags /p /disable course_heap_lab.exe
gflags /p

המצב המלא מוסיף overhead של זיכרון ומשנה layout ותזמון. הוא נועד לאבחון ממוקד, ולא להרצת כל המחשב עם אותה מדיניות. ההבדל בינו לבין בדיקת patterns בעת שחרור מתואר בתיעוד GFlags וPageHeap.

עוצרים על הכותב

אם ידוע שדה מסוים שמתחיל תקין ומתקלקל, אפשר להציב data breakpoint אחרי יצירתו ולפני ההשחתה. הפקודה ba w 4 עוצרת על כתיבה לאזור מתאים בגודל ארבעה בתים. הכתובת חייבת להגיע מההרצה הנוכחית, והגודל והיישור צריכים להתאים לארכיטקטורה.

ba w 4 <address-of-live-field>
g
kv

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

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

יוצרים תקיעה עם סיבה ברורה

הקובץ הבא, course_hang_lab.cpp, יוצר deadlock בכוונה. שני threadים לוקחים locks בסדר הפוך. הbarrier מבטיח שכל אחד מחזיק את הראשון לפני ניסיון לקחת את השני, כך שלא צריך לקוות לתזמון נדיר. מריצים כתהליך מעבדה נפרד, עוצרים בdebugger לצורך איסוף ומסיימים אותו אחרי החקירה.

#include <barrier>
#include <mutex>
#include <thread>
#include <cstdio>

std::mutex first;
std::mutex second;
std::barrier rendezvous(2);

void left() {
    std::lock_guard hold_first(first);
    rendezvous.arrive_and_wait();
    std::lock_guard hold_second(second);
    std::puts("left finished");
}

void right() {
    std::lock_guard hold_second(second);
    rendezvous.arrive_and_wait();
    std::lock_guard hold_first(first);
    std::puts("right finished");
}

int main() {
    std::thread a(left);
    std::thread b(right);
    a.join();
    b.join();
}

הthread הראשי תקוע בjoin, אבל הסיבה אינה מימוש join. קיבלנו מעגל: הראשון מחזיק lock א וממתין לב, והשני מחזיק ב וממתין לא. במחסניות של כל הthreadים נראה את שני הצדדים. נשתמש ב~* kv, ולא נסתפק בstack של הthread שהיה מסומן בזמן העצירה.

מה עץ המתנה יודע

המנגנון Wait Chain Traversal מספק מידע על סוגי המתנה נתמכים, כגון mutexים, critical sections, COM וSendMessage. הוא אינו יודע להסיק כל תלות לוגית שהתוכנית המציאה. גם std::mutex יכול להיות ממומש על primitive שאינו מוצג כפי שציפינו בכלי.

לכן עץ ריק אינו הוכחה שאין deadlock. נשלב את המידע עם stacks, בעלות על locks וקוד. נבדיל בין deadlock, המתנה ארוכה לI/O, starvation ולולאה שצורכת CPU. לכל אחת מהן תצפית אחרת: מעגל בעלות, פעולה חיצונית שלא הסתיימה, עבודה ready שאינה מקבלת זמן, או thread שמתקדם באותו אזור קוד.

בכלי משלנו אפשר לפתוח session של WCT ולשאול על threadים, תוך שמירת חיי context עד callback בגרסה האסינכרונית. החוזים וסוגי ההמתנה הנתמכים מתוארים בתיעוד WCT.

תיקון צריך לשבור את הסיבה

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

בdeadlock נגדיר סדר locks אחיד או רכישה משותפת שמונעת את המעגל. הוספת Sleep אינה פתרון, משום שהיא רק משנה את הסיכוי להיתקל בו. גם timeout דורש cleanup של המצב החלקי ושל locks שכבר נרכשו; הוא אינו מבטל את הצורך בפרוטוקול.

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