לדלג לתוכן

8.1 - מכונת המצבים של Future ושל async - הרצאה

מחכים לתוצאה בלי להחזיק תהליכון

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

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

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

חוזה ההתקדמות

הממשק המרכזי של העתיד נראה כך:

pub trait Future {
    type Output;
    fn poll(
        self: std::pin::Pin<&mut Self>,
        cx: &mut std::task::Context<'_>,
    ) -> std::task::Poll<Self::Output>;
}

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

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

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

ניסוי שבו כל מעבר גלוי

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

שמרו את התוכנית בקובץ future_steps.rs:

use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};
use std::task::{Context, Poll, Wake, Waker};

#[derive(Default)]
struct PauseOnce { paused: bool }

impl Future for PauseOnce {
    type Output = u32;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u32> {
        if !self.paused {
            self.paused = true;
            cx.waker().wake_by_ref();
            Poll::Pending
        } else {
            Poll::Ready(7)
        }
    }
}

struct CountWake(AtomicUsize);

impl Wake for CountWake {
    fn wake(self: Arc<Self>) {
        self.0.fetch_add(1, Ordering::Relaxed);
    }
}

async fn calculate() -> u32 {
    println!("entered");
    let value = PauseOnce::default().await;
    println!("resumed: {value}");
    value * 2
}

fn main() {
    let mut future = Box::pin(calculate());
    println!("created");
    let counter = Arc::new(CountWake(AtomicUsize::new(0)));
    let waker = Waker::from(counter.clone());
    let mut cx = Context::from_waker(&waker);

    assert!(future.as_mut().poll(&mut cx).is_pending());
    println!("wakes: {}", counter.0.load(Ordering::Relaxed));
    assert_eq!(future.as_mut().poll(&mut cx), Poll::Ready(14));
}

הפקודות הבאות בונות ומריצות את הדוגמה באמצעות מהדר ראסט יציב:

rustc --edition=2024 future_steps.rs -o future_steps
./future_steps

הפלט הצפוי הוא:

created
entered
wakes: 1
resumed: 7

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

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

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

מכונת המצבים שמסתתרת מאחורי התחביר

אפשר לתאר את החישוב שלנו באמצעות ארבעה מצבים רעיוניים:

Created
   | poll
   v
Waiting { inner: PauseOnce { paused: true } }
   | poll -> inner Ready(7)
   v
Completed { output delivered }

Created or Waiting -- drop --> Destroyed

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

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

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

השמדה באמצע העבודה

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

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

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

גבולות הניסוי

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

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

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