לדלג לתוכן

9.1 - ערוצי תקשורת מקומיים וחיי הקצוות - הרצאה

מי עדיין מחזיק את הקצה השני

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

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

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

ניסוי בלי תלות בתזמון

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

use std::fs::File;
use std::io::{self, Read, Write};
use std::os::fd::FromRawFd;

fn main() -> io::Result<()> {
    let mut raw = [-1; 2];
    if unsafe { libc::pipe2(raw.as_mut_ptr(), libc::O_NONBLOCK | libc::O_CLOEXEC) } < 0 {
        return Err(io::Error::last_os_error());
    }
    let mut reader = unsafe { File::from_raw_fd(raw[0]) };
    let mut writer = unsafe { File::from_raw_fd(raw[1]) };
    let duplicate = writer.try_clone()?;
    writer.write_all(b"abc")?;
    drop(writer);
    let mut bytes = [0_u8; 3];
    reader.read_exact(&mut bytes)?;
    assert_eq!(&bytes, b"abc");
    let mut extra = [0_u8; 1];
    let error = reader.read(&mut extra).unwrap_err();
    assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
    println!("empty, but a writer still exists");
    drop(duplicate);
    assert_eq!(reader.read(&mut extra)?, 0);
    println!("all writers closed: EOF");
    Ok(())
}

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

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

קיבולת ואטומיות הן שאלות שונות

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

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

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

צינור בעל שם

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

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

ערוץ דו־כיווני בלי שם

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

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

use std::io::{self, Read, Write};
use std::net::Shutdown;
use std::os::unix::net::UnixStream;

fn main() -> io::Result<()> {
    let (mut client, mut server) = UnixStream::pair()?;
    client.write_all(b"request")?;
    client.shutdown(Shutdown::Write)?;
    let mut request = Vec::new();
    server.read_to_end(&mut request)?;
    assert_eq!(&request, b"request");
    server.write_all(b"response")?;
    server.shutdown(Shutdown::Write)?;
    let mut response = Vec::new();
    client.read_to_end(&mut response)?;
    assert_eq!(&response, b"response");
    Ok(())
}

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

זרם הודעות וכתובת מקומית

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

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

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

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