לדלג לתוכן

משימה אינה תהליכון

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

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

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

מה צריך לעבור בין עובדים

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

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

```rust,compile_fail
use std::rc::Rc;

[tokio::main]

async fn main() {
tokio::spawn(async {
let value = Rc::new(7);
tokio::task::yield_now().await;
println!("{}", value);
}).await.unwrap();
}

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

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

## ניסוי בחסימת העובד

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

```toml
[package]
name = "blocking_worker"
version = "0.1.0"
edition = "2024"

[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"] }

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

use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};
use std::time::Duration;

async fn run_case(offload: bool) {
    let ticks = Arc::new(AtomicUsize::new(0));
    let ticker_ticks = ticks.clone();
    let ticker = tokio::spawn(async move {
        loop {
            tokio::time::sleep(Duration::from_millis(10)).await;
            ticker_ticks.fetch_add(1, Ordering::Relaxed);
        }
    });
    tokio::task::yield_now().await;
    if offload {
        tokio::task::spawn_blocking(|| {
            std::thread::sleep(Duration::from_millis(120));
        }).await.unwrap();
    } else {
        std::thread::sleep(Duration::from_millis(120));
    }
    let observed = ticks.load(Ordering::Relaxed);
    ticker.abort();
    let _ = ticker.await;
    println!("offload={offload}, observed ticks={observed}");
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    run_case(false).await;
    run_case(true).await;
}

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

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

מאגר חוסם אינו משאב אינסופי

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

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

async fn compute(limits: Arc<tokio::sync::Semaphore>, input: Vec<u64>) -> u64 {
    let permit = limits.acquire_owned().await.unwrap();
    tokio::task::spawn_blocking(move || {
        let _permit = permit;
        input.into_iter().fold(0_u64, |sum, value| sum.wrapping_add(value))
    }).await.unwrap()
}

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

ביטול אינו עצירת הוראות זרה

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

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

משאבים תלויי מנוע

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

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

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