לדלג לתוכן

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

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

לפני שנבנה אותה, נפריד בין שכבות. המתודה Write::write היא חוזה של ספריית ראסט, ומימוש מסוים שלה עשוי לעבוד רק בזיכרון. כתיבה לקובץ עוברת דרך עטיפות מערכת, שלעיתים משתמשות ב־libc. קריאת syscall גולמית משתמשת ב־ABI של הקרנל. חלק מהפעולות, כגון קריאת שעון בתנאים מסוימים, יכולות להשתמש ב־vDSO בלי מעבר syscall רגיל.

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

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

use std::io::{self, Write};

fn write_progress<W: Write>(writer: &mut W, bytes: &[u8])
    -> Result<usize, (usize, io::Error)> {
    let mut done = 0;
    while done < bytes.len() {
        match writer.write(&bytes[done..]) {
            Ok(0) => return Err((done, io::ErrorKind::WriteZero.into())),
            Ok(count) => done += count,
            Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
            Err(error) => return Err((done, error)),
        }
    }
    Ok(done)
}

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

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

struct Scripted { calls: usize, output: Vec<u8> }
impl Write for Scripted {
    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
        self.calls += 1;
        match self.calls {
            1 => return Err(io::ErrorKind::Interrupted.into()),
            3 => return Err(io::ErrorKind::WouldBlock.into()),
            _ => {}
        }
        let count = bytes.len().min(2);
        self.output.extend_from_slice(&bytes[..count]);
        Ok(count)
    }
    fn flush(&mut self) -> io::Result<()> { Ok(()) }
}
fn main() {
    let input = b"abcdef";
    let mut writer = Scripted { calls: 0, output: Vec::new() };
    let (done, error) = write_progress(&mut writer, input).unwrap_err();
    assert_eq!(done, 2);
    assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
    assert_eq!(write_progress(&mut writer, &input[done..]).unwrap(), 4);
    assert_eq!(writer.output, input);
}

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

כאשר משתמשים ישירות ב־libc, צריך לקרוא את errno רק אחרי תוצאה שמוגדרת ככשל, ולשמור אותו לפני קריאה נוספת שעלולה לשנותו. הערך אינו מתאפס אוטומטית בכל הצלחה. בראסט הפונקציה io::Error::last_os_error לוכדת את השגיאה הנוכחית, אבל אינה יודעת אם הקריאה הקודמת באמת נכשלה.

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

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

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