לדלג לתוכן

חקירת חיי משאבים בתוכנית מקבילית

אותה עלייה בזיכרון יכולה לספר סיפורים שונים

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

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

קיפאון שאפשר להכריח

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

use std::sync::{Arc, Barrier, Mutex};

fn main() {
    let left = Arc::new(Mutex::new(0_u64));
    let right = Arc::new(Mutex::new(0_u64));
    let ready = Arc::new(Barrier::new(3));
    let first = {
        let (left, right, ready) = (left.clone(), right.clone(), ready.clone());
        std::thread::Builder::new().name("left-first".into()).spawn(move || {
            let _left = left.lock().unwrap();
            println!("left-first owns left");
            ready.wait();
            let _right = right.lock().unwrap();
            println!("left-first completed");
        }).unwrap()
    };
    let second = {
        let (left, right, ready) = (left.clone(), right.clone(), ready.clone());
        std::thread::Builder::new().name("right-first".into()).spawn(move || {
            let _right = right.lock().unwrap();
            println!("right-first owns right");
            ready.wait();
            let _left = left.lock().unwrap();
            println!("right-first completed");
        }).unwrap()
    };
    ready.wait();
    println!("pid={} both first locks acquired", std::process::id());
    first.join().unwrap();
    second.join().unwrap();
}

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

rustc --edition=2024 -g lock_lab.rs -o lock_lab
timeout 60s ./lock_lab

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

info threads
thread apply all backtrace
detach
quit

מחסנית אחת אינה גרף המתנה

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

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

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

תיקון סדר הרכישה

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

use std::sync::{Arc, Barrier, Mutex};

fn main() {
    let left = Arc::new(Mutex::new(0_u64));
    let right = Arc::new(Mutex::new(0_u64));
    let start = Arc::new(Barrier::new(3));
    let mut workers = Vec::new();
    for _ in 0..2 {
        let (left, right, start) = (left.clone(), right.clone(), start.clone());
        workers.push(std::thread::spawn(move || {
            start.wait();
            let mut left_guard = left.lock().unwrap();
            let mut right_guard = right.lock().unwrap();
            *left_guard += 1;
            *right_guard += 1;
        }));
    }
    start.wait();
    for worker in workers { worker.join().unwrap(); }
    assert_eq!(*left.lock().unwrap(), 2);
    assert_eq!(*right.lock().unwrap(), 2);
}

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

בעלות מעגלית בלי שגיאת זיכרון

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

use std::cell::RefCell;
use std::rc::Rc;

struct Node { next: RefCell<Option<Rc<Node>>> }

fn main() {
    let first = Rc::new(Node { next: RefCell::new(None) });
    let second = Rc::new(Node { next: RefCell::new(None) });
    *first.next.borrow_mut() = Some(second.clone());
    *second.next.borrow_mut() = Some(first.clone());
    let witness = Rc::downgrade(&first);
    drop(first);
    drop(second);
    let remaining = witness.upgrade().expect("cycle still owns node");
    println!("strong references={}", Rc::strong_count(&remaining));
    remaining.next.borrow_mut().take();
    drop(remaining);
    assert!(witness.upgrade().is_none());
}

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

משימה תקועה לעומת משאב דולף

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

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

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