לדלג לתוכן

1.6 - חיי ערך השמדה וניקוי תחת כשל - הרצאה

מי סוגר את המשאב כשחוזרים מוקדם

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

נתחיל ממשאב מדומה שמדפיס מתי הוא נוצר ומתי הוא מושמד. הדוגמה אינה פותחת קובץ אמיתי, כדי שנוכל לראות קודם את סדר החיים:

struct Resource(&'static str);

impl Resource {
    fn acquire(name: &'static str) -> Self {
        println!("acquire {name}");
        Self(name)
    }
}

impl Drop for Resource {
    fn drop(&mut self) {
        println!("drop {}", self.0);
    }
}

fn work(fail: bool) -> Result<(), &'static str> {
    let _first = Resource::acquire("first");
    let _second = Resource::acquire("second");
    if fail {
        return Err("stopped");
    }
    println!("work complete");
    Ok(())
}

fn main() {
    println!("result={:?}", work(true));
}

היציאה המוקדמת אינה מדלגת על ההשמדה הרגילה. המשתנים המקומיים מושמדים לפי כללי התחומים שלהם, ובדוגמה second נוצר אחרי first ולכן מושמד לפניו. הסימן התחתון בתחילת שם המשתנה מונע אזהרת אי־שימוש, אבל הערך עדיין קשור למשתנה וחי עד סוף התחום. הכתיבה let _ = ... היא מקרה אחר ועלולה להשמיד ערך מיד.

השמדה היא פעולה של בעל הערך

המתודה drop מקבלת &mut self, ולא בעלות חדשה על self. המערכת קוראת לה כחלק מסיום חיי הערך, ואחריה ממשיכה להשמיד את שדותיו לפי הכללים. אין לקרוא ישירות ל־Drop::drop כדי להריץ destructor ולשמור את הערך כאילו הוא עדיין תקף.

כדי לסיים בעלות לפני סוף התחום משתמשים ב־std::mem::drop, שמקבלת ערך בבעלות:

fn main() {
    let text = String::from("temporary");
    println!("len={}", text.len());
    drop(text);
    println!("allocation owner ended");
}

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

משאבים אמיתיים ושגיאות בזמן סגירה

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

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

נשתמש בדוגמה פשוטה שמפרידה בין סיום פעולה לניקוי:

use std::fs::File;
use std::io::{self, Write};

fn save(path: &str) -> io::Result<()> {
    let mut file = File::create(path)?;
    file.write_all(b"ready\n")?;
    file.sync_all()?;
    Ok(())
}

fn main() -> io::Result<()> {
    save("state.txt")
}

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

פאניקה ותחומים שנפרמים

במצב שבו פאניקה משתמשת ב־unwinding, היציאה דרך המחסנית מריצה ניקוי של ערכים בתחומים שנעזבים. אפשר להחליף את החזרה המוקדמת במשאב המדומה ב־panic ולראות את ההדפסות לפני סיום התהליך. עם זאת, ניתן לבנות תוכנית עם מדיניות abort, ובמקרה כזה אין הבטחה לפרימת המחסנית הזאת.

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

ה־destructor עצמו צריך להיות פשוט וצפוי ככל האפשר. פאניקה שנייה בזמן unwinding עלולה להביא להפסקה מיידית של התהליך. עדיף לתכנן פעולות שעלולות להיכשל כחלק מממשק מפורש, ולהשאיר ל־Drop ניקוי שהלקוח לא צריך לנהל ידנית בכל מסלול.

סדר חיים מבטא תלות

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

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