לדלג לתוכן

8.8 - הוגנות עומס ואימות מנוע אסינכרוני - הרצאה

כשהמערכת נכונה אבל אינה נותנת שירות

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

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

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

תקציב עבודה אינו זמן מובטח

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

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

מודל קטן של תור הוגן

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

use std::collections::VecDeque;

fn simulate(budget: usize) -> [usize; 3] {
    assert!(budget > 0);
    let mut remaining = [21_usize, 1, 1];
    let mut completed = [0; 3];
    let mut ready = VecDeque::from([0_usize, 1, 2]);
    let mut time = 0;
    while let Some(id) = ready.pop_front() {
        let performed = remaining[id].min(budget);
        remaining[id] -= performed;
        time += performed;
        if remaining[id] == 0 {
            completed[id] = time;
        } else {
            ready.push_back(id);
        }
    }
    completed
}

fn main() {
    let unlimited = simulate(usize::MAX);
    let bounded = simulate(4);
    assert_eq!(unlimited, [21, 22, 23]);
    assert_eq!(bounded, [23, 5, 6]);
    println!("unlimited: {unlimited:?}");
    println!("budget=4: {bounded:?}");
}

הפקודות הבאות בונות את המודל בלי תלויות חיצוניות.

rustc --edition=2024 fairness.rs -o fairness
./fairness

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

יקיצות כפולות הן עומס בפני עצמן

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

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

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

תור מוגבל צריך מדיניות קבלה

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

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

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

שעון מדומה ובדיקה יריבה

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

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

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

סגירה שאפשר להסביר

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

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

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