חקירה עצמאית של מערכת לא מוכרת¶
להחליט מה לבדוק בלי רשימת צעדים¶
עד עכשיו בחרנו כלים אחרי שהסברנו מראש את סוג התקלה. במציאות נקבל לעיתים בינארי, תיאור חלקי ומעט זמן. המיומנות הנדרשת היא להקטין את אי־הוודאות בלי לשנות בבת אחת את כל תנאי המערכת. נבנה חקירה בשלבים, וכל שלב יסתיים בהחלטה מה הראיות מאפשרות לומר ומה עדיין לא ידוע.
המעבדה הבאה מכילה מערכת עבודה קטנה עם כמה פגמים שתלויים בתמהיל הבקשות. בשלב הראשון נריץ אותה ונאסוף תוצאות בלי להשתמש בקריאת הקוד כתחליף לתצפית. לאחר ניסוח השערות נפתח את המימוש ונבדוק אם הוא מסביר את התוצאות. המטרה אינה משחק ניחושים, אלא תרגול של מעבר בין תצפית, מודל ומקור.
מערכת המעבדה¶
נשמור את הקוד בקובץ incident_lab.rs ונבנה עם אופטימיזציה ומידע ניפוי. התוכנית מקבלת מצב עומס ומספר בקשות מוגבל. ארבעה עובדים קוראים מתור מוגבל, וכל תוצאה מציגה זמן מקצה לקצה שמתחיל לפני ניסיון ההכנסה לתור. לכן הזמן כולל גם לחץ שהיצרן מרגיש כאשר התור מלא.
use std::sync::{mpsc, Arc, Mutex};
use std::time::{Duration, Instant};
struct Job { id: usize, bad: bool, slow: bool, entered: Instant }
struct State { used: usize, history: Vec<Vec<u8>> }
fn handle(state: &Mutex<State>, job: &Job) -> Result<usize, &'static str> {
let mut state = state.lock().unwrap();
if state.used >= 8 { return Err("capacity"); }
state.used += 1;
if job.bad { return Err("invalid"); }
if job.slow { std::thread::sleep(Duration::from_millis(20)); }
state.history.push(vec![(job.id % 256) as u8; 1024]);
let retained = state.history.len();
state.used -= 1;
Ok(retained)
}
fn main() {
let args: Vec<_> = std::env::args().collect();
let mode = args.get(1).map(String::as_str).unwrap_or("clean");
assert!(["clean", "invalid", "mixed"].contains(&mode));
let count: usize = args.get(2).map(String::as_str).unwrap_or("400")
.parse().expect("request count");
assert!((1..=10_000).contains(&count));
let state = Arc::new(Mutex::new(State { used: 0, history: Vec::new() }));
let (sender, receiver) = mpsc::sync_channel::<Job>(64);
let receiver = Arc::new(Mutex::new(receiver));
let (results, completed) = mpsc::channel();
let mut workers = Vec::new();
for index in 0..4 {
let (receiver, state, results) = (receiver.clone(), state.clone(), results.clone());
workers.push(std::thread::Builder::new().name(format!("worker-{index}"))
.spawn(move || loop {
let next = { receiver.lock().unwrap().recv() };
let Ok(job) = next else { break; };
let result = handle(&state, &job);
results.send((job.id, job.slow, job.entered.elapsed().as_micros(), result))
.unwrap();
}).unwrap());
}
drop(results);
println!("pid={} mode={mode} count={count}", std::process::id());
let begin = Instant::now();
for id in 0..count {
sender.send(Job { id, bad: mode == "invalid" && id % 7 == 0,
slow: mode == "mixed" && id % 10 == 0, entered: Instant::now() }).unwrap();
}
drop(sender);
for worker in workers { worker.join().unwrap(); }
let mut times = Vec::new();
let mut errors = 0;
for (id, slow, elapsed, result) in completed {
if result.is_err() { errors += 1; }
times.push(elapsed);
println!("id={id} slow={slow} us={elapsed} result={result:?}");
}
times.sort_unstable();
let state = state.lock().unwrap();
println!("wall={:?} errors={errors} used={} retained={} p99_us={}",
begin.elapsed(), state.used, state.history.len(), times[(times.len() - 1) * 99 / 100]);
}
התור של התוצאות אינו מוגבל במימוש הזה, אך מספר הבקשות כולו מוגבל מראש לעשרת אלפים והוא משמש לאיסוף המעבדה. בשירות מתמשך נצטרך להגדיר גם לו גבול או לצרוך תוצאות תוך כדי העבודה. אין להסיק מהמגבלה על תור הקלט שכל זיכרון התוכנית בהכרח מוגבל ללא תלות במשך חייה.
חוזה החקירה הראשוני¶
נריץ בנפרד את המצבים clean, invalid ו־mixed. לכל מצב נשמור את אותה גרסת בינארי, אותו מספר בקשות ואת תוצאת התהליך. נרשום אילו שגיאות צפויות בגלל הקלט ואילו שגיאות אינן מוצדקות על ידי החוזה. בקשה שסומנה כלא תקינה רשאית להידחות, אבל אינה רשאית להשאיר את כל השירות בלי יכולת לטפל בבקשות תקינות עתידיות.
נשווה כמה גדלי עומס, ולא רק ריצה אחת. מצב ששומר רשומה לכל בקשה יראה מגמה שונה ממצב שמחזיק מאגר בגודל קבוע. מצב שתלוי בבקשות איטיות יגיב לשינוי התמהיל. ההשוואות האלה נותנות לנו דרך להפריד תקלות שיכולות להופיע יחד באירוע גדול.
בחירת ראיות לפי השערה¶
אם מספר השגיאות גדל אחרי קלט לא תקין, נתחיל ממוני מכסה וממסלולי סיום. אם הזיכרון גדל גם בעומס נקי, נבדוק אובייקטים שנשמרים אחרי סיום העבודה. אם בקשות מהירות מתעכבות רק בנוכחות בקשות איטיות, נבחן נעילות ותורים. אין צורך להפעיל מיד כל כלי שלמדנו, כי כל כלי מוסיף עלות ונתונים שצריך לפרש.
פרופיל מעבד עשוי להראות שימוש קטן במצב המעורב, משום שההמתנה המכוונת אינה חישוב. מחסניות עובדים יכולות להראות המתנה לנעילה או לתור. מונה ההיסטוריה יכול להסביר חלק מהזיכרון בלי שנצטרך לנחש מתוך מספר הזיכרון התושב בלבד. כל תצפית מכוונת לשאלה אחרת.
סדר ניסויים שמבודד סיבה¶
נשנה גורם אחד בכל פעם. ראשית נריץ עומס נקי כדי למדוד את התנהגות הבסיס. אחר כך נוסיף רק בקשות שגויות ונבדוק אם משאבים חוזרים. לאחר מכן נוסיף רק בקשות איטיות ונבדוק מי ממתין להן. שילוב כל הפגמים מההתחלה עלול לגרום לכך שדליפת המכסה תעצור את העבודה לפני שנראה את בעיית ההמתנה.
נעדיף ניסוי קצר שמייצר ראיה ברורה על פני ריצה ארוכה עם הרבה תסמינים. כדי לבדוק חשבונאות אין צורך בעשרת אלפים בקשות אם שמונה שגיאות כבר מסבירות את המעבר. כדי לבדוק מגמת שימור זיכרון נצטרך יותר מנקודה אחת, אבל עדיין אפשר לבחור גדלים בטוחים ומוגבלים.
לקרוא מקור אחרי שיש שאלה¶
כאשר נפתח את המימוש נחפש את המקום שבו מתבצע המעבר שראינו. נעקוב אחרי חיי נעילת המצב, אחרי ההגדלה וההפחתה של המכסה ואחרי המקום שמוסיף להיסטוריה. לא כל שורה חשודה היא בהכרח גורם לתסמין שנמדד. נרצה קשר שמסביר גם את המקרה שבו התקלה מופיעה וגם את המקרה שבו היא אינה מופיעה.
נבדוק את משך חיי שומר הנעילה בשפת ראסט, ולא רק את שורת הקריאה ל־lock. שומר שנשאר בתחום לאורך שינה מחזיק את הנעילה לאורך כל ההמתנה. נבדוק גם החזרות מוקדמות, כי הן יכולות לשחרר נעילה אוטומטית ובו בזמן להשאיר מונה לוגי במצב שגוי. בטיחות זיכרון אינה מבטיחה שכל האינווריאנטים העסקיים נשמרים.
תיקון שמפריד אחריות¶
הפתרון צריך להפריד חשבונאות של עבודה חיה, היסטוריית תצפית ועבודה איטית. להיתר יהיה מחזור חיים שמסתיים בכל נתיב רגיל. להיסטוריה יהיה גבול מוגדר ותוכן שמשרת מטרה תפעולית. העבודה האיטית לא תחזיק נעילה של מצב שאינו חייב להיות מוגן לאורך אותה המתנה.
אחרי כל שינוי נריץ שוב את שלושת המצבים, משום שתיקון יכול להסתיר פגם אחר או לשנות את סדר האירועים. למשל, העברת השינה מחוץ לנעילה מגדילה מקביליות ועלולה לחשוף בעיה במונה שאינו מסונכרן נכון. לכן נעבור מתיקון למבחן אינווריאנטים ולא רק להשוואת זמן כולל.
להגן על בחירת הכלי¶
בדוח נבחר לכל טענה ראיה מתאימה. לטענה על מכסה נציג מצב לפני ואחרי שגיאות. לטענה על שימור נתונים נציג מספר רשומות אחרי סיום כל העבודות. לטענה על חסימה נציג חיי נעילה ותזמוני בקשות מסוגים שונים. אם השתמשנו בכלי יקר, נסביר איזו שאלה הוא פתר שלא יכולנו לפתור באמצעות מדד פשוט יותר.
נכתוב גם מה לא הוכחנו. תוכנית המעבדה אינה שרת רשת אמיתי, אינה כוללת אחסון עמיד ואינה מודדת זמן תגובה של משתמש מרוחק. היא כן מספקת כמה כשלים לוגיים ומקביליים שאפשר לשחזר ולהסביר. היכולת לשמור את גבול המסקנה היא חלק מהמקצועיות של חקירת מערכות.