לדלג לתוכן

8.9 - השלמות קלט ופלט ובעלות בבאפר - הרצאה

מהקריאה המוכנה לפעולה שהושלמה

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

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

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

תור משותף דורש פרסום מסודר

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

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

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

ניסוי ראשון ללא מצביעי נתונים

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

[package]
name = "uring_nop"
version = "0.1.0"
edition = "2024"

[dependencies]
io-uring = "=0.7.15"

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

use io_uring::{opcode, IoUring};
use std::io;

fn main() -> io::Result<()> {
    let mut ring = IoUring::new(8)?;
    let entry = opcode::Nop::new().build().user_data(42);
    unsafe {
        ring.submission().push(&entry)
            .map_err(|_| io::Error::other("submission queue full"))?;
    }
    loop {
        let completion = ring.completion().next()
            .map(|item| (item.user_data(), item.result()));
        if let Some((identity, result)) = completion {
            assert_eq!(identity, 42);
            if result < 0 { return Err(io::Error::from_raw_os_error(-result)); }
            assert_eq!(result, 0);
            println!("completed request {identity}");
            break;
        }
        match ring.submit_and_wait(1) {
            Ok(_) => {}
            Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
            Err(error) => return Err(error),
        }
    }
    Ok(())
}

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

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

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

ספרייה בטוחה יכולה לקחת את הבאפר בבעלות ולהחזיר אותו יחד עם התוצאה. כך קוד המשתמש אינו יכול לשחרר או לשנות אותו בזמן שהפעולה מחזיקה בו. הדוגמה הבאה היא חבילת מעבדה נפרדת עם תלות tokio-uring = "=0.5.0".

use std::io;

fn main() -> io::Result<()> {
    let path = std::env::args().nth(1).expect("provide an existing file path");
    tokio_uring::start(async move {
        let file = tokio_uring::fs::File::open(path).await?;
        let buffer = vec![0_u8; 4096];
        let (result, buffer) = file.read_at(buffer, 0).await;
        let count = result?;
        println!("read {count} bytes");
        println!("{}", String::from_utf8_lossy(&buffer[..count]));
        file.close().await?;
        Ok(())
    })
}

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

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

רישום מראש אינו העברת אחריות

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

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

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

ביטול הוא פעולה נוספת במערכת

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

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

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