לדלג לתוכן

מנגנון פיוטקס ותקשורת דרך תורים

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

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

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

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

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

use std::{sync::mpsc::sync_channel, time::Duration};
fn main() {
    let (sender, receiver) = sync_channel::<Vec<u8>>(1);
    let producer = std::thread::spawn(move || {
        sender.send(vec![1; 16]).unwrap();
        sender.send(vec![2; 16]).unwrap();
    });
    std::thread::sleep(Duration::from_millis(100));
    let first = receiver.recv().unwrap();
    let second = receiver.recv().unwrap();
    assert_eq!(first, vec![1; 16]);
    assert_eq!(second, vec![2; 16]);
    producer.join().unwrap();
    assert!(receiver.recv().is_err());
}

ההשהיה נועדה רק ליצור תנאי ניסוי שבו המפיק צפוי להיתקל בתור מלא, ואינה פרוטוקול תקינות. התור עצמו שומר על סדר ועל בעלות גם אם התזמון שונה. הריצו את הבינארי שנבנה דרך strace -f -e trace=futex ונסו לקשר בין חסימה לבין המעבר למסלול הקרנל.

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

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

use std::sync::mpsc::{sync_channel, TrySendError};
fn main() {
    let (sender, receiver) = sync_channel(1);
    sender.try_send(String::from("first")).unwrap();
    match sender.try_send(String::from("second")) {
        Err(TrySendError::Full(value)) => assert_eq!(value, "second"),
        _ => panic!("expected full queue"),
    }
    drop(receiver);
    match sender.try_send(String::from("third")) {
        Err(TrySendError::Disconnected(value)) => assert_eq!(value, "third"),
        _ => panic!("expected disconnected receiver"),
    }
}

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

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

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