לדלג לתוכן

תהליכוני מערכת וחיי משימה

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

תהליכוני מערכת חולקים מרחב כתובות ומשאבים רבים של התהליך, אבל לכל אחד יש הקשר ביצוע, מחסנית ומצב מקומי לתהליכון. משימת task של runtime אסינכרוני אינו בהכרח תהליכון מערכת נוסף; כמה משימות כאלה יכולות להתבצע על אותם workers. בפרק הבא נבנה את ההבחנה הזאת, וכאן נתחיל מתהליכונים אמיתיים של std::thread.

הקוד הבא מחלק מערך לטווחים זרים ומעבד אותם באמצעות scoped threads. הוא אינו צריך לנעול את המערך משום שכל תהליכון מקבל השאלה משתנה לטווח אחר.

fn main() {
    let mut values = [1u64, 2, 3, 4, 5, 6, 7, 8];
    std::thread::scope(|scope| {
        for chunk in values.chunks_mut(2) {
            scope.spawn(move || {
                for value in chunk { *value *= *value; }
            });
        }
    });
    assert_eq!(values, [1, 4, 9, 16, 25, 36, 49, 64]);
}

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

נשווה זאת ל־spawn רגילה. הדוגמה הבאה מיועדת להיכשל משום שה־closure עשוי לחיות יותר מהמשתנה המקומי שאליו הוא מתייחס.

fn main() {
    let label = String::from("worker");
    let handle = std::thread::spawn(|| println!("{label}"));
    handle.join().unwrap();
}

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

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

fn main() {
    let label = String::from("worker");
    let handle = std::thread::Builder::new().name("scanner".into())
        .spawn(move || label.len()).expect("thread creation failed");
    match handle.join() {
        Ok(length) => println!("result={length}"),
        Err(_) => eprintln!("worker panicked"),
    }
}

שם התהליכון הוא כלי אבחון, ולא זהות נצחית. גם ThreadId של ראסט אינו בהכרח מספר TID של הקרנל. כאשר משווים ל־/proc/<pid>/task או לכלי tracing, צריך לדעת איזה מרחב מזהים מופיע בכל צד ולא לחבר מספרים רק כי הם נראים דומים.

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

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

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

החוזים של חיים ותוצאות נמצאים בתיעוד thread::scope ובתיעוד thread::spawn. נתחיל כל תכנון מקבילי בשאלה מי מחזיק את הנתונים ומי מבטיח שהעובד לא ישתמש בהם אחרי סיום חייהם.