Liran Chen's Blog

.Net Internals, Development, Multithreading - and More!

November 2009 - Posts

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 הדקות בשביל לעדכן את הקוד המתאים.