9.4 - חיבור TCP ותנועת הנתונים - הרצאה
חיבור פתוח אינו שירות מוכן¶
כאשר לקוח מצליח להתחבר, הוא יודע שהוקם חיבור ברמת התעבורה בהתאם לתוצאת הממשק. הוא אינו יודע שהשרת כבר קרא את הבקשה, שיש עובד פנוי לטפל בה או שמסד הנתונים זמין. ההבחנה הזאת חשובה במיוחד כאשר בונים בדיקת בריאות שמנסה רק לפתוח סוקט ומכריזה שהמערכת תקינה.
הקרנל יכול לנהל את הקמת החיבור ואת תורי החיבורים הממתינים גם כשהיישום עדיין לא קרא ל־accept. לכן אפשר לראות הצלחת חיבור בזמן שהתהליך תקוע לפני קבלת לקוחות חדשים. אם ממשיכים להעמיס, בסופו של דבר גם התורים והמגבלות הקרנליות ישפיעו על התוצאה, אבל הצלחה של ניסיון בודד אינה הוכחת זמינות עסקית.
נפריד לאורך ההרצאה בין שלושה רגעים: חיבור הוקם, בתים התקבלו אצל צד התעבורה המרוחק, ופעולה יישומית הושלמה. כל רגע דורש ראיה אחרת. ערבוב ביניהם גורם להבטחות שגויות על מסירה ולמדידות זמן שאינן מסבירות את מה שהמשתמש רואה.
ניסוי לפני קבלת החיבור¶
השרת בדוגמה יוצר סוקט מאזין, אבל מחכה בערוץ לפני שהוא מקבל את החיבור. הלקוח מתחבר ושולח בקשה קטנה בזמן שהשרת עדיין מחכה. הערוץ מאפשר לנו להוכיח שהקוד היישומי עוד לא הגיע לקבלה, בלי להסתמך על משך שינה משוער.
use std::io::{self, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, mpsc, atomic::{AtomicBool, Ordering}};
use std::time::Duration;
fn main() -> io::Result<()> {
let listener = TcpListener::bind(("127.0.0.1", 0))?;
let address = listener.local_addr()?;
let accepted = Arc::new(AtomicBool::new(false));
let server_accepted = accepted.clone();
let (release, released) = mpsc::channel();
let server = std::thread::spawn(move || -> io::Result<()> {
released.recv().unwrap();
let (mut stream, _) = listener.accept()?;
server_accepted.store(true, Ordering::SeqCst);
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
let mut request = [0_u8; 5];
stream.read_exact(&mut request)?;
assert_eq!(&request, b"hello");
stream.write_all(b"ok")
});
let mut client = TcpStream::connect(address)?;
client.set_read_timeout(Some(Duration::from_secs(2)))?;
client.write_all(b"hello")?;
assert!(!accepted.load(Ordering::SeqCst));
println!("connected and queued bytes before application accept");
release.send(()).unwrap();
let mut response = [0_u8; 2];
client.read_exact(&mut response)?;
assert_eq!(&response, b"ok");
server.join().unwrap()?;
assert!(accepted.load(Ordering::SeqCst));
Ok(())
}
הדוגמה ניתנת לבנייה באמצעות הספרייה התקנית בלבד. התוצאה הצפויה כוללת את ההודעה לפני שחרור השרת ולאחר מכן סיום תקין של הבקשה. אין כאן טענה שהלקוח יכול להמשיך לשלוח כמות בלתי מוגבלת בזמן שהיישום אינו קורא; הבאפרים והתורים עדיין מוגבלים.
אפשר להפוך את הניסוי לכלי חקירה באמצעות עצירה מבוקרת לפני accept ובדיקה של התורים עם ss. יש לתעד את הכתובת והפורט שנבחרו בפועל, משום שהדוגמה מבקשת פורט פנוי מהמערכת. תצפית על חיבור אחד אינה מספיקה כדי להסיק מה יקרה כאשר תור הקבלה מלא.
מסלול ההקמה והתורים¶
בהקמה הרגילה שני הצדדים מחליפים מידע שמאפשר לתאם מספרי רצף ומצב חיבור. בצד המאזין קיימים מצבים שעדיין קשורים להקמה ומצבים של חיבורים שהיישום יכול לקבל. בלינוקס חשוב להבחין בין מגבלות המסלול החצי־פתוח לבין מגבלת החיבורים שממתינים לקבלה. מנגנונים כגון עוגיות הקמה יכולים לשנות את אופן שמירת המצב תחת עומס.
הפרמטר של תור ההאזנה אינו מספר העובדים של היישום ואינו תקציב לבקשות שכבר התקבלו. הוא כפוף גם למגבלות מערכת. הגדלתו יכולה לאפשר ליותר לקוחות להמתין, אבל אינה מגדילה את קצב הטיפול אם צוואר הבקבוק הוא מסד הנתונים או תהליכון עבודה יחיד.
הפקודות הבאות מציגות מידע לקריאה בלבד בסביבת המעבדה. השדות המדויקים תלויים בגרסת הכלים והקרנל, ולכן מצרפים אותם לדוח יחד עם הגרסאות.
אין לשנות ערכי מערכת גלובליים כדי לגרום לבדיקה לעבור בלי להבין את הסיבה. בפרק הבידוד ניצור סביבת רשת נפרדת לניסויים שבהם נדרש שינוי מכוון של מגבלות או אובדן מנות.
חיבור לא חוסם¶
בפתיחת חיבור לא חוסם, הקריאה יכולה לדווח שהפעולה עדיין מתקדמת. לאחר מכן מחכים למוכנות מתאימה ובודקים את שגיאת הסוקט באמצעות SO_ERROR. מוכנות לכתיבה אינה לבדה הוכחה שהחיבור הצליח; היא יכולה להודיע גם שהניסיון הסתיים בכשל.
השלב הזה צריך להשתלב במכונת המצבים של החיבור. לפני הצלחה אין להתייחס לסוקט כאילו הוא כבר מוכן לשליחת בקשות רגילות. אחרי כשל אין למחזר את אותו מצב בקשה בלי להגדיר כיצד נוצר ניסיון חדש ומה קורה למועד הסיום המקורי.
התיעוד של פתיחת חיבור מגדיר את מסלול הבדיקה הזה. ההשוואה למנגנון המוכנות מהפרק הקודם עוזרת לזכור שהאירוע אומר לבדוק תוצאה, ולא שהאירוע עצמו הוא התוצאה.
שני חלונות שונים¶
בקרת זרימה מגינה על המקבל מפני כמות נתונים גדולה ממה שהוא מסוגל לקבל לבאפרים שלו. בקרת גודש מנסה להתאים את קצב השליחה למצב הנתיב. המגבלה המעשית על נתונים שנמצאים בדרך מושפעת משניהם, לצד פרטים נוספים של המימוש והקצב.
אם היישום המקבל מפסיק לקרוא, הבאפר שלו יכול להתמלא והחלון שהוא מפרסם יכול להצטמצם. במקרה כזה הכותב עשוי להיתקע למרות שהרשת עצמה אינה מאבדת מנות. אם הנתיב סובל מאובדן או מגודש, ייתכנו שידורים חוזרים ושינוי במדיניות השליחה גם כשהיישום המקבל פנוי לגמרי.
לכן תסמין של כתיבה איטית אינו מצביע מיד על קוד איטי בצד השולח. נבדוק האם יש נתונים ממתינים בבאפר המקומי, האם הצד השני קורא, האם יש שידורים חוזרים ומהו זמן הסבב שנמדד. מדידה בשכבה אחת בלבד עלולה להחמיץ את הסיבה.
אישור תעבורה ואישור יישום¶
כאשר כתיבה רגילה מחזירה הצלחה, היא מדווחת כמה בתים התקבלו במסלול המקומי בהתאם לממשק. היא אינה מוכיחה שהיישום המרוחק כבר עיבד אותם. גם אישור ברמת התעבורה אינו אישור שהפעולה העסקית נשמרה בדיסק או בוצעה פעם אחת.
הפרוטוקול שלנו צריך להגדיר תשובה יישומית אם נדרשת ידיעה כזאת. אם הלקוח מאבד את החיבור אחרי שליחת בקשה ולפני קבלת האישור היישומי, חוזרת בעיית התוצאה הלא ידועה. האמינות של זרם הבתים אינה מבטלת את הצורך במזהה בקשה ובחוזה התאוששות.
מדוע הזנב ארוך מהממוצע¶
רוב הבקשות יכולות להסתיים במהירות, בעוד שמעטות משלמות מחיר של המתנה לשידור חוזר או של תור עמוס. לכן זמן תגובה ממוצע קטן אינו מוכיח חוויית שירות יציבה. נפריד בין זמן המתנה בתור היישום, זמן המתנה לפתיחת חיבור וזמן ההעברה והתגובה.
מנגנון השידור החוזר מעריך זמני סבב ומשתמש במדיניות המתאימה לכשלי מסירה. אין להניח שכל אובדן מוסיף בדיוק זמן סבב אחד. התקן של שעון השידור החוזר מסביר את העיקרון, וההתנהגות שנמדדת בפועל תלויה גם באלגוריתמים ובתכונות של המימוש.
אפשרויות כגון TCP_NODELAY משנות החלטות מסוימות על איגוד כתיבות קטנות, אך אינן מתקנות תור יישומי בלתי מוגבל או מקבל שאינו קורא. נשווה אותן בניסוי שמבודד גודל הודעות ותבנית בקשה־תגובה, במקום להוסיף אותן לכל סוקט כטקס. התיעוד של מימוש לינוקס ושל פרוטוקול התעבורה יסייע להפריד בין חוזה כללי לפרט של מערכת המעבדה.