לדלג לתוכן

8.7 - ביטול והצטרפות למשימות תלויות - הרצאה

מה נשאר אחרי ביטול

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

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

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

קריאה מלאה היא רצף קריאות

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

tokio::select! {
    result = stream.read_exact(&mut frame) => {
        result?;
    }
    _ = cancellation => {
        return Err(std::io::Error::other("cancelled"));
    }
}

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

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

ניסוי שנעצר בדיוק אחרי קידומת

נכתוב קורא שמחזיק באפר והיסט בתוך מצב חיצוני. נשתמש בזרם זיכרון של טוקיו כדי לשלוט בכמות הנתונים הזמינה, ובערוץ חד־פעמי כדי למנוע מהכותב להמשיך לפני שסיימנו את הבדיקה. הוסיפו לתלות טוקיו את התכונה io-util, לצד rt, macros ו־sync.

use std::io;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};

#[derive(Default)]
struct Frame {
    bytes: [u8; 6],
    filled: usize,
}

async fn fill<R: AsyncRead + Unpin>(stream: &mut R, frame: &mut Frame) -> io::Result<()> {
    while frame.filled < frame.bytes.len() {
        let count = stream.read(&mut frame.bytes[frame.filled..]).await?;
        if count == 0 {
            return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "partial frame"));
        }
        frame.filled += count;
    }
    Ok(())
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> io::Result<()> {
    let (mut reader, mut writer) = tokio::io::duplex(64);
    let (prefix_sent, prefix_received) = tokio::sync::oneshot::channel();
    let (resume, resumed) = tokio::sync::oneshot::channel();
    let writer_task = tokio::spawn(async move {
        writer.write_all(b"abc").await.unwrap();
        prefix_sent.send(()).unwrap();
        resumed.await.unwrap();
        writer.write_all(b"def").await.unwrap();
    });
    prefix_received.await.unwrap();
    let mut frame = Frame::default();
    tokio::select! {
        biased;
        result = fill(&mut reader, &mut frame) => {
            panic!("frame unexpectedly completed: {result:?}");
        }
        _ = std::future::ready(()) => {}
    }
    assert_eq!(frame.filled, 3);
    assert_eq!(&frame.bytes[..frame.filled], b"abc");
    resume.send(()).unwrap();
    fill(&mut reader, &mut frame).await?;
    assert_eq!(&frame.bytes, b"abcdef");
    writer_task.await.unwrap();
    println!("prefix retained across cancellation");
    Ok(())
}

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

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

תוצאת פעולת זמן

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

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

הורה וילדים

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

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

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

ניקוי שדורש המתנה

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

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

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