זליגת זכרון : הרוצח השקט

זליגת זכרון היא אחד הבאגים החמקמקים ביותר, ממש הרוצח השקט של תוכנות מחשב.
לא משנה באיזה שפה אתם כותבים, לא קשה במיוחד לכתוב קוד שידלוף כמסננת (אם כי זליגות זכרון בשפות מנוהלות כמו ג'אווה ו#C הן בעלות מאפיינים שונים לגמרי מזליגות זכרון בשפות בהן המתכנת אחראי על ניהול הזכרון ואין איסוף זבל אוטומטי).
כל משתמש מנוסה מכיר את התופעה המציקה, שתוכנה עובדת מהר בהתחלה ואחרי פרק זמן לא קבוע היא מתחילה להאט ולהאט עד כדי זחילה.
לפעמים היא מצליחה להאט את כל המחשב על הדרך, אם היא זולגת כמויות זכרון שגורמות למחשב להתחיל להשתמש בצורה מופרזת בקובץ הSWAP.
בניגוד להרבה בעיות תכנות אחרות, המתכנת לא מקבל אזהרה או שגיאה כאשר הוא כותב באג של זליגת זכרון, התוכנה לא קורסת מייד כמו למשל בגישה לזכרון דרך מצביע לNULL בC או בC++, והקומפיילר לא יספר למתכנת על הטעות שהוא עשה.
הסיבה לכך היא לא שמפתחי הקומפיילרים הם עצלים מכדי לכתוב קוד שמוצא את הבעיה – אלא שפשוט לא ניתן לכתוב קוד שיזהה זליגת זכרון בכל המקרים בצורה סטטית (על בסיס הקוד הכתוב בלבד).
הדוגמא הפשוטה ביותר היא תוכנית שמקצה ומשחררת זכרון על פי בקשת המשתמש (לאו דווקא במונחים האלו, המשתמש יכול לבקש לפתוח קובץ ובתגובה התוכנה תקצה זכרון בגודל הקובץ ותטען את הקובץ פנימה). אם התוכנה עובדת ישירות עם הזכרון אז הקומפיילר לא יכול להבטיח שהזכרון שיוקצה בשלב מסויים גם ישוחרר – ולו כי זה יקרה רק אם המשתמש יבקש את זה.

אז מה הבעיה עם זליגת זכרון?
הבעיה הראשונה והטריויאלית ביותר היא שתוכנה שזולגת משתמשת ביותר זכרון ממה שהיא צריכה, והמצב מחמיר בהדרגה ככל שאותו קוד שזולג רץ יותר. תוכנה שצורכת יותר מדי זכרון היא לא בהכרח בעיה חמורה. למעשה – כמעט כל תוכנה ברמת מורכבות בינונית ומעלה שנכתבה בשפת C או C++ זולגת זכרון כאילו אין מחר, ובדרך כלל זה לא גורם בעיות כי המשתמש סוגר את התוכנה לפני שהיא מגיע למצב של חוסר זכרון.
הבעיה הזו היא קצת יותר חמור משנדמה במבט ראשון:
כאשר המתכנת מבקש להקצות זכרון, נניח 1MB, הוא מצפה לקבל 1MB של זכרון רציף, שאפשר לעבור עליו בלולאה אחת מתחילתו ועד סופו.
דמיינו מערכת עם 2 מגה זכרון שעומדים לרשות תוכנה שרצה שם, מספיק לכאורה להקצות 2 בלוקים של 1MB.
עכשיו, אם נקצה סך הכל של 1K זכרון, ניתן להניח שנוכל להקצות עוד 1999K ובוודאי שנוכל להקצות 1MB, נכון?
אז זהו – שלא בדיוק : אם נקצה את ה1K בארבע בלוקים של 250 בתים, ובהפרשים של כ500K, למעשה נמנע מהתוכנה אפשרות לקבל זכרון רציף של יותר מכ500K.
כלומר, למרות שהזכרון הפנוי מספיק – בפועל הזכרון הפנוי שבור לחתיכות קטנות מדי (Fragmented), ואין לנו יכולת להקצות את הזכרון הדרוש.
מכאן אפשר להסיק שגם דליפת זכרון קטנה עלולה במצבים מסויימים לגרום לחוסר זכרון קריטי.
אני מקווה שזה שיכנע אתכם שדליפת זכרון זה דבר רע שראוי לטפל בו.

כל מתכנת מתחיל יודע שלשפות מודרניות יש איסוף זבל אוטומטי שאמור למנוע זליגת זכרון.
יש פה כמה בעיות, קודם כל – למרות שלרוב השפות יש איסוף זבל אוטומטי, ברוב המקרים מדובר בזבל של איסוף זבל, ואם להיות פחות ציורי ויותר ספציפי – מדובר בקוד איטי ודי מוגבל, שלא תופס את כל המקרים.
אבל אם נסתכל בג'אווה וב#C (שאני מניח שיש לה איסוף זבל בקליבר דומה לזה של ג'אווה) – הן בהחלט מסוגלות לאסוף את הזבל במהירות וביעילות.
הבעיה היא שההגדרה של זבל הוא אובייקט שהוקצה ואין אליו התייחסות משום מקום (Reference), ולפעמים המתכנת לא מבחין שבעצם יש אליו התייחסות.
למשל, קוד הג'אווה הטריואלי הבא ירוץ עד שיעוף עקב חוסר זכרון:
[code lang="java"]
import java.util.HashSet;
import java.util.Set;

public class a {
public static void main(String[] args) {
Set s = new HashSet();
int a = 0;
while(true)
s.add(String.valueOf(a++));
}
}
[/code]

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

בשפות עם ניהול זכרון ישיר יש מגוון רחב בהרבה של שגיאות שקשורות לזכרון, החל מזכרון שמוקצה ולא משוחרר, זכרון שמשוחרר פעמיים, זכרון שמוקצה עם Malloc של C ומשוחרר עם delete של C++, כתיבה או קריאה אל מחוץ לגבולות הזכרון שהוקצה ועוד ועוד.
הנה תוכנית C++ קטנה שמדגימה חלק מהשגיאות האפשריות:
[code lang="c++"]
#include

char* get1(){
return new char[10];
}

char* get2(){
return (char*)malloc(sizeof(char)*10);
}

char* get3(){
static char c[10];
return c;
}

int main() {
char *s1 = get1();
char *s2 = get2();
char *s3 = get3();

s1[11] = 'x'; // out of bounds access. mistake
free(s1); // mistake, should be delete[]
delete s2; // mistake should be free
delete s3; // mistake, should not be freed or deleted

char *s4 = new char[10];
delete s4; // mistake, should delete with delete[] and not with delete.
delete s4; // mistake, deleted twice.
char *s5 = new char[10]; // mistake, never deleted
return 0;
}
[/code]
כמו תמיד, C++ נותנת הרבה יותר חבל למתכנת התמים שרוצה לתלות את אח של אח שלו על עץ.
למעשה רק השגיאה האחרונה היא ממש זליגת זכרון, אבל כל השגיאות נפוצות וקשורות לזכרון.
יש טכניקות ידועות ומקובלות שמקטינות מאוד את הסבירות לטעויות שקשורות להקצאה ושחרור זכרון, ספציפית יש טכניקה שמוזכרת בספר The C++ programming language של סטראוסטרופ שנקראת Resource Acquisition Is Initialization או RAII בקיצור.
RAII הוא נושא שראוי לפוסט שלם בפני עצמו, אבל הנה דוגמת שימוש פשוטה (מדי, יש בה לא מעט חורים):
[code lang="c++"]
void direct_test(){
char *buffer = new char[100];
try{
work(buffer);
delete[] buffer;
}
catch(…){
delete[] buffer;
throw;
}
}
[/code]
הקוד למעלה מקצה זכרון, וקורא לפונקציה שעובדת עליו. לבסוף הוא משחרר את הזכרון וגם דואג שהוא ישוחרר כראוי אם הפונקציה תזרוק אקספשן.
התבנית הזו של טיפול ידני בשגיאות היא מאוד בעייתית ומועדת לטעויות.
אם היינו משתמשים בRAII, יכלנו להגדיר מחלקה בשם Buffer, שתדאג לשחרור ולהקצאה:
[code lang="c++"]
class Buffer{
char *buffer;
size_t size;

public:
Buffer(int size){
buffer = new char[size];
}

~Buffer(){
delete[] buffer;
}

char *get() {return buffer;}
};

void raii_test(){
Buffer buffer(100); // 100 bytes allocated in constructor
work(buffer.get());
// automatically freed when buffer destructor is called (will be called even if an exception is thrown).
}
[/code]

אפשר לראות שהקוד שמשתמש בזכרון הוא הרבה יותר פשוט עכשיו.
מצד שני, יש מספר לא מבוטל של באגים בדוגמא הזו (למשל, כשמעתיקים את buffer הזכרון שהוקצה ישוחרר פעמיים). כדי שזה באמת יעבוד צריך לדאוג לבנאי העתקה, אופרטור הצבה וכו'.
כל מתכנת C++ רציני חייב להכיר את הטכניקה הזו, שתחסוך לו שערות לבנות רבות מאוד.
אבל לפעמים אין ברירה וחייבים להשתמש בזכרון ישירות, בדרך כלל כאשר עובדים עם ספריות שכתובות ככה-ככה, או עם קוד אחר שנכתב על ידי מישהו שעדיין לא ראה את האור.
עוד דבר – בC לא אין אפשרות אמיתית להשתמש בRAII כי השפה לא מספיק עשירה, ולכן מתכנתים שכותבים בC אמיתי (ולא C+) לא יכולים להנות מהטכניקה הזו.

אחד הכלים החזקים ביותר לזיהוי בעיות שקשורות לעבודה עם זכרון הוא valgrind.
ואלגרינד הוא למעשה קבוצה של כלים, כשהידוע בהם הוא memcheck שבמגלה שגיאות בעבודה עם זכרון.
הוא כולל כלים אחרים לניתוח ביצועים (cachegrind, callgrind) ועוד.
ואלגרינד עובד על רוב מערכות ההפעלה ממשפחת היוניקסים (BSD, Linux, MacOS X וכו'), בתאוריה אפשר לבדוק איתו תוכנות חלונות דרך WINE.
ואלגרינד עובד בשורת הפקודה, מה שמרתיע משתמשים רבים. לאחרונה חיפשתי ומצאתי פתרון שמאפשר אינטגרציה של ולגרינד עם CDT:
התמיכה היא כחלק מפרוייקט בשם LinuxTools שמפותח על ידי RedHat (כנראה).
יש פה דמו וידאו של השימוש בפלאגין.
קובץ הוידאו נמצא כאן למי שלא מצליח לראות אותו בבלוג (דורש פיירפוקס 3.5 או דפדפן שתומך בתג וידאו).

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

Facebook Comments

17 תגובות בנושא “זליגת זכרון : הרוצח השקט”

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

    למשל אם תיקח את Object Pascal אשר ממש כמו ב ++C כל מה שאתה מאתחל אתה חייב לשחרר, הוא מביא איתו כמה יכולות שעם כל הכבוד לא ב C ולא ++C תמצא לניהול זיכרון.
    למשל אני יכול לשנות לגמרי את מה שמאתחל את הזיכרון בלי לשנות את הקוד שלי על ידי שינוי רישום נהלי הזיכרון. אני יכול לעקוב אחרי כל דבר שמאתחל את הזיכרון ולדעת מתי שוחרר שם כל משתנה שאותחל, וכך אני יודע בוודאות בכל קוד שאני כתבתי אם הקוד שלי דולף או לא. דוגמא לכלי כזה: http://www.freepascal.org/docs-html/rtl/heaptrc/index.html

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

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

  3. מספר הערות:

    ‎s4[0] = ‘a’; // mistake, should check for null value after allocating memory.‎

    לא נכון, new זורק exception במקרה של חוסר זיכרון. אין שום צורך לבדוק ל־null.

    בנוסף, ב־C++‎ יש מספר טכניקות מאוד פשוטות למנוע כל זליגת זיכרון, כך שבפועל, היום זליגות זיכרון ב־C++‎ הן לא יותר נפוצות מאשר ב־Java.

    דוגמת ה"RAII" שבאת עם try ו־catch היא גרועה ביותר – האמת, אפילו לא לזה התכוון המשורר: במקום לעשות new char[100]‎ אתה מגדיר std::vector worker(100,0);‎
    כך מונע כל צורך לכתיבת try-catch. למעשה, בכתיבה נכונה יש לך מעט מאוד catchים.

    והדבר השלישי, אם אתה מדבר על RAII ו־C++‎ אתה חייב להזכיר מצביעים חכמים כמו auto_ptr או shared_ptr שפותרים של 99% מהבעיות.
    בנוסף, אף פעם אל תמציא "buffer" משלך, יש בשביל זה std::vector.

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

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

  5. "אז זהו – שלא בדיוק : אם נקצה את ה1K בארבע בלוקים של 250 בתים, ובהפרשים של כ500K, למעשה נמנע מהתוכנה אפשרות לקבל זכרון רציף של יותר מכ500K.
    כלומר, למרות שהזכרון הפנוי מספיק – בפועל הזכרון הפנוי שבור לחתיכות קטנות מדי (Fragmented), ואין לנו יכולת להקצות את הזכרון הדרוש."

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

    char *s5 = new char[10]; // mistake, never deleted
    גם כאן אין בעייה גדולה. המערכת הפעלה תשחרר את הזיכרון בסיום התוכנית. קרא כאן: http://stackoverflow.com/questions/966632/freeing-memory-in-c-under-linux/966662#966662 כמובן ששוב מדובר על מהערכות עם MMU.

    בנוסף, הדוגמה הזאת כתובה ב-C++ אבל אתה משתמש ב-indludes של C. אתה צריך להשתמש בו

  6. ארתיום:
    1. לגבי הקצאה עם new שזורקת bad_alloc, תיקנתי. (זה הרגל בריא שנובע מזה שגם באפליקציות C++ משתמשים לפעמים בmalloc או באלוקייטורים אחרים שמחזירים NULL אם אין זכרון.
    2. אני מסכים שיש בC++ טכניקות לטיפול בזליגות זכרון – הבעיה היא שאפליקציות C++ אמיתיות משתמשות הרבה פעמים בספריות חיצוניות – חלקן כתובות בC. אם האפליקציה שלך היא 100% C++, תוכל להמנע לחלוטין מבעיות של זליגת משאבים.
    3. שמע, ממה שאני זוכר ממתי שקראתי את TCPPPL לזה התכוון המשורר (רק במובן הרחב יותר של משאבים באופן כללי). ספציפית לגבי השימוש בvector כבאפר, לא זכורה לי דוגמא כזו וזה רעיון מעניין שלא חשבתי עליו.

  7. vedder, אתה לא טועה:
    התוכנית תעוף כי היא תשתמש בכל הזכרון.
    הנקודה שלי היא שבתוכניות ג'אווה מורכבות קשה לראות איפה הזכרון נמצא ולפעמים אתה שומר references לכל מני דברים בלי להבין שזה מה שקורה.
    זו הכוונה שלי בזליגת זכרון בג'אווה.

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

  9. > הבעיה היא שאפליקציות C++‎ אמיתיות משתמשות הרבה פעמים בספריות חיצוניות – חלקן כתובות בC.‏

    אז אתה עוטף API של C עם מחלקה פשוטה שדואגת לעבודה מוסדרת ברמת ה־RAII, לדוגמה אתה לא תראה הרבה שימוש ישיר ב־pthreads אלא תעטוף אותם אם משהו שיבצע שחרור נעילה באופן אוטומטי.

    אגב גם ב־C יש טכניקה מאוד פשוטה למניעות זליגות זיכרון (אם כי לא חזקה כמו ב־C++‎ ע"י יצירת נקודת יציאה יחידה ושימוש ב־goto.

    ראה: http://art-blog.no-ip.info/newpress/blog/post/164

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

  11. ארתיום, חשבתי קצת על ווקטור כבאפר, ולא ברורה לי הכוונה שלך.
    סמנטיקת ההעתקה של std::vector היא לפי ערך, לכן להעביר וקטור של char לפונקציה במקום מערך של char זה יקר (כאורך הווקטור).
    כמובן – אפשר להעביר רפרנס או פוינטר לוקטור – אבל אז מאבדים את כל היתרונות של raii.

  12. לראות ואלגינד בתוך CDT זה מרגש ממש 🙂
    כמובן שהתלהבתי יתר על המידה מתוך ציפיה לראות את זה עובד בחלונות.
    אין סיכוי, הא?

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

  14. > סמנטיקת ההעתקה של std::vector היא לפי ערך, לכן להעביר וקטור של char לפונקציה במקום מערך של char זה יקר (כאורך הווקטור).

    אני לא מציע לך להעביר את ה־vector אלא קטע הזיכרון. למשל. יש לך פונקציה foo(char *buffer,size_t size);‎
    אז במקום לכתוב
    char *buf=new char[100];‎
    try { foo(buf,100); } catch(…) { delete [] buf; throw; }‎
    delete [] buf;‎

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

    std::vector buf(100,0);‎
    foo(&buf.front(),buf.size());‎

    כמובן, שים לב שהגודל של הווקטור הוא לא 0 (אם אתה מקבל פרמטר של גודל מבחוץ).

    זהו.

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

  16. ב- Java,
    מעבר ל-JProfiler, אם כבר נזלת והתרסקת, אני ממליץ על eclipse memory analyzer, כאחלה כלי לבחינת ה- DUMP שאמור היה להיות מופק אוטומטית ע"י ה- JVM.
    http://www.eclipse.org/mat/
    ניתן לתחקר את העץ בעזרת שפת שאילתות SQL-ית.
    לקינוח הוא מסוגל לעבד גם core dumps ואז יש לך לא רק את עץ הרפרנסים, אלא גם את את תוכן הזכרון עצמו.

סגור לתגובות.