Liran Chen's Blog

.Net Internals, Development, Multithreading - and More!

The Case of Delayed ACKs and Nagle's Algorithm

למרות שההשלכות של השילוב בין Nagle's Algorithm ו-Delayed ACKs בעבודה עם TCP מתועדות היטב בספרות, עדיין מדובר בפרט שלא פעם קל לשכוח ממנו ולא לקחת אותו בחשבון כשצריך. מאחר ולסימפטומים הבעייתים של השילוב ביניהם יש נטייה לצוף רק תחת אוסף של תנאים מקדימים, יכול לעבור קצת זמן עד שמשייכים את את ה"תופעות המעניינות" לשימוש בשני האלגוריתמים הנ"ל. במקרה הזה, ככל שהסימפטומים שנוצרים עקב השילוב חריפים יותר, כך גם יותר קל להבין את המקור שלהם.
אבל קודם כל, נבין מה הרעיון מאחורי כל אחד מהאלגוריתמים האלה, ולמה למעשה האינטרקציה ביניהם יכולה להביא לתוצאות לא רצויות.

Delayed ACKs
בגלל ש-TCP דואג להבטיח שכל פאקט (Packet) שנשלח ברשת אל לקוח מרוחק באמת יצליח להגיע אליו, הוא זקוק לאיזשהו אישור (Acknowledgment) מהצד המקבל שאותו פאקט באמת הגיע אליו. לכן, אותו לקוח שקיבל את ההודעה החדשה, חייב לדאוג לשלוח בחזרה הודעת ACK מתאימה שתאשר את קבלתה.
הבעיה עם שימוש בהודעות בלעדיות עבור ACKs היא שאנחנו מהר מאוד יכולים למצוא את עצמנו "מציפים" את הרשת בהודעות חסרות תוכן שכל משמעותן היא בסך הכל להגיד "כן, קיבלתי את ההודעה שלך" (אם מחברים את הגודל המינימלי עבור ה-Header'ים של TCP ו-IP בלבד [גם מבלי להחשיב את התוספת של Ethernet] כבר מגיעים ל-40 בתים [ועבור IPv6 המספר הזה כבר גדל ל-60 בתים]).
לכן, על מנת לחסוך את ה-Overhead, הוגדר השימוש ב-Delayed ACKs. הרעיון הוא שבמקום שנענה בהודעה חדשה על כל פאקט שאנחנו מקבלים, יוצאים מתוך הנחה שהאפליקציה שלנו כנראה הולכת לשלוח "איזשהיא" הודעה בזמן הקרוב עבור מי ששלח לנו אותה ההודעה המקורית (לא בהכרח מדובר בהודעת Reponse, אלא כל הודעה שהיא), ולכן למעשה נוכל "לרכב" על ההודעה שהאפליקציה רוצה לשלוח, ונוסיף לה את ה-ACK על הפאקט שקיבלנו מקודם. כך שבצורה הזאת אנחנו יכולים לחסוך לא מעט תעבורת רשת מיותרת.
בדרך כלל נהוג להשתמש בעיכוב של 200ms עבור שליחת ACK'ים (האופי המדוייק של הערך הוא גם תלוי במימוש של הפרוטוקול. למשל האם המפתח החליט להשתמש בטיימר שמתעורר תמיד כל 200ms מאז פתיחת הסוקט, או שהוא פותח טיימר חדש רק כשהוא צריך לשלוח ACK?). ב-Windows ברירת המחדל היא 200ms גם כן, אבל אם רוצים, ניתן לערוך את הערך של
TcpDelAckTicks ב-Registry ולקבוע אותו היכנשהו בין הגבולות של 0ms עד 600ms (בתור אנקדוטה, ה-Host Requirements RFC אוסר על שימוש ב-delay הגבוה יותר מ-500ms).
כדאי גם לציין שעל פי ה-RFC גם אין צורך לשלוח ACK על כל פאקט שאנו מקבלים. הרי ש-ACK אחד יכול להחשב בתור אישור קבלה עבור מספר פאקטים שונים. דוגמה למקרה כזה היא שעמדה א' שולחת 5 הודעות נפרדות בהפרשים של 10ms לעמדה ב'. ברגע שההודעה הראשונה מגיעה לעמדה ב', נפתח טיימר ל-200ms שמטרתו לשלוח ACK על ההודעה הזאת (נצא מתוך הנחה שמדובר בממשק חד-כיווני ושהאפליקציה לא מתכוונת לשלוח שום הודעה בעצמה). עד שאותו טיימר יספיק לפקוע, אנחנו מספיקים לקבל את שאר ארבעת ההודעות הנותרות. אבל בכלל שאנחנו כבר יודעים שיש לנו הודעת ACK שנמצאת כבר "בקנה", אנחנו רק צריכים לדאוג לעדכן אותה כך שהיא תאשר גם את קבלת ההודעות הנוספות. לאחר פקיעת הטיימר, תשלח הודעת ACK בודדת שתאשר את קבלת כל 5 ההודעות.
נוסף על כך, לא מדובר בטריגרים היחידים לשליחת ACK. למשל, טריגר אחר לשליחה הוא התמלאות ה-Receive Window (מירב הבתים שאנחנו יכולים לקבל ללא שליחת ACK), עוד אחד הוא שימוש בפוליסת ה-"ACK עבור כל הודעה שניה" שדואגת לשלוח ACK עבור כל פאקט שני שאנחנו מקבלים. ב-Windows אפשר גם לשנות את מספר ברירת המחדל הזה (2), על ידי עדכון הערך של TcpAckFrequency ב-Registry.
 
Nagle's Algorithm
למרות שאינו קשור לשימוש ב-Delayed ACKs, אלגוריתם זה בא לפתור בעייה דומה שמתרחשת בצד השני של שולח הנתונים.מאותה הסיבה שכדאי להמנע משליחת הודעות ACK בודדות וקטנות על הרשת, כך כדאי גם להמנע משליחת הודעות קטנות "רגילות" מצד האפליקציה, משום שגם במקרה כזה אנחנו נסבול מה-Overhead של שליחת ה-Header'ים, בעוד שגודל ההודעה שהאפליקציה רוצה לשלוח הוא מאוד קטן.
על פי האלגוריתם, הפרוטוקול רשאי לדחות פעולות שליחה קטנות על מנת לבצע "Buffering" להודעות כך שבסופו של דבר ישלח רק פאקט בודד שמכיל בתוכו מספר הודעות שונות. התשובה לשאלה "מתי להפסיק לצבור הודעות ולשלוח את הפאקט" אינה שרירותית והיא מסתמכת על קצב קבלת ה-ACK'ים מהלקוח המרוחק. הרעיון הוא שכל עוד לא קיבלנו ACK על הפאקט האחרון ששלחנו, אין טעם שנשלח פאקט נוסף. לכן, בזמן שבו אנחנו מחכים לקבלת ה-ACK על הפאקט הקודם, אנחנו מבצעים צוברים את כל ההודעות שהאפליקציה מעוניינת לשלוח (תחת גבולות ה-MSS). ברגע שנקבל את ה-ACK על הפאקט הקודם, כל ההודעות שהצטברו עד כה ישלחו בתוך פאקט בודד. מה שיפה באלגוריתם הזה הוא שהוא מתאים את עצמו ללקוח ששולח בחזרה ACK'ים. ככל שקצב קבלת ה-ACK'ים גודל, כך גם יגדל קצב שליחת ההודעות החדשות.

אז כפי שזה נראה, לשני האלגוריתמים שהזכרנו עכשיו יש "זכות קיום" לגיטימית מאחר והם באים לפתור בעיות אמיתיות ומהותיות בטבען. עם זאת, מה יקרה כשנשלב בין שניהם? מצד אחד כל אחד מהם ינסה לצמצם את שליחת ה-tinygrams בצד שלו (שליחת הודעות אפליקטיביות לעומת שליחת ACK'ים), אבל מצד שני, תחת תנאים מסויימים הם יכולים לגרום לעיכובים משמעותיים בתדרי שליחת ההודעות על גבי הרשת. הדוגמה הבולטת ביותר לכך היא אצל ממשקים חד-כיווניים, שם רק צד אחד שולח הודעות, בעוד שהצד השני אף פעם לא שולח הודעה בחזרה (למשל לקוח שהתחבר לשרת וכעת רק מזין אותו בנתונים, בלי לקבל שום Feedback בחזרה). במקרה כזה, גם אם האפליקציה שלנו שולחת הודעות ללא הפסקה בקצב מאוד גבוה, אנחנו עדיין צפויים לחוש בקפיצות של עד 200ms מהזמן שהאפליקציה רצתה לשלוח את ההודעה, עד שהפרוטוקול החליט לשלוח אותה בפועל. במקרים אחרים, גם אצל ממשקים דו-כיווניים, יתכנו מצבים בהם אחד מהצדדים מפסיק לשלוח הודעה לכמה רגעים, ובעקבות זאת הוא גם מפסיק לקבל הודעות חדשות במשך 200ms. במקרה כזה, אנחנו עשויים לראות הקפיצות בעיכוב שליחת ההודעות רק "לפעמים" בלי הסבר ברור (וגם אז, לא בהכרח נחכה 200ms שלמים). מידת ההשפעה והחומרה של עיכוב כזה על האפליקציה יכול להשתנות בהתאם לאופי האפליקציה.



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


void Server()

{

    Socket server = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);

    server.Bind(ServerEndPoint);

    server.Listen(1);

    Socket s = server.Accept();

 

    while (true)

    {

        // measure how long it takes to receive both messages

        Stopwatch stopwatch = Stopwatch.StartNew();

 

        s.Receive(new byte[8]);

        s.Receive(new byte[8]);

 

        // Output: around 200ms

        Console.WriteLine(stopwatch.ElapsedMilliseconds);

    }

}

 

void Client()

{

    Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);

    client.Bind(ClientEndPoint);

    client.Connect(ServerEndPoint);

 

    while (true)

    {

        client.Send(new byte[8]); // will be sent immediately

        client.Send(new byte[8]); // delayed for 200ms

 

        // wait for an imaginery response

        client.Receive(new byte[0]);

    }

}


בעקבות ההשלכות הלא רצויות שיכולות להיווצר כתוצאה מהעיכוב הזה, ה-RFC מציין שמימושים של TCP שממשים את Nagle's Algorithm חייבים לתמוך גם בדרך לבטל את השימוש בו, כך שהודעות אפליקטיביות שנשלחות לא יעוכבו ללא סיבה על ידי הפרוטוקול, וישלחו מיידית ליעד שלהן. היכולת הזאת בדרך כל נחשפת דרך השימוש בדגל TCP_NODELAY, ובדוט-נט עטפו את השימוש בו דרך הפרופרטי Socket.NoDelay.
שלח תגובה

(שדה חובה)  

(שדה חובה)  

(אופציונלי)

(שדה חובה) 

Please add 4 and 5 and type the answer here: