לירן חן

.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.
Behind .locals init
כפי שכולנו מכירים, #C דורשת שכל המשתנים הלוקאלים יאותחלו לפני השימוש בהם.
עם זאת, למי שיצא להעזר ב-ildasm בשביל להציץ לתוך קוד ה-IL שהקומפיילר מייצר, בוודאי שם לב שמייד לאחר ההכרזה על שם הפונקציה, מתווספת שורה בסגנון הבא:

.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       10 (0xa)
  .maxstack  1
  .locals init ([0] int32 x) <--- localsinit flag
  IL_0000:  ldc.i4.4
  IL_0001:  stloc.0
  IL_0002:  ldloc.0
  IL_0003:  call       void [mscorlib]System.Console::WriteLine(int32)
}
השורה הזאת מייצגת את הימצאות הדגל CorILMethod_InitLocals ב-Header של הפונקציה שאנחנו נמצאים בה. הדגל הזה למעשה מבטיח שה-CLR יאתחל את כל המשתנים הלוקאלים הנמצאים בפונקציה לערכי ברירת המחדל שלהם. כלומר, לא משנה איזה ערך דאגתם לתת למשתנה הלוקאלי שלכם (במקרה הזה המשתנה x מקבל את הערך 4), הסביבה תוודא שלפני שהקוד יתבצע, המשתנה x בהכרח יהיה מאותחל לערך חוקי (במקרה הזה, 0).

במימוש המיקרוסופטי של הסטנדרט, הדגל הזה תמיד קיים ב-Header (בהנחה שבאמת נוצרים משתנים לוקאלים בגוף הפונקציה). מה שיכול לגרום לנו מעט לתהות למה אם כך הקומפיילר ה-#C'י מכריח אותנו לאתחל את כל המשתנים הלוקאלים שלנו, אם הקוד שהוא מייצר בעצמו גם ככה מבטיח שכל המשתנים יאותחלו. האילוץ הזה יוצר רושם כמיותר אבל בפועל קיימות מספר סיבות שגורמות להמצאות הדגל הזה להיות כמעט הכרחית.

לפני שנבדוק מהי המשמעות מאחורי השימוש בדגל localsinit, נחזור לרגע לשאלת האתחול הכפול.
כאמור, כפי שניתן להבין מקוד ה-IL שנוצר לנו, נראה שבכל פעם שאנחנו יוצרים משתנה לוקאלי חדש, נוספת לנו תקורה מיותרת הנובעת מהאתחול הכפול של המשתנה (פעם אחת על ידי הסביבה, ועוד פעם על ידינו). התקורה הזאת היא אומנם מינורית לגמרי בהיבט של פגיעה בביצועים, אבל היא בכל זאת יכולה להעביר בנו איזשהו vibe לא טוב בגלל שאם אפשר פשוט להודות: הקוד הזה נראה רע.
אך למעשה, האתחול הכפול הזה אף פעם לא מתקיים. הסיבה לכך טמונה בצורה בה הדגל localsinit מבטיח את ערכי ברירת המחדל. כל מה שהוא עושה, זה לדאוג שה-JIT יחולל קוד שיאתחל את המשתנה לפני השימוש בו. במקרה שלנו, ה-JIT יצטרך לחולל הוראת mov שתאתחל את x ב-0.
ואכן, קוד האסמבלי שאנחנו מקבלים בזמן ריצה (ללא שימוש באופטימיזציות) מאשר זאת:

Normal JIT generated code
ConsoleApplication4.Program.Main(System.String[])
Begin 00e20070, size 30
00E20070 push        ebp
00E20071 mov         ebp,esp
00E20073 sub         esp,8
00E20076 mov         dword ptr [ebp-4],ecx
00E20079 cmp         dword ptr ds:[00942E14h],0
00E20080 je          00E20087
00E20082 call        7A0CA6C1 (JitHelp: CORINFO_HELP_DBG_IS_JUST_MY_CODE)

-------------------- Generated code due to the LocalsInit flag  ----------------
00E20087 xor         edx,edx                    // zero out the EDX register
00E20089 mov         dword ptr [ebp-8],edx   // assign the value of EDX to the location of 'X'

--------------------- Our own application's code ---------------------------------
00E2008C mov         dword ptr [ebp-8],4     // assign the value 4 to the location of 'X'

00E20093 mov         ecx,dword ptr [ebp-8]
00E20096 call        79793E74 (System.Console.WriteLine(Int32), mdToken: 060007c3)
00E2009B nop
00E2009C mov         esp,ebp
00E2009E pop         ebp
00E2009F ret

אם כן, בדוגמאת הקוד הזאת ניתן לראות באופן מובהק את ההשפעה שלדגל localsinit יש על חילול הקוד JIT, ועל הדרך אנחנו נחשפים לאותו אתחול כפול של המשתנה x.
אולם, צריך לזכור שהקוד הזה חולל ללא אופטימיזציות של ה-JIT. ברגע שנאפשר את השימוש באופטמיזציות, נראה שה-JIT מזהה את האתחול הראשוני בתור dead code משום שאין לו שום השפעה על התוכנית (והמשתנה הלוקאלי הוא בהכרח לא volatile). כתוצאה מכך, ה-JIT יהיה מספיק חכם כדי להסיר לגמרי את האתחול הראשוני, וחולל קוד אך ורק לאתחול האמיתי של התוכנית שלנו.
כך שלאחר שנאפשר את השימוש באופטימיזציות, הקוד המחולל נראה כך:

Normal JIT generated code
ConsoleApplication4.Program.Main(System.String[])
Begin 00c80070, size 19
00c80070 push    ebp
00c80071 mov     ebp,esp
00c80073 call    mscorlib_ni+0x22d2f0 (792ed2f0) (System.Console.get_Out(), mdToken: 06000772)
00c80078 mov     ecx,eax
00c8007a mov   edx,4  // assign 4 to the "virtual representation" of X
00c8007f mov     eax,dword ptr [ecx]
00c80081 call    dword ptr [eax+0BCh]

הדבר הראשון שניתן להבחין בו הוא שעכשיו אין לנו למעשה "משתנה x" בזכרון, אלא יש לנו במקומו register שמחזיק את ערכו. אבל חשוב מכך, ניתן לראות שכעת אין בקוד שום זכר לאותו אתחול כפול שראינו מקודם. כך בפועל אנחנו לא סובלים מתקורה כלשהיא עקב השימוש ב-localsinit.

עכשיו, אפשר לבדוק מהי למעשה המשמעות מאחורי השימוש ב-localsinit והאילוץ של הקומפיילר שמכריח את המפתח לאתחל את המשתנים הלוקאלים שלו.
הטיעון של מיקרוסופט בנוגע לשימוש במנגנון ה-Definite Assignment הוא שרוב הפעמים בהן מתכנתים לא מאתחלים משתנים לוקאלים נובעים מבאגים לוגים, ולא בגלל שהוא בונה על זה שהסביבה תאתחל את הערך ל-0. באחת התגובות של Eric Lippert בבלוג שלו, הוא מציין בעצמו:

"The reason we require definite assignment is because failure to definitely assign a local is probably a bug. We do not want to detect and then silently ignore your bug! We tell you so that you can fix it."
 
את החשיבות של הדגל localsinit אפשר לסכם במילה אחת: Verfication.
ורפיקציה היא התהליך שבו ה-CLR מוודא שכל קוד ה-CIL שקיים בתוכנית הוא "בטוח". זה כולל וידוא שהפונקציות שאנחנו מפעילים מקבלות בדיוק את מספר הפרמטרים שהן צריכות לקבל, שהפרמטרים שהן מקבלות הם מהטיפוסים הנכונים, שכל המשתנים הלוקאלים מאותחלים לפני השימוש ועוד...
במידה וה-CLR מגלה קטע קוד שנכשל בתהליך הורפיקציה, תזרק שגיאת VerficationException.
צריך לשים לב שלא כל קוד CIL חייב בהכרח להיות Verifiable, כפי שמצויין ב-Partition III של הסטנדרט:

"It is perfectly acceptable to generate correct CIL code that is not verifiable, but which is known to be memory safe by the compiler writer. Thus, correct CIL  might not be verifiable, even though the producing compiler might know that it is memory safe."

עם זאת, ברגע שאנחנו כותבים קוד שהוא לא Verifiable, אנחנו מוכרחים לשנות את ההרשאות הניתנות לו בעזרת SecurityPermissionAttribute, ולומר ל-CLR במפורש לא לבצע בדיקות ורפיקציה על הקוד בעזרת הפרופרטי SkipVerfication (ה-CLR לא יבצע בדיקת Definite Assignment על הקוד). אחת הפעמים שבאמת משתמשים ביכולת הזאת, היא כאשר רוצים לכתוב קוד unsafe בתוכנית. במקרה כזה, אנחנו צריכים לסמן בהגדרות הפרוייקט באופן מפורש שאנחנו רוצים לתמוך ב-unsafe code, מלבד שכעת הקומפיילר באמת יאפשר לנו לקמפל את הקוד, הוא גם יוסיף לאסמבלי המחולל את UnverifiableCodeAttribute, שידאג לספר ל-CLR שכל המודול הזה הוא לא Verifiable.

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

"Local variables are initialized to 0 before entering the method only if the localsinit on the method is true (see Partition I) ... System.VerificationException is thrown if the the localsinit bit for this method has not been set, and the assembly containing this method has not been granted
System.Security.Permissions.SecurityPermission.SkipVerification (and the CIL does not perform automatic definite-assignment analysis) "


בשלב מאוחר יותר המסמך גם כן מתייחס לאותה תקורה המתווספת כאשר מבצעים ניתוח של Definite Assignment על הקוד:

"Performance measurements on C++ implementations (which do not require definite-assignment analysis) indicate that adding this requirement has almost no impact, even in highly optimized code. Furthermore, customers incorrectly attribute bugs to the compiler when this zeroing is not performed, since such code often fails when small, unrelated changes are made to the program."
Don't Rely on Environment.ProcessorCount

אחת התורות הנסתרות בפיתוח מקבילי היא השאלה "בכמה ת'רדים צריך להשתמש כדי להגיע לניצול מירבי של החומרה העומדת לרשותנו?". יש שיגידו שמספר הת'רדים צריך להיות כמספר המעבדים, או כמספר המעבדים +1, או אולי בכלל פי 2 ממספר המעבדים. הסיבה שיש כל כך הרבה תשובות לשאלה, היא פשוט בגלל הסיבה שעבור כל תרחיש מסויים, תתאים תשובה אחרת (לכל אפליקציה יש אופי שונה, למשל האם היא מוגבלת על ידי ה-CPU או ה-IO?). אבל בכל אופן, תמיד הנוסחאות האלה מתבססות באיזשהיא צורה על מספר המעבדים הזמינים לנו (סך הכל, אנחנו רוצים להיות כמה שיותר Scalable כשזה נוגע להוספת מעבדים).

בדרך כלל כשהאפליקציה מריצה את רוטינת האיתחול שלה ומתכוננת ליצור את רשימת ה-Worker Threads שלה, היא תבדוק כמה מעבדים קיימים על המחשב כדי להגיע לאותו "מספר קסם" ממקודם. כדי לדעת מהמספר המעבדים הקיימים, בדרך כלל פונים ל-Environment.ProcessorCount. מה שהפרופרטי הזה בסך הכל עושה, זה לפנות ל-Environment Variable הנקרא "NUMBER_OF_PROCESSORS", ולהחזיר את הערך שלו בתור מספר.
הבעיה היא שאותו ערך לא משקף כלל את מספר המעבדים שבאמת זמינים ל-Process שלנו. בתסריט מסויים, המשתמש שהפעיל את האפליקציה החליט להעניק לה Affinity כלשהו, שבפועל יגרום לאפליקציה להשתמש רק בחלק זעום ממספר המעבדים הקיימים במחשב (נניח שעל המחשב יש 64 מעבדים, אבל לפרוסס נקבע לרוץ רק על אחד מהם). מה שיקרה בסיטואציה הזאת, היא שהאפליקציה אמנם תיצור ת'רדים כמספר המעבדים הקיימים (64), אבל לא כמספר המעבדים הזמינים לה (1). כך שכל אותם ת'רדים למעשה יחלקו את אותו מספר מצומצם של מעבדים, מה שבאופן בלתי נמנע יוביל לכמות לא מבוטלת של Context Switch'ים שפשוט יהרגו את ביצועי האפליקציה.
התסריט של קביעת Affinity הוא רחוק מלהיות מופרך מאחר ובסיטואציה בה האפליקציה שלנו מנצלת כל הזמן את כל המעבדים העומדים לרשותה, ואנחנו מעונינים להריץ על אותו המחשב, במקביל אליה, אפליקציה אינטנסיבית אחרת, נהיה חייבים לקבוע Affinity מתאים עבור 2 האפליקציות כדי שלא "יפריעו" אחת לשני. אך במידה והאפליקציות מתעלמות מה-Affinity שנקבע להן, אנחנו נמצא את עצמנו שורפים Cycle'ים בלי סיבה.

בדוט-נט אפשר לקבל את ערך ה-Affinity דרך הפרופרטי Process.ProcessorAffinity. מדובר ב-Bitmask בו כל ביט דלוק מייצג מעבד עליו הפרוסס שלנו יכול לרוץ (במידה וכולם כבויים, ה-Scheduler יחליט בעצמו באילו מעבדים להשתמש, כך שלמעשה כל המעבדים זמינים). כברירת מחדל עבור כל מעבד שזמין למערכת ההפעלה, הביט התואם יהיה דלוק. מכאן שמערכות הפעלה של 32 ביט יכולות לפנות ל-32 מעבדים, ואילו מערכות הפעלה של 64 ביט יכולות לפנות ל-64 מעבדים). עם זאת, בגרסאות האחרונות של Windows קיימת תמיכה גם במעל ל-64 מעבדים. כדי לפנות לכל המעבדים הללו משתמשים ב-Groups, כאשר כל Group יכול לפנות לעד 64 מעבדים השייכים לו. כך שאותו Bitmask שמייצג את ה-Affinity, למעשה מייצג את ה-Affinity בתוך ה-Group המיוחס (כברירת מחדל, פרוסס משתמש במעבדים מתוך Group אחד בלבד).
אז בכל אופן, כדי לעבוד בצורה מתחשבת ולתמוך ב-Affinity שנקבע לפרוסס שלנו, אנחנו למעשה צריכים לספור את מספר הביטים הדולקים באותו Bitmask .
לצורך ההדגמה, התוכנית הזאת בודקת כמה מעבדים זמינים לפרוסס, ומדפיסה על אילו אינדקסי מעבדים היא יכולה לרוץ.

 

static void PrintAffinitizedProcessors()

{

    // gets the number of affinitized proccesors in the

    // current processor group (up to 64 logical processors)

    Process currentProcess = Process.GetCurrentProcess();

    long affinityMask = (long)currentProcess.ProcessorAffinity;

 

    if (affinityMask == 0)

        affinityMask = (long)Math.Pow(Environment.ProcessorCount, 2) - 1;

 

    const int BITS_IN_BYTE = 8;

    int numberOfBits = IntPtr.Size * BITS_IN_BYTE;


    int counter = 0;


    for (int i = 0; i < numberOfBits; i++)

    {

        if ((affinityMask >> i & 1) == 1)

        {

            Console.WriteLine(i);

            counter++;

        }

    }


    Console.WriteLine("Total: " + counter);

}

Hot/Cold Data in Multithreaded Environments
בתקופה האחרונה שמתי לב שהנושא של False Sharing עולה לעתים די קרובות בבלוגים שמפרסמים פוסטים המסבירים במה בעצם מדובר ובאיך אפשר להמנע מהתופעה. כך שכמובן שהגיע הזמן שאתייחס בעצמי לנושא החשוב אך חמקמק הזה.
אבל קודם כל, נסביר בקצרה במה בעצם מדובר ובאיפה טמונה הבעיה.
אחד הנושאים הרגישים בפיתוח קוד מקבילי הוא הגישה לזיכרון המשותף למספר Thread'ים שונים. (כדי שנשאר ממוקדים בנושא הפוסט, אני אתעלם לרגע מבעיות העלולות להגרם כתוצאה מ-Instruction Reordering או אופטימיזציות אחרות שמבוצעות ברמת הקומפיילר או החומרה), אחת מבעיות הליבה היא לסנכרן את הגישה לאותם אזורי זכרון משותף. נניח שאנחנו מריצים את האפליקציה שלנו על מחשב עם 8 ליבות, והיא משתמשת ב-8 ת'רדים שמעבדים נתונים כלשהם ובסופו של דבר מעדכנים מבנה נתונים המשותף לכל הת'רדים בגודל 500KB. כדי למנוע corruption (העלול להיגרם מכמה ת'רדים שקוראים/כותבים לאותו בלוק זכרון בו-זמנית), סביר שנשתמש במנגנון נעילה כלשהו שיסנכרן את הגישות לאותו בלוק זכרון משותף. הבעיה בפתרון הזה הוא שיכול להיווצר לנו contention מאוד גדול על אותה נעילה, מה שלמעשה יהרוג את ה-scalability של הקוד שלנו (סביר שנראה שככל שנוסיף עוד מעבדים ועוד ת'רדים שירוצו עליהם, למעשה נראה ירידה בביצועים במקום עליה, מאחר והסיכוי שנקבל contentions על אותה נעילה בודדת רק הולך וגודל).
מאחר ושיתוף הזכרון הזה פוגע ב-scalability שלנו, והפגיעה הזאת למעשה מתורגמת לפגיעה בביצועים, ושיפור בביצועים הוא למעשה המוטיבציה היחידה לכתוב קוד מקבילי מלכתחילה, אפשר להבין שמדובר למעשה בבעיה קשה שצריך לטפל בה באיזשהיא צורה.
אחת האפשרויות העומדות בפנינו, היא להקטין במידת האפשר את השימוש בזכרון משותף. כך שאם נחזור לדוגמה הקודמת, זה אומר שנוכל לשפר את ביצועי הקוד אם נחליט שבמקום שכל ת'רד יעדכן את אותו בלוק זכרון מרכזי בתדר גבוה מאוד, נוכל להקצות עבור כל ת'רד תא זכרון נפרד שיהיה נגיש אך ורק לו. כך שלמעשה, בזמן העיבוד האינטנסיבי כל ת'רד יעדכן בתדירות גבוהה רק את תא הזכרון השייך לו (גישות אליו לא מצריכות שום סוג של נעילה מאחר והוא נגיש רק לת'רד בודד), ורק בסוף כל התהליך (אחרי שכל ת'רד סיים את החלק היחסי שלו בעבודה), נאסוף את כל המידע הזה ונעדכן בעזרתו את בלוק הזכרון המרכזי. בצורה הזאת הורדנו את הסנכרון הנדרש בין הת'רדים למינימום, וניתן להניח שנראה הבדל ניכר בביצועי התוכנית כשהיא תרוץ על מספר רב של מעבדים.
עם זאת, קיימת בעיה מהותית בפתרון הזה. לתופעה קוראים False Sharing, והיא מקבלת את שמה מכך שבזמן כתיבת קוד הדוגמה שלנו, אנחנו מפרידים בין תאי הזיכרון הייחודים לכל ת'רד בצורה לוגית. אנחנו אומרים "ת'רד 1 יגיש לתא X ות'רד 2 יגש לתא Y. מאחר ומדובר בשני תאי זכרון שונים לגמרי, אין לנו שום צורך לסנכרן גישות אליהם". אבל בפועל, המצב אינו כל כך פשוט. תלוי באופן בו הקצנו את תאי הזכרון עבור הת'רדים, יתכן שבזמן ריצת האפליקציה, תאי זכרון שהוקצו "בסמוך לאחרים" יגיעו בסופו של דבר לאותו Cache Line במעבד.
מעבדים מודרנים משתמשים ב-Cache פרטי בשביל לשמור (בין היתר) ערכים של משתנים שהשתמשו בהם לאחרונה. בעוד שהגישה ל-Main Memory נחשבה ליקרה יחסית, הגישה ל-Cache נחשבת למהירה בצורה ניכרת (בייחוד אם מדובר בגישות ל-L1). בדוגמה שלנו עלולה להיגרם בעיה חמורה בהנחה שאותם תאי זכרון פרטיים יותר קטנים מגודל ה-Cache Line של המעבד בו אנו משתמשים. כך שאם הם קטנים מספיק, והוקצו "מספיק קרוב" אחד לשני, יתכן מאוד שמספר תאי זכרון סמוכים כאלה יכנסו לאותו ה-Cache Line. כאשר ת'רד מסויים ירצה לשנות את אחד מהערכים שנמצאים ב-Cache Line המדובר, הוא יצטרך לקבל בלעדיות על אותו Line. כך שאם למשל 4 ת'רדים שונים מנסים לשנות 4 ערכים שונים שנכנסו לאותו Cache Line, זה אומר שתמיד 3 ת'רדים יחכו עד שהת'רד הרביעי יסיים את העבודה שלו, ורק אז יוכלו לעדכן את המשתנה הפרטי שלהם בעצמם.
כלומר, אם אנחנו רוצים לסכם את כל זה במשפט אחד, אפשר להגיד שאותם "תאי זכרון פרטיים" שהזכרנו מקודם כדרך להמנע משיתוף זכרון בין ת'רדים, הם לא יותר מאשליה גסה. מאחר ובסופו של דבר אנחנו כן נאלצים (למעשה, המעבד נאלץ), לסנכרן את הגישות ל-Cache Line בו הם נמצאים. כך שאם נריץ את הקוד, נוכל לגלת שהמעבדים שלנו אומנם עובדים קשה מאוד, אבל בפועל, אנחנו לא מקבלים את ה-speedup שהיינו מצפים לקבל.
כדי ללמוד עוד על הנושא ולקבל גם מעט יותר דוגמאות קונקרטיות על איך התופעה משפיעה על הביצועים של אפליקציה, מקום טוב הוא המאמר Eliminate False Sharing של Herb Sutter.

אז לאחר ההקדמה (שבסופו של דבר היתה "קצת" יותר ארוכה ממה שתכננתי), אפשר לגעת בנושא האמיתי של הפוסט.
כשזה מגיע לעיצוב מבנים של טיפוסי נתונים, לפעמים אפשר לראות חלוקה בין "Hot Data" לבין "Cold Data". כל צד בחלוקה למעשה מייצג קבוצה של שדות הקשורים לאובייקט מסויים ואת המידה שבה ניגשים אליהם. כלומר, לשדות הנחשבים ל-"Hot" נגשים בתדירות גבוהה (בין אם מדובר בכתיבה או קריאה), בעוד שעבור שדות הנחשבים ל-"Cold" נגשים בתדירות נמוכה יותר. החלוקה הזאת פופולרית בדרך כלל במצבים בהם לאובייקט שלנו יש מצד אחד שדות שמשתנים לעיתים תכופות, ומצד שני מידע שכמעט ואינו משתנה כלל (אם בכלל), למשל שדות המכילים Metadata על האובייקט. למעשה, אפשר לקחת דוגמה הישר מתוך ה-CLR, הנוגעת לצורה בה אסמבלים מיוצגים בזכרון. זהו למעשה ההבדל בין מחלקת ה-MethodTable, המכילה את המידע החם (למשל מצביעים לפונקציות, ומידע שה-GC נעזר בו), לעומת מחלקת ה-EEClass שמכילה את המידע הקר (כגון מידע על מבנים, גדלים וטיפוסים).
הסיבה העיקרית שמשתמשים בחלוקה הזאת, היא על מנת להשיג ניצול טוב יותר של ה-Cache. לצורך האילוסטרציה, ניקח טיפוס בעל שדות שניגשים אליהם בתדירות גבוהה ונמוכה, ונראה כיצד הוא נכנס לתוך ה-Cache.



הצבעים השונים מסמלים את תדירות הגישות לכל תא זכרון באובייקט. אדום לתדירות גבוהה, וירוק לנמוכה.
ניתן לראות שכאשר אנחנו לוקחים את האובייקט הזה, בו השדות ממוקמים ללא קשר למידת "החום" שלהם, ומכניסים אותו לתוך ה-Cache הדמיוני שלנו, אנחנו מכניסים לאותו Cache Line גם תאים שניגשים אליהם בתדירות גבוהה, וגם תאים שניגשים אליהם בתדירות נמוכה. כלומר, נניח שאנחנו מריצים כרגע קטע בקוד שעובד בצורה אינטנסיבית עם מבנה הנתונים הזה. בפועל, הוא ניגש אך ורק לתאים המסומנים באדום/כתום. מה שיקרה, זה שכדי לשמור את כל השדות האלו ב-Cache, אנחנו למעשה נצטרך להשתמש במספר רב יחסית של Cache Lines, רק מהסיבה שתאים רבים ב-Cache "מתבזבזים" על אזורי זכרון שאנחנו בכלל לא עושים בהם שימוש. ניצול המקום הבזבזני הזה משפיע בצורה ישירה לביצועים. זה אומר שאנחנו יכולים לסבול מיותר Cache Misses וגישות ל-Main Memory.
בדיוק כאן מגיע הרעיון של פיצול ל-Hot/Cold Data. הכוונה היא להגיע למצב בו אנו מנצלים את ה-Cache שלנו בצורה כמה שיותר אופטימלית. כדי לעשות זאת, כל מה שעלינו לעשות הוא לדאוג שהשדות האדומים יוקצו בקבוצה אחת, בעוד שהתאים הכתומים/ירוקים יוקצו בקבוצות נפרדות.
כך שלאחר אופטמיזציה מסוימת של אותו המבנה ממקודם, אנחנו יכולים לקבל את התוצאה הבאה:



הפעם אפשר לראות שהחלוקה ל-Cache Lines מבוצעת כמעט לגמרי על פי סוג התאים. בצורה הזאת, כל התאים שאנו ניגשים אליהם בתדירות הגבוהה ביותר נמצאים כמעט באותה השורה. מה שאומר שהגישות לתאי הזיכרון האלה יהיו מהירות במיוחד, כך שלמעשה בעזרת השינוי הפשוט למדי הזה, אנחנו עשויים לקבל שיפור משמעותי בכל הנוגע לגישות לזיכרון.
אם אנחנו רוצים, אנחנו יכולים ללכת אפילו עוד יותר רחוק, ולהקצות את כל התאים הירוקים באזורים נפרדים לחלוטין בזיכרון, באובייקטים שונים (כמו שנעשה עם MethodTable ו-EEClass שהזכרנו מקודם). בצורה הזאת, אנחנו מפנים לגמרי מה-Cache את כל התאים הירוקים, ובמקומם יכולים להכנס אובייקטים אחרים, שלהבדיל מהירוקים, דווקא כן כדאי לנו לשמור בתוך ה-Cache (מאחר ובאובייקטים האלה אנחנו באמת משתמשים, לא כמו בירוקים).

הבעיה עם האופטמיזציה הזאת, היא שהיא לא טובה. או ליתר דיוק, לא מתאימה עבור אפליקציות המריצות קוד מקבילי.
אם אנחנו חוזרים לבעיית ה-False Sharing, אפשר להבין שהחלוקה הזאת בין Hot/Cold Data היא למעשה ההפך ממה שהיינו רוצים לעשות. מאחר ולא נעשית כאן הבחנה בין שדות שקוראים מהם נתונים בתדירות גבוהה, לעומת שדות שכותבים אליהם ערכים בתדירות גבוהה. מאחר וכשהמעבד משנה את ערכיו של משתנה, קיים צורך לבצע Invalidation עבור ה-Cache Lines (זה נכון במיוחד עבור פעולות אטומיות הדורשות אחזקה בלעדית על Cache Lines בזמן הפעולה), ומאחר ב-Hot/Cold Split לא קיימת הפרדה בין שדות המשנים את ערכם לבין אלו שלא, אנחנו למעשה יכולים לגרום לאינוולידציה מיותרת של שדות אלו הנמצאים במקרה על אותו Cache Line שנמצא עליו איזשהו שדה שכותבים אליו בתדירות גבוהה. כך שלמעשה, אפשר ללכת צעד אחד קדימה ולבצע הפרדה נוספת בין שדות בעלי תדירות גבוהה של Written To ו-Read From.
אבל, כדי להגיע ל-Locality מירבי, אנחנו צריכים להיפטר לגמרי מאיזשהו קיום של False Sharing אצלנו בקוד. לכן גם חלוקה של אותו Cache Line עבור מספר שדות שמשנים את ערכם בתדירות גבוהה היא למעשה טעות (זאת למעשה הסיטואציה הלא נעימה איתה הפוסט התחיל). כך שכדי להגיע ל-scalability אופטימלי, אנחנו נדרשים למעשה לנצל את ה-Cache באופן הכי בזבזני שאפשר, והוא לשמור בכל Cache Line אך ורק שדה זכרון אחד. בצורה הזאת, אנחנו סוף סוף מקבלים את אותו אפקט לוקאליות, שיאפשר לנו לגשת לאותו תא זכרון בלי שאף ת'רד אחר באפליקציה יפריע לנו.
כדי להשיג layout שכזה נצטרך להשתמש ב-StructLayout עם הדגל Explicit, כאשר עבור כל שדה משתמשים ב-FieldOffset שגודל בכל פעם בגודל ה-Cache Line בחיסור גודלו של השדה. באותה מידה, אפשר גם להקצות מערך של האובייקט המדובר, אבל שבין כל איבר ואיבר "אמיתי" קיימים מספר איברי "דמה" שלמעשה צריכים לתפוס את המקום שיכנס לתוך ה-Cache Line. כלומר, אנחנו מקצים הרבה יותר זכרון ממה שאנחנו צריכים באמת, רק בשביל "לרפד" את המרווחים בין האיברים האמיתיים, כך שבזמן הריצה בתוך כל Cache Line יכנס אך ורק תא אמיתי אחד. אפשר לראות דוגמה לשימוש בטכניקות האלו בפוסט False sharing is no fun מהבלוג של Joe Duffy.

לסיכום, כל ההתחשבות הזאת ב-Data Cache שהפוסט מדבר עליה רק מדגישה את העובדה שעם הזמן החומרה עליה האפליקציה רצה כבר לא כל כך שקופה למתכנתים. מצד אחד אנו חיים בעולם של Managed Code שחוסך לא מעט עבודה "טרחנית" אפשר לומר, אבל בו בזמן עולות שאלות כגון "על כמה מעבדים הקוד הזה אמור לרוץ? 1? 4? 128? 256?", או "מהו גודלו של כל Cache Line במעבד?". אופן כתיבת הקוד יכול להיות מושפע באופן ישיר מהתשובות שאלות האלה, וגם אחרות ("מה ה-memory model של המעבד?"), כך שאם יש דבר אחד בטוח, זה לא הופך את החיים שלנו לפשוטים יותר. אלא רק .. ליותר מעניינים.
String.Format Isn't Suitable for Intensive Logging

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

קודם כל נסתכל על האופרטור +. באופן בסיסי, מה שהוא עושה זה לקחת 2 מחרוזות קיימות, ליצור אחת חדשה ואז להעתיק לתוכה את התוכן של המחרוזות הישנות. כלומר, באופן תיאורטי, קיים כאן חסרון מהותי של הקצאות זכרון רבות אך לא נחוצות. עכשיו, אני אומר "באופן תיאורטי" בגלל שבפועל, זה שאנחנו משתמשים באופרטור +, זה לא באמת אומר שהקוד המקומפל ישתמש בו גם כן. מה שקורה, זה שאחת מהאופטימיזציות שהקומפיילר מבצע על הקוד, הוא לזהות מקומות בהן מחברים 5 מחרוזות ומעלה בעזרת האופרטור +, ואז למעשה להחליף את הקריאה לאופרטור בקריאה ל-String.Concat.
לדוגמה, הקוד הזה:

string str2 = 1.ToString() + 2.ToString() + 3.ToString() + 4.ToString() + 5.ToString();


יתקמפל לקוד הזה:

string[] CS$0$0000 = new string[] { 1, 2, 3, 4, 5 }; // edit: removed ToString

string text1 = string.Concat(CS$0$0000);


לגבי String.Concat, אין סיבה לחשוש מהקצאות זכרון מיותרות. כל מה שהוא עושה זה לחשוב מה יהיה גודל המחרוזת הסופית, להקצאות את באפר היעד עם קריאה ל-AllocateFastString, ואז פשוט להעתיק את תכני המחרוזות הישנות לבאפר היעד עם wstrcpy (העתקת בתים יעילה דרך שימוש ב-unsafe code).

אחד השימושים הנפוצים בבניית מחרוזות, הוא כתיבת לוגים. ובמידה והאפליקציה שלכם כותבת הרבה ללוג, אז יתכן שהיא מקדישה חלק לא קטן מזמן הריצה שלה לטובת בניית מחרוזות שיגיעו בסופו של דבר ללוג. הסיטואציה יכולה מעט להשתנות על פי ה-Logging Framework שאתם עובדים איתה, אבל אני אקח כדוגמה את log4net כרגע. מה שקורה ב-log4net זה שיש 2 דרכים לכתוב ללוג. האחד "לוג רגיל" (Debug/Info/Warn..), והשני "לוג מפורמט" (DebugFormat/InfoFormat/WarnFormat..). ההבדל היחיד בין 2 הדרכים האלו הוא שהראשון מקבל מחרוזת קבועה ללא פרמטרים, בעוד השני מקבל מחרוזת מפורמטת, ובנוסף הפרמטרים שצריכים להכנס אליה (שכל הסיפור הזה בסופו של דבר מועבר ל-String.Format). עכשיו זה.. בעייתי.
מה שבעייתי כאן זה שבכל פעם שאנחנו רוצים להכניס איזשהו פרמטר לתוך המחרוזת שלנו, סביר להניח שאוטומטית נשתמש בגרסאת ה-Format של הכתיבה ללוג. מה שבעיה בזה, זה אומר שעבור כל כתיבה ללוג, אנחנו למעשה מפעילים את String.Format.
מתחת לפני השטח, String.Format משתמש ב-StringBuilder, שבהשוואה ל-String.Concat, נמצא הרחק מאחור בכל הנוגע לביצועים ויעילות. אומנם היינו יכולים בקלות פשוט לכתוב ללוג עם הגרסה הלא-מפורמטת (שלא משתמשת ב-String.Format), ובמקום זה לפנות ל-String.Concat בעצמנו, אבל בפועל מאחר ו-log4net חושף לנו את 2 ה-overload'ים האלה, ברוב המוחלט של המקרים, אנשים פשוט יעדיפו לפנות לגרסאת ה-Format. פשוט בגלל שזה נגיש, ושזה שם.
בשביל להדגים את הבדלי הביצועים בין האפשרויות השונות, נשתמש בבנצ'מרק הבא:

while(true)

{

    Stopwatch sw = Stopwatch.StartNew();

 

    for (int i = 0; i < 500000; i++)

    {

        // 960ms

        string str1 = string.Format("{0}, {1}, {2}, {3}, {4}", 1, 2, 3, 4, 5);

        // 665ms

        string str2 = string.Concat(1, ", ", 2, ", ",3, ", ", 4, ", ", 5);

        // 566ms

        string str3 = string.Join(", ", new string[] { 1, 2, 3, 4, 5 });

    }

 

    Console.WriteLine(sw.ElapsedMilliseconds);

}


במקרה הזה עשיתי שימוש בלוג פשטני למדי, אבל גם כאן אפשר להבחין בהבדלים ניכרים בין הפונקציות השונות. כשמשווים את Format ל-Concat אנחנו מקבלים שיפור של 31%, בעוד שאם נשווה ל-Join (שאמנם הוא לא תמיד אופציה ללוגים סטנדרטים), נקבל שיפור של 62% (המימוש של Join אומנם לא פונה ישירות ל-Concat, אבל עובד בצורה דומה ויעילה עם קריאה ל-FastAllocateString ועבודה עם המחלקה UnSafeCharBuffer לבניית המחרוזת החדשה).
אחרי שראינו את ההבדל בזמן הריצה, מה לגבי הבדלים בהקצאות הזכרון? הפעלתי בלולאה של 10 איטרציות את Format ואת Concat כשברקע CLRProfiler ניטר את התהליך, ואלו התוצאות שהתקבלו: גרסאת ה-Format גרסה להקצאה של סה"כ 69,320 בתים. במהלך הריצה נוצרו 816 מופעים של מחרוזות שתפסו סה"כ 35,308 בתים (שאר ההקצאות הלכו בעיקר על יצירת מערכי Object'ים, מערכי Int'ים ומערכי Char'ים).
לעומת זאת, אותה תוכנית עם שימוש ב-Concat גרמה להקצאה של 53,822 בתים (15,498 בתים פחות), ויצירה של 714 מופעים של מחרוזות (102 מופעים פחות), שתפסו סה"כ 20,810 בתים. אם כן, מתברר שגם מבחינת ניהול הזכרון, השימוש ב-Concat כדאי יותר.
עוד פרט שכדאי לשים לב אליו, הוא שאלמלא ציינתם במפורש ל-StringBuilder לאיזה גודל הוא אמור כנראה להגיע (פרמטר ה-capacity בבנאי), הוא יווצר עם באפר בגודל של 16 בתים. כך שברגע שתחרגו ממנו, הוא יאלץ להקצות את המקום מחדש. במקרה של String.Format, הפריימוורק כבר דואג לאתחל אותו עם ה-capacity המדוייק שהוא יצטרך, מה שאומר שבמידה ובקוד שלכם אתם עובדים ישירות עם StringBuilder, בלי לעזור לו לשער לאיזה גודל הוא הולך להגיע, אתם צפויים לקבל עוד הרבה יותר הקצאות זכרון ממה שרואים בדוגמה הזאת.
דבר נוסף שצריך לשים לב אליו הוא שהפוסט הזה עוסק בהשוואה בין Concat ל-Format (או למעשה בין Concat להפעלה בודדת על StringBuilder), בסיטואציות אחרות, בהן קיימת דרישה לבניית מחרוזת גדולה "לאורך זמן" (לולאות, בניה מתמשכת וכו'), שימוש במחלקה StringBuilder (עם קריאה ל-Append) יהיה עדיף מאחר ולא נצטרך להקצות מחרוזות חדשות בכל איטרציה של הלולאה וכו'.. (כמו שהיינו נדרשים לעשות במידה והיינו עובדים בלעדית עם String.Concat).

כמובן שעבור לוגים קצרים שנכתבים "פעם ב..", להבדל הביצועים הזה אין באמת משמעות. ושעצם זה שהשימוש ב-Format נותן לנו קוד קצת יותר קריא מ-Concat (לפחות תלוי בסיטואציה), ההעדפה הברורה היא לטובת שימוש ב-Format. אבל, במידה וקיימים חלקים באפליקציה שלכם שבהם אתם כותבים בצורה אינטנסיבית ללוג, הבדלי הביצועים האלה יכולים להיות משמעתיים עבורכם. כי אם תבדקו את הקוד שלכם בעזרת Profiler מתאים, תוכלו לגלות שאתם שורפים Cycle'ים על פרסור ובניית מחרוזות, בעוד ששימוש ב-Concat (או אפילו Join אם מתאפשר לכם), יכול לתת לכם Boost משמעותי ביעילות, במידה ותבחרו להשקיע את 5 הדקות בשביל לעדכן את הקוד המתאים.
A Small But Helpful Tip

כמה פעמים כבר יצא לכם שבזמן שאתם כותבים קוד, פתאום קופץ לכם על המסך הקומבו-בוקס המוכר של ה-Intellisense, או אולי איזה קטע מהתיעוד ה-XML'י של הפונקציה שאתם מנסים לקרוא לה. ואותו חלון בדיוק נופל על קטע קוד שאתם מנסים לקרוא באותו הרגע. כך שבעצם קורה שאי אפשר לעבוד גם עם ה-Intellisense וגם לעיין בקוד שמעניין אותנו ונמצא בהמשך השורה, או אולי כמה שורות מתחתינו (תלוי עד כמה גדול חלון ה-Intellisense שלכם, שדרך אגב, אפשר לשנות אותו עם גרירה של העכבר כמו כל חלון רגיל). בדרך כלל אנחנו פשוט נאלצים להקיש על Esc, לסגור את החלון הקטן שנפתח, לקרוא את הקוד שעניין אותנו, להפעיל איזשהו טריגר שיגרום לאותו חלון לחזור להופיע, ואז לחזור בחזרה להקלדה.
ובכן, לא עוד. מסתבר שניתן להפוך את כל החלונות הקטנים האלה שקופצים (תיעוד/Intellisense וכו'..) לשקופים למחצה על ידי לחיצה על מקש ה-Ctrl. ברגע שנפסיק ללחוץ עליו, אותו חלון יחזור למצב התצוגה הרגיל שלו. כך שאנחנו יכולים לקרוא את הקוד המוסתר ולחזור להקלדה הרציפה במאמץ מינימלי.

לפני:


ואחרי:

Headaches with Prefix and Temporary Variables

לפני לא פחות מ-5 שנים, Luca Bolognese כתב פוסט שעסק בשאלה שעלתה ב-C# User Group. השאלה היתה מה יהיה הערך של x, בסוף ביצוע קטע הקוד הבא:

int x = 3;

x += x++;


אם אנחנו זוכרים את ההבדל בין Postfix ו-Prefix, אז לא צריכה להיות יותר מדי בעיה להבין שהתוצאה תהיה בסוף 6 מאחר ואין משמעות ל++ האחרון. כך שלמעשה, ניתן לפשט את הביטוי הזה ל: x = x + x, ועדיין נקבל את אותה ההתנהגות (שימו לב שזה המקרה ב-#C. ב-CPP למשל, אין הגדרה אמיתית לגבי מה הביטוי הזה צריך להחזיר).
אם כך, זאת נקודת הפתיחה שלנו. הרשתי לעצמי לקחת צעד אחד קדימה ולכתוב את שורת הקוד המאוד קריאה וברורה הזאת:

int x = 10;

x = --x + x + --x;


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

מה שמבלבל בביטוי הזה, הוא שאנחנו כל הזמן צריכים לעקוב היכן בזכרון נשמרים הערכים במהלך החישוב. האם הנתון נמצא במשתנה המקורי? האם ברג'יסטר של המעבד? או אולי בכלל במקום אחר? מה שחשוב לשים לב אליו כאן הוא שבמהלך החישוב אנחנו למעשה מקצים int נוסף שישב על ה-stack, וישמור את "תוצאת הביניים" של החישוב.
בצעד הראשון, אנחנו מורידים ב-1 את ערכו של x, ומעדכנים את המשתנה בזכרון, כלומר ברגע זה x=9. לאחר מכן, אנחנו מחברים את x ב-x. את התוצאה של החישוב הזה אנחנו למעשה נשמור במשתנה זמני נוסף שיוקצה במיוחד למטרה הזאת על ה-stack. שימו לב לא להתבלבל, אנחנו לא מעדכנים את ערכו של x במקרה הזה. לאחר מכן, אנחנו מורידים שוב ב-1 את ערכו של x (עכשיו ל-8), ואז מחברים אותו למשתנה הזמני ממקודם (שערכו 18). את התוצאה של החישוב האחרון הזה, נשמור בתוך x. כך קיבלנו בסופו של דבר את התוצאה 26.

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

00000019  mov  dword ptr [ebp-4],0Ah   // x = 10

00000020  dec  dword ptr [ebp-4]       // x = 9

00000023  mov  eax, dword ptr [ebp-4]  // x = 9, eax = 9

00000026  add  eax, dword ptr [ebp-4]  // x = 9, eax = 18

00000029  mov  dword ptr [ebp-8],eax   // x = 9, eax = 18, temp = 18

0000002c  dec  dword ptr [ebp-4]       // x = 8, eax = 18, temp = 18

0000002f  mov  eax, dword ptr [ebp-8]  // x = 8, eax = 18, temp = 18

00000032  add  dword ptr [ebp-4],eax   // x = 26

Forcing JIT Compilation During Runtime

אחד החסרונות/יתרונות של דוט-נט הוא השימוש במנגנון ה-JIT (הלא הוא ה-Just in Time Compilation). למעשה, התהליך שתפקידו להפוך את ה-CIL לשפת מכונה. אפשר להסתכל על המנגנון הזה בתור יתרון מאחר ובצורה הזאת התוכנית מקומפלת על מחשב היעד שבאמת מריץ את התוכנה שלנו. בצורה הזאת, בזמן הקומפילציה ניתן להשתמש  בכל היכולות שמעבד היעד תומך בהן. כלומר, בצורה הזאת אנחנו יכולים להגיע לקוד יעיל, ומהיר יותר בהשוואה לאם היינו מקמפלים את התוכנית על מחשב נפרד, בלי לדעת על אילו מחשבים יריצו את התוכנה שלנו (מאחר והיינו צריכים לקמפל את הקוד למכנה המשותף הנמוך ביותר, ולא היינו יכולים להשתמש ביכולות ייחודיות של מעבדים שונים).
החסרון הבולט של ה-JIT, הוא הזמן שאנחנו מבזבזים בעצם ביצוע הקומפילציה הזאת אצל המשתמש. מה שקורה, זה שעבור כל MethodDesc (מבנה נתונים הקיים עבור כל פונקציה דוט-נטית, ומכיל מעט Metadata על הפונקציה. נמצא בתוך ה-EE Memory), קיים שדה בוליאני הנקרא IsJitted. כמו שמשתמע מהשם שלו, הוא למעשה אומר האם ה-JIT כבר הופעל על אותה הפונקציה (נעזר בשדה הזה בהמשך). כשהקוד שלנו קורא לפונקציה בפעם הראשונה, הוא מופנה דרך stub מיוחד אל ה-JIT שגורם לו לחולל את קוד המכונה המתאים. לאחר מכן, אותו stub נדרס עם פקודת jmp שמביאה אותנו הישר לקוד שחולל על ידי ה-JIT (למעשה, מדובר במנגנון שמאוד מזכיר את יכולת ה-delay load הקיימת ב-CPP), אז ה-JIT נכנס לפעולה ומקמפל את הקוד הנ"ל. כך שאנחנו למעשה סובלים מאיזשהו Overhead קטן בהרצה הראשונה של הקוד שלנו (זאת הסיבה שכאשר עושים Benchmark'ים לקוד דוט-נטי, לא מתייחסים להרצה הראשונה של קטע הקוד).
זה המקום להבהיר שה-Overhead שה-JIT גורם לו הוא בדרך כלל מזערי, מאחר ואנחנו חווים אותו בהרצה הראשונה של הקוד בלבד, ולאחר מכן נהנה מהביצועים המשופרים של הקוד שעבר אופטימיזציה למכונת המשתמש. כך שהתרחיש הכנראה יחיד בו בכל זאת יהיה יכול לעניין אותנו כיצד אפשר להפטר מה-Overhead הזה בכל זאת, הוא כשמדובר באפליקציות עם ממשק משתמש כלשהו. במקרה הזה, המשתמש עשוי לקבל את הרושם שהתוכנית שלנו מעט "עצלה" בהרצה הראשונה, כך שבתרחיש הזה יש לנו איזשהיא מוטיבציה לשפר את הביצועים במובן הזה.

הפתרון הקלאסי לבעיה הזאת, הוא שימוש ב-NGen. מדובר בכלי המסופק על ידי מיקרוסופט, והרעיון שעומד מאחוריו הוא שבזמן התקנת התוכנה אצל מחשב הלקוח, אנחנו נריץ את ה-JIT על כל הקוד שלנו ולמעשה נחולל Native Image עבור האפליקציה. בזמן הטעינה, הסביבה כבר תדאג לטעון את ה-Image הנכון מהדיסק, ולהמנע מ-Jitting מיותר. הבעיה עם השימוש ב-NGen הוא שהוא מסורבל למדי, ואם אנחנו באמת מעוניינים לנצל אותו כמו שצריך ולהמנע מ-Overhead שהוא יכול להוסיף בעצמו, נצטרך כבר באמת להקדיש לא מעט זמן ומחשבה לעניין (קביעת Base Addresses נכונים, המנעות מ-ReBasing, רישום האסמבלי ב-GAC ועוד...)
אולם, קיימת אלטרנטיבה לשימוש ב-NGen, והיא לאלץ את ה-JIT לעבוד בזמן הרצת התוכנית. לשיטה הזאת גם כן לא חסרים חסרונות משלה, ובתמונה הכוללת היא לא בהכרח טובה משימוש ב-NGen, אבל במידה ואנחנו מעוניינים לזרז את ה-JIT תוך כדי מאמץ מינימלי מצידנו, זאת בהחלט אפשרות שניתן לשקול (ובכלל, תמיד מעניין לדעת מה באמת אפשר לעשות בדוט-נט).

אז כדי לקפוץ ישר למים, אני אגש להסבר בצורת Bottom-Up, כך שקודם נבין איך נוכל לבצע את האילוץ עצמו, ואחר כך איך אפשר להשתמש ביכולת הזאת באפליקציה קיימת.
אם כן, הצעד הראשון שלנו להפעיל את ה-JIT על פונקציה בודדת. לשם כך נשתמש בפונקציה PrepareMethod. באופן טבעי נעשה בה שימוש כאשר רוצים להפעיל פונקציות וירטואליות מתוך אזורי CER, אבל מה שמעניין אותנו הוא זה שאותה פונקציה למעשה גורמת ל-JIT לקמפל את פונקצית היעד שהעברנו לה. פרט נוסף שכדאי להיות מודעים אליו, הוא שקריאה ל-PrepareMethod יכולה לגרור הפעלה של הבנאי הסטאטי במידה והמחלקה מממשת אותו.
היות ובמקרה שלנו אנחנו לא מעוניינים לפנות פונקציה ספציפית, אלא לכל הפונקציות הקיימות בשלל האסמבלים שהתוכנית שלנו משתמשת בהם, נצטרך לכתוב פונקציית עזר שמקבלת אסמבלי, מחלצת את כל הטיפוסים והפונקציות שמעניינות אותנו, ואז מפעילה על כל פונקציה שמצאנו את PrepareMethod. לדוגמה:

public static void PreJITMethods(Assembly assembly)

{

    Type[] types = assembly.GetTypes();

    foreach (Type curType in types)

    {

        MethodInfo[] methods = curType.GetMethods(

                BindingFlags.DeclaredOnly |

                BindingFlags.NonPublic |

                BindingFlags.Public |

                BindingFlags.Instance |

                BindingFlags.Static);

 

        foreach (MethodInfo curMethod in methods)

        {

            if (curMethod.IsAbstract ||

                curMethod.ContainsGenericParameters)

                continue;

 

            RuntimeHelpers.PrepareMethod(curMethod.MethodHandle);

        }

    }

}


פרט חשוב לגבי הפונקציה הזאת, הוא משפט ה-If רק לפני הקריאה ל-PrepareMethod. החלק הראשון והמובן מאליו הוא שעלינו להתעלם מפונקציות אבסטרקטיות, מאחר ואין להן מימוש, אין ל-JIT מה לעשות איתן, ולכן ניסיון להפעיל את ה-JIT עליהן יגרור זריקת שגיאה. החלק השני, הוא ההתעלמות מפונקציות המקבלות פרמטרים גנריים (Generics). אנחנו לא יכולים לגרום ל-JIT לקמפל את הפונקציה הגנרית, מאחר ובשלב הזה אנחנו עדיין לא יודעים איזה טיפוס מועבר לפונקציה, ומאחר וטיפוסים שונים יכולים לגרום לנו לחולל קוד מעט שונה עבור אותו "שלד" של פונקציה גנרית, אנחנו נמנעים מלקמפל אותה (למרות שתיאורתית לפחות, אפשר לחקור ולראות מי קורא לה ועם איזה טיפוס. ברגע שהטיפוס הזה ידוע לנו, נוכל לקמפל אותה בנקודה הזאת. למרות שבמציאות, אין שום טעם אמיתי בלהתאמץ ובאמת לנהוג כך). למעשה מדובר גם באיזשהו חסרון מול NGen, שעל הדרך מתמודד גם עם קוד גנרי.

השלב הבא יהיה להטמיע את הקוד הזה בתוך תוכנית דוגמה. כשאנחנו באים לעשות את זה, עומדות לפנינו מספר נקודות למחשבה לגבי הצורה בה אנו מעונינים להפעיל את התהליך הזה. האם אנחנו רוצים לבצע אותו פעם אחת, בעליית האפליקציה? או אולי בכל פעם שנטען אסמבלי חדש? האם אנחנו רוצים שהפעולה תהיה סינכרונית, או שולי נעדיף שתרוץ במקביל להרצה הרגילה, בת'רד נפרד (שיתכן ונעדיף להעניק לו עדיפות נמוכה מאשר שאר הת'רדים בהם בהם אנחנו משתמשים), והאם אנחנו מעוניינים לעשות זאת רק על הקוד שלנו? או גם על הקוד שאנחנו טוענים מה-BCL? עולות כאן הרבה שאלות, שהתשובה אליהן יכולה להשתנות מתרחיש לתרחיש.
אני אתייחס לשאלות האלה בעוד רגע, אבל קודם כל, בואו נראה מה עלינו לעשות כדי שכל זה יהיה אפשרי בכלל.
קודם כל עלינו לזכור שבתור ברירת מחדל, ה-CLR נוהג באופן חסכוני הכל הנוגע לטעינת אסמבלים. ועל פי הקו המנחה שלו, כל עוד לא השתמשנו בטיפוס הנמצא באסמבלי נפרד, אין צורך לטעון אותו. כלומר, גם אם התוכנית שלנו משתמשת ב-100 אסמבלים נוספים ל-exe הראשי, אם נקרא ל-GetAssemblies ברגע שהתוכנית הופעלה, נגלה שאף אחד מאותם 100 אסמבלים בכלל לא נטענו עדיין ל-AppDomain, מאותה הסיבה בדיוק. לכן, אם אנחנו רוצים להפעיל את ה-JIT על כל האסמבלים שעומדים להטען בעתיד, אנחנו נצטרך לטעון אותם באופן מפורש. נוכל לעשות זאת על ידי קריאה באופן רקורסיבי ל-GetReferencedAssemblies ו-Load. לדוגמה:

// recursively load all of assemblies referenced by the given assembly

public static void ForceLoadAll(Assembly assembly)

{

    ForceLoadAll(assembly, new HashSet<Assembly>());

}

 

private static void ForceLoadAll(Assembly assembly,

                                 HashSet<Assembly> loadedAssmblies)

{

    bool alreadyLoaded = !loadedAssmblies.Add(assembly);

    if (alreadyLoaded)

        return;

 

    AssemblyName[] refrencedAssemblies =

        assembly.GetReferencedAssemblies();

 

    foreach (AssemblyName curAssemblyName in refrencedAssemblies)

    {

        Assembly nextAssembly = Assembly.Load(curAssemblyName);

        if (nextAssembly.GlobalAssemblyCache)

            continue;

 

        ForceLoadAll(nextAssembly, loadedAssmblies);

    }

}


אפשר לשים לב שבדוגמה הזאת, הפונקציה מסננת אסמבלים הנמצאים ב-GAC, כך שהיא תתעלם באופן אוטומטי מאסמבלים השייכים ל-BCL למשל (וע"י כך חסכון משמעותית בגודל ה-Working Set). כמו שאפשר להבין, את הפונקציה הזאת (כמו דוגמת הקוד הקודמת) אפשר לכוון ולערוך למטרות ספציפיות. יתכן ותרצו לטעון אסמבלים מסויימים, ולהתעלם מאחרים. בכל אופן, מה שלא תרצו לעשות, זה המקום לעשות זאת.
חשוב לשים לב שקריאה לפונקציה הזאת תגרום לטעינת כל ה-Statically Referenced Assemblies. כלומר, רק במידה והקוד שלכם מתייחס לטיפוסים מאותו אסמבלי, תוכלו לטעון אותו בצורה הזאת. אולם, קיימת אפשרות שתרצו לטעון חלק מהאסמבלים שלכם בצורה דינאמית (על ידי שימוש ב-Reflection). במידה ותרצו להיות מודעים לכל טעינת אסמבלי שקוראת אצלכם בתוכנית (ולהגיב בהפעלת ה-JIT על אותו אסמבלי למשל), תוכלו לעשות זאת דרך רישום לאירוע AssemblyLoad שיופעל בזמן טעינת אסמבלים חדשים.

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

> !name2ee OtherAssmebly.dll C.ClassC
Module: 00a457b8 (OtherAssmebly.dll)
Token: 0x02000002
MethodTable: 00a48bbc
EEClass: 00f283d4
Name: C.ClassC

> !dumpmt -md 00a48bbc
EEClass: 00f283d4
Module: 00a457b8
Name: C.ClassC
mdToken: 02000002  (C:\Documents and Settings\Liran\My Documents\VisualStudio2008\Projects\ConsoleApplication1\ConsoleApplication1\bin\Debug\OtherAssmebly.dll)
BaseSize: 0xc
ComponentSize: 0x0
Number of IFaces in IFaceMap: 0
Slots in VTable: 6
--------------------------------------
MethodDesc Table
   Entry MethodDesc      JIT Name
00a4c838   00a48bb0     NONE C.ClassC..ctor()
00a4c830   00a48ba0      JIT C.ClassC.FooMethod()

> !dumpmd 00a48ba0
Method Name: C.ClassC.FooMethod()
Class: 00f283d4
MethodTable: 00a48bbc
mdToken: 06000001
Module: 00a457b8
IsJitted: yes
CodeAddr: 00f50778
Designing Method Overloads

כשזה מגיע לעיצוב API עבור ספריות תוכנה, לא פעם נתקלים במצב בו יש לנו פונקציה שיודעת לקבל מספר לא מבוטל של פרמטרים, אבל בפועל, בד"כ המשתמש לא מעוניין לפרט מה הוא הערך של כל פרמטר ופרמטר, אלא להשתמש בכל מיני "ערכי ברירת מחדל" שהפונקציה יודעת לקבל במקום זאת. התופעה הזאת די נפוצה כשמדובר בפונקציות "פופולריות" שבד"כ נוהגים להשתמש בהם עם סט פרמטרים X, אבל רק לעיתים נדירות רוצים התנהגות מעט שונה, שמצריכה העברה של מספר פרמטרים ספציפים נוספים. הבעיה היא, שברגע שאותה פונקציה מקבלת יותר מכמה פרמטרים בודדים, עומדת בפנינו השאלה "אילו Overloads עלינו להעניק לפונקציה?". אם למשל אנחנו מקבלים 5 פרמטרים, הרי לא רצוי שנוסיף Overload חדש עבור כל קומבינציה אפשרית בין הפרמטרים שהמשתמש כן רוצה להעביר, לבין אלו ערכי ברירת המחדל שהפונקציה יודעת לקבל. אם נעשה זאת, אנחנו יכולים בקלות להגיע למצב בו אנחנו "מזבלים" את המחלקה שלנו באינספור Overload'ים שונים ומשונים לאותה הפונקציה.
אז אם כך, כמה Overloads כן צריך לתת? ומה הוא אותו "מספר הקסם" חמקמק? ובכן, בדרך כלל, מספר הקסם הוא 3. לא באמת משנה כמה ואילו פרמטרים הפונקציה שלכם מקבלת, המספר המקסימלי, והבדרך כלל מומלץ של Overloads הוא 3.
מסתבר שכשמסתכלים על אופן השימוש הפופולרי של מפתח ב-API, בדרך כלל אפשר לראות העדפה ללכת לקצוות. ברוב המקרים הוא יעדיף להשתמש או ב-Overload הכי מינימלי, שעושה שימוש בכל ערכי ברירת המחדל של הפונקציה, או ב-Overload הכי מפורט, שנותן לו את השליטה הרבה ביותר על הפרמטרים שמגיעים לפונקציה. כך שלהתחיל ולעשות מאמצים עילאיים להעניק למפתח כמה שיותר אפשרויות לבחור מתוכן, בסופו של דבר מתבררת  כמוטעת, ובסוף של דבר כל "הרעש" הזה שה-Overloads גורמים לו, רק מבלבל את המפתח וגורמים ל-API להראות מסובך שלא לצורך.
מכאן בדיוק מגיע מספר הקסם. בצד האחד של הסקאלה, אנחנו צריכים מעוניינים לחשוף את הפונקציה הבסיסית, והפשוטה ביותר. מצד שני, אנחנו צריכים להעניק כמה שיותר שליטה למפתח, כך שנחשוף לו Overload נוסף בו הוא יוכל לפרט ולבחור כל פרמטר שהוא מעוניין להעביר. ה-Overload השלישי והנוסף הוא יותר עניין של Sweet Spot שאפשר למצוא. לכאן צריך להכניס מעט היגיון ומחשבה, ולמצוא האם קיים איזשהו Overload ביניים, שנותן איזשהוא ערך מוסף למפתח. איזשהו Overload בעל פוטנציאל פופולריות ונוחות עבור המפתח. במידה והפעלתם את שיקול דעתכם ולא ראיתם לנכון להוסיף Overload כזה, כמובן שתמיד אפשר גם לוותר עליו.
כי כמו תמיד, Less is Better.

Mysteries with Circular Dependencies

אם יש משהו אחד בסיסי שכולם יודעים על פיתוח תוכנה, הוא שלא ניתן ליצור תלות מעגלית בין פרוייקטים (DLL'ים).
אם למשל יש לנו פרוייקט A, שפונה לפרוייקט B, אז לא יהיה ניתן שאותו פרוייקט B יפנה בחזרה לפרוייקט A בתור רפרנס. אם היינו עושים דבר כזה, היתה נוצרת לנו תלות מעגלית בין שני הפרוייקטים. וזה רע, בגלל שכשהקומפיילר ירצה לקמפל את A, הוא יראה שהוא תלוי ב-B, אז הוא יגש ל-B וינסה לקמפל אותו. אבל אז הוא יגלה ש-B למעשה תלוי ב-A, ואז הוא יחזור חלילה עד אין קץ..

האמנם?
אם יצא לכם לפשפש מספיק במבנה הספריות של ה-BCL, יתכן ושמתם לב לכל מיני תלויות לא הגיוניות, אם לא "בלתי אפשריות". קחו למשל את המקרה הזה:

  • Xml - תלוי ב-System
  • Configuration - תלוי ב-System
  • System - תלוי ב-Xml ו-Configuration
אכן, תמונות קשות. ברגע אחד, נראה שכל ההבנה שלנו על מה שאפשר, או יותר נכון: אי אפשר לעשות עם תלויות בין DLL'ים, התנפצה לרסיסים. אבל מה לעשות, זה המצב, ולמרות שאנחנו נמצאים ב-State of mind שמה שראינו לפני רגע לא יכול להתקיים ... נראה שהוא מתקיים בכל זאת. והכל בחסות ה-BCL.

אז אחרי ששיפשפנו את העיניים, ושטפנו את הפנים במעט המים, הגיע הזמן להבין איך המצב הזה באמת מתאפשר בפועל.
נחזור לדוגמה איתה פתחתי את הפוסט. לצורך העניין נאמר שיש לנו שני DLL'ים, A ו-B. בתוך A, יש לנו את המחלקות ClassA, ClassB. לעומת זאת, בתוך B, יש לנו את המחלקה ClassC. עכשיו, אנחנו רוצים ש-ClassB תירש מ-ClassC בעוד שזאתי תירש מ-ClassA. כלומר, נוצרת לנו כאן תלות מעגלית קלאסית.


 
 
כדי לאפשר את התלות הזאת, נצטרך לעזוב לרגע את Visual Studio, ולעבור ל-Command Prompt כדי שנוכל לעבוד ישירות מול הקומפיילר (ברמת העקרון ניתן לעשות זאת גם דרך VS, רק שזה הופך את התהליך להרבה יותר מסורבל).
הרעיון הוא שנבצע את הקומפילצ
יה בשני שלבים. בשלב הראשון נקמפל את A, ללא החלקים שתלויים ב-B. כלומר, רק את ClassA. לאחר מכן, נקמפל את B כרגיל (הוא יוכל להשתמש ברפרנס ל-A בגלל שהחלקים שמעניינים אותו כבר קומפלו בצעד הקודם). הצעד האחרון, הוא לקמפל מחדש את A. הפעם את כל הקוד, גם זה שתלוי ב-B (שימו לב ש-B הוא כבר DLL מקומפל ומלא לכל דבר).
דרך ה-Command Prompt, זה נראה כך:
 
>csc /target:library /out:A.dll ClassA.cs                                              // compile a "thin" version of A
>csc /target:library /reference:A.dll /out:B.dll ClassC.cs                    // compile full B
>csc /target:library /reference:B.dll /out:A.dll ClassA.cs ClassB.cs  // compile full A
 
 
בתוצאה הסופית, קיבלנו בדיוק מה שרצינו. יש לנו כעת שני DLL'ים, A ו-B. ובניגוד לכל מה שהגיוני בעולם, קיימת ביניהם תלות מעגלית אחת ונפלאה.
אחרי מסכת חיפושים קצרה שערכתי בגוגל, הגעתי לקצה חוט שלפיו, בתהליך ה-Build הפנימי של מיקרוסופט נעשה שימוש ב-Metadata Assemblies (אסמבלים המכילים אך ורק Metadata, ללא פרטי מימוש), במקום אסמבלים אמיתיים. ובצורה זאת התלות המעגלית "נשברת" והסיטואציה המשונה הזאת מתאפשרת. עם זאת, לא נתקלתי עדיין במאמר מסודר מספיק שמתאר את התהליך בפרטי פרטים, כך שאין לשער באילו קסמים אחרים יתכן ומשתמשים ברדמונד.
DateTime.Now Causes Boxing

אולי לא הייתם מיודעים לכך, אבל בכל פעם שאתם פונים ל-DateTime.Now, אתם גורמים בעקיפין הקצאת זכרון דינאמית על ידי Boxing של Int32.
הסיבה לכך טמונה עמוק בתוך המימוש של Now, שאם נסתכל מקרוב, נוכל לראות שהוא למעשה עוטף קריאה ל-UtcNow והמרתו לזמן מקומי על ידי קריאה לפונקציה ToLocalTime.
עם הקריאה ל-UtcNow אין בעיה, מאחר ובסך הכל נעשית קריאה פנימית ל-GetSystemTimeAsFileTime, שמחזיר את הזמן הנוכחי בפורמט UTC.
הבעיה האמיתית טמונה במימוש של ToLocalTime, או למעשה במחלקה CurrentSystemTimeZone בה היא נעזרת. כחלק מרוטינת ההמרה, מפעילים את הפונקציה GetDaylightChanges שגורמת ל-Boxing עצמו. לא מאמינים? תראו בעצמכם:

public override DaylightTime GetDaylightChanges(int year)
{

object key = year;

if (!this.
m_CachedDaylightChanges.Contains(key))
{
// ..lots of code

}

return (
DaylightTime) this.m_CachedDaylightChanges[key];
}

זאת דוגמה טובה לכך שגם בגרסאות האחרונות של הפריימוורק, עדיין לא עודכנו קטעי קוד שמשתמשים במבני נתונים מגרסאות 1.0/1.1, לפני שהוצגו היכולות הגנריות בדוט-נט (במקרה הזה, m_CachedDaylightChanges הוא למעשה Hashtable שיודע לעבוד רק עם Object'ים).
המשמעות היא שהדוגמה הזאת עם DateTime.Now היא רק טיפה אחת בים, ואני לא אתפלא אם מבצעים עוד אינספור פעולות Boxing/Unboxing בלי שאנחנו בכלל מודעים לכך בתוך ה-BCL. עם זאת, מה שהופך את המקרה עם DateTime.Now לחמור במיוחד, הוא שמדובר במאפיין שיכולים לקרוא לו בתדירות גבוהה במיוחד (למשל, השארת חותמת זמן בכתיבה ללוג), כך שלכל הקצאות הזכרון הדינאמיות האלה יש מחיר, והוא לא זול.

לצורך האילוסטרציה, נשתמש בבנצ'מרק הבא:

while (true)

{

    Stopwatch sw = Stopwatch.StartNew();

 

    for (int i = 0; i < 1000000; i++)

    {

        DateTime now = DateTime.Now;

    }

 

    Console.WriteLine(sw.ElapsedMilliseconds);

}


מהרצה של הקוד הזה, אפשר ללמוד 2 דברים. הראשון, הוא שלוקח לנו בממוצע 456ms להשלים כל לולאה (נתייחס לזה בהמשך). והשני, הוא שניתן לקבל סדר גודל על השפעת הקריאות ל-Now על כלל ההקצאות הדינאמיות (שכביכול בכלל לא אמורות להתקיים בדוגמה הזאת). כך שאם נשתמש ב-Performance Counter כדי לנתר את הקצאות הזכרון, נקבל את התוצאה "המפתיעה" הזאת:



אם כן, למרות שכל ההקצאות האלה ישוחררו על ידי ה-GC במסגרת Generation 0, עדיין אי אפשר לקחת אותם יותר מדי בקלות, במיוחד אם אתם בונים אפליקציה בה אתם מנסים לצמצם ככל האפשר הקצאות דינאמיות, למען מתן שיפור בביצועים ובתגובתיות התוכנית (על ידי חסכון של Collection'ים מיותרים).

איך להמנע מ-Boxing?
אחרי שראינו למה לא באמת כדאי לקרוא ל-DateTime.Now, עולה השאלה איך אפשר להמנע מההקצאות היקרות והמיותרת האלה.
מהצד של מיקרוסופט, הם רק צריכים לעדכן את הקוד ולהחליף את השימוש ב-Hashtable ל-Dictionary. ברגע שהם יעשו את זה, נוכל לחזור להשתמש ב-Now בלי לחשוש שאנחנו מפגיזים את ה-Heap בהקצאות זכרון לא נגמרות.
עד שזה יקרה, אנחנו צריכים לקחת את העיניים לידיים שלנו, ולהשתמש בתחליף ל-DateTime.Now. מה שאני מציע לעשות הוא פשוט לכתוב פונקציה שכמעט מקבילה במימוש ל-UtcNow (שכזכור פונה ל-Win32 בשביל לקבל את הזמן). כך שכל מה שאנחנו צריכים לעשות זה לפנות ל-GetLocalTime שתחזיר לנו את הזמן בצורה של SYSTEMTIME, ואותו רק נצטרך להמיר ל-DateTime שיוחזר למשתמש.
בקוד זה נראה כך:

public static class TimeUtil

{

    [DllImport("kernel32.dll")]

    static extern void GetLocalTime(out SYSTEMTIME time);

 

    [StructLayout(LayoutKind.Sequential)]

    private struct SYSTEMTIME

    {

        public ushort Year;

        public ushort Month;

        public ushort DayOfWeek;

        public ushort Day;

        public ushort Hour;

        public ushort Minute;

        public ushort Second;

        public ushort Milliseconds;

    }

 

    public static DateTime LocalTime

    {

        get

        {

            SYSTEMTIME nativeTime;

            GetLocalTime(out nativeTime);

 

            return new DateTime(nativeTime.Year, nativeTime.Month, nativeTime.Day,

                    nativeTime.Hour, nativeTime.Minute, nativeTime.Second,

                    nativeTime.Milliseconds, DateTimeKind.Local);

        }

    }

}


עכשיו לצורך העניין, נשווה את ביצועי הקוד הזה מול השימוש ב-Now. אז לאחר הרצת הבנצ'מרק ממקודם, אפשר לראות שנפטרנו לגמרי מההקצאות הדינאמיות המטרידות. ודבר שני, אנחנו עכשיו מקבלים תוצאה ממוצעת של 370ms. כלומר, שיפור של 18% בהשוואה לשימוש ב-Now.
Macro For Automatically Freezing Threads

לפני 3 חודשים כתבתי פוסט קצר שהציע דרך להקל על עבודת ה-Debug תחת Multithreaded Environments. במקום להתחיל לקבוע Breakpoint'ים עם פילטרים שיגרמו רק לת'רד מסויים להעצר בהם, הצעתי להגדיר Breakpoint רגיל וברגע שנעצרים בו, פשוט לגשת לפאנל ה-Threads ולהקפיא את כל שאר הת'רדים הקיימים (עניין של Select All והקלקה על Freeze).
לאחרונה John Robbins פרסם בבלוג שלו פוסט עם דוגמה לפקודת מאקרו שמקפיאה באופן אוטומטי את כל הת'רדים חוץ מהפעיל. למעשה, אוטומטיזציה של הפוסט הקודם שלי. כך שאפשר לחסוך אפילו עוד כמה רגעים ולהשתמש במאקרו הזה גם כן. יכול להיות שימושי מאוד במידה ואתם מוצאים את עצמכם לא פעם מנסים ללהטט בין Breakpoint'ים עם ת'רדים שונים במקביל.

Regions From Hell

אם יש דבר אחד שאני לא אוהב לראות בקוד, זה שימוש מוגזם ב-Region'ים.
הטיעון העיקרי של התומכים בשימוש באותם Region'ים הוא שאפשר להגיע בעזרתם לקוד הרבה יותר "נקי", "מסודר", או חס וחלילה, "קל לתחזוקה". תלוי ביום, ומצב הרוח שלי באותו רגע, אני אוטומטית משיב: הפוך גוטה, הפוך.
הדבר היחיד ש-Region'ים יודעים לעשות זה להחביא קוד. מה שלעצמו מרגיש די אבסורדי מאחר ורובנו בדרך כלל נמצאים במירוץ לא נגמר אחרי מסך גדול יותר, רזולוציה מטורפת יותר, פונט קטן יותר - העיקר להכניס כמה שיותר קוד למסך בודד. ובכל זאת, ברגע שאנחנו מתחילים לתחום אזורים בקוד עם Region, אנחנו למעשה הופכים את הגלגל לאחור, וגורמים לנו להחשף לפחות ופחות שורות קוד.
כשמפתח ניגש בפעם הראשונה לקוד שהוא לא מכיר, הדבר הראשון שעומד בראש מעיניו הוא לענות על השאלה (הכביכול פשוטה)  "מה _____ הולך כאן?!" (הכנס קללה מועדפת). הדרך הזריזה ביותר לענות על השאלה הזאת היא להקליק על Ctrl+M+O ולגרום לכל הקוד שבקובץ להתכנס להצהרות הפונקציות בלבד. לאחר הצעד הזה, אפשר לסרוק תחילה אחרי פונקציות הנחשפות כ-Public, ומשם כבר להתחיל להבין איך ה-Execution Flow עובר דרך המחלקה, והפונקציות השונות. ובכלל, להבין איזו לוגיקה היא מכילה.
אולם, ברגע שאנחנו מחליטים להשתמש ב-Regions, אנחנו מאבדים את כל הנוחות הזאת, והופכים את עבודת התחזוקה למייגת וארוכה עוד יותר.
נקח את הדוגמה הקלאסית הבאה:



עכשיו, תחשבו שאתם מגיעים בפעם הראשונה לטיפוס המאוד-מאוד מסובך הזה, ומנסים להבין מה בכלל קורה שם. אבל, ברגע שאתם מנסים לראות איזשהו תמונה כללית על הקוד, אתם נתקלים בקיר הבטון הזה שהשימוש ב-Regions גורם לו. במבט ראשון יכול אנחנו יכולים לקבל את הרושם שסך הכל מדובר במחלקה פשוטה למדי, בלי יותר מדי שורות קוד. אבל כל זה הוא לא יותר מאחיזת שווא, מאחר ויתכן וכל אותם Region'ים שאנחנו רואים, יכולים למעשה להסתיר מאות, אם לא אלפי שורות קוד שמתחבאות מתחת לעטיפה היפה הזאת.
בדוגמה הזאת יש 2 בעיות עיקריות. הראשונה, היא הניסיון לתחום אזורים בקוד על פי ה-Access Modifier שלהם. לגבי זה, חשוב לזכור שנורא, אבל נורא קשה לתחזק חיה שכזאת. זאת אומרת, את ה-Region עצמם. רק תחשבו שאתם מוסיפים עכשיו פונקציה פרטית, או אולי איזו Utility מסכנה לקוד. מי שבאמת חושב שהוא יזכור תמיד, אבל תמיד, להכניס את אותה פונקציה לבלוק ה-Region המתאים ביותר .. שיחשוב שנית. זה פשוט בלתי אפשרי, ואי אפשר לצפות מאף אחד לתחזק דבר כזה ב-100% מהזמן. והנה, ברגע ש"התפספס" לנו משהו קטן כזה, הלך לנו כל הסדר. עכשיו מבחינתנו אותה פונקציית Utility כבר לא קיימת. הרי כשנרצה לבחון את אוסף הפונקציות הפרטיות, קיימת סבירות לא רעה בכלל שלא נמצא אותה תחת ה-Region שמתאים לה. ואם אנחנו לא יכולים למצוא אותה שם, מה זה בכלל עוזר לנו זה שאנחנו משתמשים ב-Regions מלכתחילה? ברגע אחד של חוסר תשומת לב איבדנו את כל ה"יתרון".
דבר שני שאפשר לשים לב אליו כאן, הוא Region'ים שתוחמים מימושים של ממשקים. כשבנאדם בא לממש ממשק באופן ידני, סביר להניח שהוא לא יחליט על דעת עצמו להוסיף Region כזה עבור כל ממשק שהוא מחליט להוסיף. אבל, בדיוק במקום הזה Visual Studio מחליט "להגדיל ראש" ולהוסיף אותו עבורנו, ברגע שאנחנו נעזרים ביכולת המימוש האוטומטי שלו. למזלנו, ניתן לבטל את התנהגות ברירת המחדל הלא-מועילה-בעליל הזאת, ולקבוע שהוא לא ידחוף את האף שלו במקרים האלה.
כל מה שצריך לעשות, זה לגשת לחלון ה-Options ומשם דרך התפריט: Text Editor->C#->Advanced, ולהוריד את הסימון מהתיבה "Surround generated code with #region".

 
ואחרי כל זה...
אני אגיד את הדבר הבא: Region'ים הם לא בהכרח רעים, אם משתמשים בהם נכון.
ככלל, כדאי להטיל ספק ברגע ששומעים מישהו אומר "_אף פעם_ אל תעשו X. במקום זה, _תמיד_ תעשו Y". כי כמו בכל דבר אחר בחיים, תמיד יש מקרים "יוצאים מן הכלל", ותמיד בכל דבר שנראה רע .. מסתתר קצת טוב.
זה נכון גם לגבי Region'ים. לפי דעתי, וטעמי האישי, שימוש ב-Region'ים יכול להביא תועלת כאשר באמת מה שאנחנו רוצים לעשות זה להחביא קוד. כלומר אנחנו הופכים את החסרון ליתרון שימושי. המקרים היחידים בהם אני רואה לנכון להשתמש ב-Region'ים הם באותם מקרים בודדים, בהם אנחנו באמת פשוט לא רוצים בכלל לראות קוד/מימוש. כשמדובר בקוד סתמי לגמרי, שאפילו מעצם זה שקראנו את הטקסט שמתאר את ה-Region, אנחנו יכולים להבין בדיוק מה הקוד המוסתר עושה - ולאחר מכן להתפנות להמשיך הלאה בסקירה. דוגמה למקרה כזה הוא מימוש פשטני של אופרטורים. אם למשל אנחנו מחליטים להגדיר טיפוס חדש בשם BigInteger, ואנחנו רוצים להוסיף לו כל אופרטור אפשרי של השוואה/חיבור/חיסור וכו'... אז באמת שאין צורך להעמיס על העיניים עם המספר הלא מבוטל הזה של פונקציות שחוזרות על עצמן פעם אחר פעם (כאמור, זאת בהנחה שאין בהן שום לוגיקה "חכמה" מעבר לכל מה שברור מאליו). להבדיל ממקרים אחרים, עטיפה של כל אזור הקוד שמגדיר את אותם אופרטורים ב-Region בודד - לא יגרום לקריסה טוטאלית של היקום בו אנחנו חיים. ועם זאת, מילת המפתח ומוסר ההשכל הכי חשוב שאפשר לקחת מהפוסט הזה, הוא "בזהירות!".
Code Sample: WorkerResetEvent

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

AutoResetEvent m_event = new AutoResetEvent(false);

 

private void WorkCycles()

{

    while(true)

    {

        // wait for a signal

        m_event.WaitOne();

 

        // do work..

    }

}


שימוש ב-AutoResetEvent מאוד קורץ ומתאים כאן מאחר והוא נותן לנו בדיוק את הפונקציונליות שאנחנו צריכים, לגרום לת'רד לחכת עד שהוא מקבל Signal שאומר לו שיש לו עבודה לעשות. אולם, הפניות אליו יקרות מאוד. ומאחר והטיפוס הדוט-נטי לא שומר את ה-State של ה-Event (האם כבר קיבלנו סיגנל או לא), כל קריאה ל-Set או WaintOne למשל, תגרום לטיול עד לרמת אובייקט הקרנל - גם אם אין צורך בכך.
בתרחיש שהדגמתי כאן, מאוד סביר שאחוז ניכר מהפניות ל-Event הן כלל לא נחוצות. אם למשל מגיעה בקשה חדשה לעבודה כל 100ms (שגוררת קריאה ל-Set) אבל בפועל לוקח לת'רד שנייה אחת לסיים כל מחזור עבודה, אז זה אומר שביצענו 9 קריאות מיותרת ל-Set. ובנוסף על כך, ברגע שסיימנו מחזור עבודה, אנחנו נקרא ל-WaitOne פעם נוספת, למרות ששוב.. אין צורך בכך (הרי אנחנו כביכול יודעים שיש כבר עבודה, כך שאין צורך לפנות ל-Event).

אז עד כמה יקרות הפניות המיותרת ל-Event ומה ההשפעה על הביצועים? נשתמש בקוד הבא בתור בנצ'מרק:

while (true)

{

    AutoResetEvent eventObj = new AutoResetEvent(false);

 

    Stopwatch sw = Stopwatch.StartNew();

    for (int i = 0; i < 100000; i++)

    {

        // call Set 10 times

        eventObj.Set();

        eventObj.Set();

        eventObj.Set();

        eventObj.Set();

        eventObj.Set();

        eventObj.Set();

        eventObj.Set();

        eventObj.Set();

        eventObj.Set();

        eventObj.Set();

 

        eventObj.WaitOne();

    }

 

    Console.WriteLine(sw.ElapsedMilliseconds);

}


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

WorkerResetEvent

אם כך, מה הפתרון? וכיצד ניתן להוריד את העלות הלא סבירה הזאת בשימוש ב-Event?
למעשה, הפתרון הוא די Straightforward. כל מה שעלינו לעשות זה להמנע מקריאות מיותרות ל-Set ו-WaitOne. איך עושים את זה? משתמשים ב-WorkerEventReset (תסלחו לי, אבל אף פעם לא הייתי מוצלח מדי במתן שמות..). מדובר בפרימיטיב סנכרון קטן שעוטף בתוכו מופע של AutoResetEvent, ואת המצב הנוכחי שלו (האם כבר קיבלנו Signal, האם ה-Worker Thread מחכה וכו'...).
אני אתן דוגמה למימוש, ואחר כך אסביר כבר בקצת יותר פרטים על הקונספט..

public class WorkerResetEvent

{

    // implementation may induce some mild race conditions, but they

    // merely effect performance

 

    private volatile int m_eventState;

    private AutoResetEvent m_event;

 

    private Thread m_worker;

 

    private const int EVENT_SET     = 1;

    private const int EVENT_NOT_SET = 2;

    private const int EVENT_ON_WAIT = 3;

 

    public WorkerResetEvent(bool initialState, Thread workerThread)

    {

        m_event = new AutoResetEvent(initialState);

        m_eventState = initialState ? EVENT_SET : EVENT_NOT_SET;

 

        m_worker = workerThread;

    }

 

    public void WaitForWork()

    {

        verifyCaller();

 

        if (m_eventState == EVENT_SET && Interlocked.CompareExchange(

            ref m_eventState, EVENT_NOT_SET, EVENT_SET) == EVENT_SET)

        {

            return;

        }

 

        if (m_eventState == EVENT_NOT_SET && Interlocked.CompareExchange(

            ref m_eventState, EVENT_ON_WAIT, EVENT_NOT_SET) == EVENT_NOT_SET)

        {

            m_event.WaitOne();

        }

    }

 

    public void SignalWork()

    {

        if (m_eventState == EVENT_NOT_SET && Interlocked.CompareExchange(

            ref m_eventState, EVENT_SET, EVENT_NOT_SET) == EVENT_NOT_SET)

        {

            return;

        }

 

        if (m_eventState == EVENT_ON_WAIT && Interlocked.CompareExchange(

            ref m_eventState, EVENT_NOT_SET, EVENT_ON_WAIT) == EVENT_ON_WAIT)

        {

            m_event.Set();

        }

    }

 

    // Note: optional attribute

    // [Conditional("DEBUG")]

    private void verifyCaller()

    {

        if (m_worker != Thread.CurrentThread)

        {

            string errMsg = string.Format("Only the pre-defined Worker thread may

               call WaitOne (Current: {0}, Worker: {1})", Thread.CurrentThread, m_worker);

 

            throw new SynchronizationLockException(errMsg);

        }

    }

}


אז דבר ראשון, אני משער שהשאלה הכי מסקרנת עכשיו היא "כמה באמת זה יותר מהיר?". ובכן, בשימוש אותו בנצ'מרק ממוקדם, אני מקבל בממוצע תוצאה של 9ms בלבד. שיפור של 11400% על פני השימוש הישיר ב-AutoResetEvent. והנה, בעקבות "רק" הורדת מספר הפניות למינימום הנחוץ קיבלנו שיפור בביצועים בקנה מידה אסטרונומי.
במימוש עצמו, השתמשתי ב-2 אמצעים על מנת לחסוך עבודה מיותרת. הראשון, אנחנו שומרים כעת על ה-State של ה-Event, כך שאנחנו יודעים מתי אפשר להמנע מפניות ל-Event. נוסף על כך, כדי להפוך את עדכון ה-State'ים ל-Thread Safe, עשיתי שימוש בפעולות CAS, שלמרות שהן יותר זולות משימוש ב-Lock, עדיין מדובר בפעולות יקרות עבור המעבד. לכן, העדפתי להשתמש בטכניקת TATAS (כלומר: test-and-test-and-set) כדי להמנע מפעולות CAS מיותרות גם כן.
כמו שההערה בתחילת הקוד ציינה, יכול להתעורר בקוד Race Condition כאשר ה-Worker Thread נמצא בדרך/מחוץ ל-WaitOne בזמן שת'רד חיצוני אחר קורא בדיוק ל-SignalWork. סך הכל מדובר ב-RC שלא יגרום לנזק, אלא במקרה הגרוע ביותר יגרום לנו לקרוא ל-Set או Wait עוד איזה פעם אחת למרות שלא היינו חייבים באמת. כך שמבחינת הפונקציונליות אין שום פגיעה, ומבחינת הביצועים גם כן אין בכלל על מה לדבר.

ואם כבר נגענו בנושא, כדאי לדעת ולהכיר שתחת חבילת ה-Parallel Extensions קיימת מחלקה חדשה בשם ManualResetEventSlim שגם כן נותנת פונקציונליות מורחבת וביצועים משופרים על פני ה-Event'ים הסטנדרטים. רק שבנוסף לשמירת State, היא עושה שימוש ב-Spinning לפני החסימה האמיתית, וגם כן משתמשת ב-Lazy Initialization ליצירת אובייקט הקרנל.
Why Thread Priorities Are Evil

לכל ת'רד שרץ תחת מערכת ההפעלה יש עדיפות, שיכולה להקבע על ידי.. כל אחד. אלה יכולים להיות אתם, המפתחים, שדואגים להעניק עדיפות מיוחדת לת'רד שיצרתם, או שאולי זה יכול גם להיות משתמש שובב, שקם בבוקר והחליט לפתוח את ה-Task Manager ולהכניס תהליך שלם לעדיפות Realtime (למעשה, לקבוע את ה-Priority Class של התהליך, שבתורו משפיע על העדיפות שכל ת'רד באותו תהליך מקבל).
בתיאוריה, כיול ומענק חכם של עדיפויות יכול להביא לשיפור בתגובתיות ובביצועים של המערכת. אולם, במציאות.. סביר להניח שלא רק שהמשחקים האלה לא יביאו לשיפור אמיתי בביצועים, אלא שהם יביאו עמם כל מיני התנהגויות "מוזרות" ו"בלתי צפויות". כאלה מהסוג שאף אדם שפוי לא ירצה להתחיל לדבג בערב יום חמישי, עמוק אל תוך הלילה.. (למזלנו, מסתובבים בינינו מספיק אנשים שלוקים מעט בשפיותם.. :)
העובדה היא, שאין לכם שליטה מלאה על האפליקציה שלכם, או מערכת ההפעלה. אתם לא _באמת_ יודעים מה קורה מסביבכם כשהתוכנית שלכם רצה באתר הלקוח, 20 אלף קילומטרים מכם. רוב הזמן, אין לנו שליטה מלאה על התהליכים שרצים במקביל אלינו, או אפילו באותו תהליך איתנו. אנחנו לא מכירים את הדרישות שלהם, המבנה שלהם, מספר הת'רדים, עדיפויות וכו'.. כל זה גורם לכל משחק העדיפויות הזה שקבענו במערכת שלנו למסוכן עוד יותר. זאת הסיבה שאם תציצו לרגע ב-Task Manager אצלכם, תוכלו לראות שבאופן כמעט מוחלט, כל התהליכים שרצים כרגע עובדים על עדיפות Normal. בצורה הזאת אנחנו נותנים ל-Scheduler של Windows לעשות את העבודה שלו בצורה הטובה ביותר. הוא ידאג בעצמו לתזמן את הת'רדים בזמן שנראה לו מתאים, על פי שיקולים הרבה יותר טובים מאלו שאנחנו מוסגלים לעשות. כך שבאותה הרוח שלא מומלץ להפריע ל-Garbage Collector ולהתחיל לדחוף בו ולגרום לו להפעיל Collection'ים דרך קריאה ל-Collect מתי "שנראה לנו", אנחנו גם לא צריכים להיות "חכמים" ולהחליט בעצמנו איזה ת'רד כן צריך לקבל עדיפות, ואיזה לא.

Priority Boosts
למזלנו, אותו Thread Scheduler שאחראי על חלוקת זמן המעבד בין הת'רדים השונים, פותר בין היתר "Deadlock'ים בפוטנציה" שעלולים להגרם מתוך חלוקה מסוכנת של עדיפויות לת'רדים שיש ביניהם אינטרקציה כלשהיא.
אפשר להדגים את אותו סיכון דרך בעיה מוכרת הנקראת Priority Inversion. קחו לדוגמה את הסיטואציה הבאה: יש לנו 3 ת'רדים במערכת, הראשון (A) עם עדיפות גבוהה, השני (B) עם עדיפות בינונית, והשלישי (C) עם עדיפות נמוכה. עכשיו מה שקורה זה דבר כזה, ת'רד C נכנס ל-Critical Zone ונועל את הכניסה לקטע הקוד. ת'רד A מגיע גם כן לאותו קטע קוד, אבל נעצר תחת הנעילה (מחכה שת'רד C ישחרר אותה). והנה, בלי ששמנו לב, נוצר לנו Deadlock. מה שקורה זה שכעת ת'רד B, בעל העדיפות הבינונית, מקבל את כל זמן המעבד ורץ ללא הפסקה. זאת מאחר ות'רד A בעל העדיפות הגבוהה מחכה בנעילה, ות'רד C בעל העדיפות הנמוכה לא מספיק אף פעם לשחרר אותה (מאחר ות'רד B לא מפסיק לעבוד בגלל שיש לו עדיפות גבוהה על פני ת'רד C).
אולם בפועל, הסיטואציה הזאת לא תקרה (או לפחות, סביר להניח שלא תקרה, אלא אם כן עשיתם משהו מאוד רע שתכף אדבר עליו). מה שקורה, זה שאותו Scheduler משתמש בת'רד פרטי ונסתר שמתעורר כל שניה וסוקר את כל הת'רדים שממתינים לתזמון. באותו סיקור, הוא בודק אילו ת'רדים לא תוזמנו כבר זמן ממושך למדי (באופן גס, מדובר על סדר גודל של 3.5~ שניות). בצורה הזאת, הוא מבחין אילו ת'רדים יתכן וסובלים מ-Starvation (כפי שקורה בדוגמה אצל ת'רד C). כשהוא מזהה מקרה שכזה, הוא מעניק לאותו ת'רד Priority Boost שמעלה את ה-Priority שלו הישר ל-15 (Time Critical). בצורה הזאת, הוא כמעט ומבטיח שאותו ת'רד יהיה בין הת'רדים הראשונים שיתוזמנו ויקבלו זמן לעבוד. ביחד עם אותו Priority Boost, אותו ת'רד יכול לקבל גם Quantum Boost, בגודל של פי 2 או אפילו פי 4 מה-Quantum הרגיל (תלוי בהאם התוכנית רצה על עמדת לקוח או שרת). אותו Boost ימנע את ה-Deadlock שראינו, בכך שהוא מעניק מספיק זמן מעבד לת'רד C, שיוכל לשחרר כעת את הנעילה ולתת לתוכניות להמשיך לרוץ כרגיל.
מקרה אחר בו אנחנו יכולים להתקל באותה צרה, היא כאשר יש לנו ת'רד בעל עדיפות גבוהה שרץ תחת איזשהו Spin Cycle שבכל הפעלה בודק תנאי בוליאני שאומר לו האם הוא צריך להפסיק לעבוד, או להמשיך ל-Cycle נוסף (בפועל, סוג של Busy Loop/SpinWait). הבעיה היא, שהת'רד האחר, שצריך לעדכן את התוצאה של אותו תנאי בוליאני, הינו בעל עדיפות נמוכה יותר מזה של הת'רד הראשון. לכן, יכול לקרות מצב בו הת'רד הראשון יעבוד ללא הפסקה, בעוד הת'רד השני לעולם לא יספיק לעדכן את התנאי הבוליאני, ולעצור את ה-Busy Loop אליו נכנס הת'רד הראשון. במקרה הזה, ניתן לפתור את הבעיה גם בשימוש ב-Sleep (השם ישמור), אבל זה כבר נושא שאדון בו כבר בפוסט נפרד. ולחשוב.. שכל זה היה יכול להמנע אם היינו מתאפקים, ולא משחקים בעדיפויות הת'רדים.
צריך לציין, שהדוגמאות האלו מושפעות גם באופן ישיר בשאלה האם התוכנית שלנו רצה על תחנה בעלת מעבד בודד, או כזה בעל מספר ליבות/מעבדים. אבל בכל אופן, המסר הוא אותו מסר - לא משחקים עם עדיפויות.
מוקדם יותר ציינתי שאותו Boost לא בהכרח מבטיח להוציא אותנו מהמבוי הסתום הזה. זה יכול לקרות במידה והגדרנו בעצמנו ת'רדים עם עדיפות גבוהה מ-15. במקרה כזה, יתכן שגם לאחר הענקת ה-Boost, הת'רד עדיין יהיה בעדיפות נמוכה יחסית לת'רדים עם העדיפות הסופר-גבוהה שהחלטנו לתת על דעת עצמנו.
בנוסף למנגנון למניעת Starvation, ה-Scheduler יכול להחליט להעניק Boost'ים גם על פי מספר פרמטרים אחרים. למשל, תוכניות בעלות חלון שנמצא ב-Foreground יקבלו עדיפות גבוהה יותר מאשר תוכניות שרצות ב-Background (בפועל, ניתן לקנפג את הפרמטר הזה תחת Windows). נוסף על כך, ת'רדים ממתינים שקיבלו Signal'ים אודות WaitHandle כלשהו, יכולים לקבל Boost רגעי גם כן.

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

More Posts Next page »