DCSIMG
Liran Chen's Blog

Liran Chen's Blog

.Net Internals, Debugging, Multithreading - and More!

הבלוג עובר דירה

חדי העין שביניכם בוודאי ששמו לב שבעת האחרונה הבלוג לא בדיוק היה עתיר ומלא חיים. אבל לשם שינוי, יש לכך גם סיבה.
במספר החודשים האחרונים הבלוג עבר מתיחת פנים ושדרוג די רציני, וכעת אפשר להגיע אליו דרך הכתובת blog.liranchen.com. ההחלטה על המעבר לפלטפורמה החדשה נבעה בעיקר מתוך המסקנה שכך פשוט קל יותר להגיע לקהלי יעד גדולים יותר. עד כמה אני שמח שהיתה לי ההזדמנות להוסיף מעט תוכן מקצועי בעברית לרשת, חייבים להכיר בכך שכדי לעשות את הצעד הבא, עדכון כזה לבלוג הוא חיוני.
כך שבתקופה האחרונה הייתי עסוק בעיקר בהעברת תכנים מהבלוג הזה לחדש, ופחות בכתיבת תכנים חדשים. המעבר למעשה עדיין לא הושלם לחלוטין, אבל אני מאמין שהסיום גם כן לא רחוק, כך שניתן לצפות שיעלו גם פוסטים חדשים בזמן הקרוב.
 
בעבור כל מנויי הבלוג, אני מציע לעדכן את קורא ה-RSS המועדף עליכם בכתובת החדשה.
אז תודה לכולם על התקופה הנעימה, ונתראה כבר שם :)

CLRStack -p" Isn't Always Reliable"

אחת הפקודות היותר שימושיות שזמינה לנו ב-SOS היא CLRStack!, שביחד עם הדגל p- מנסה להציג לנו את ערכי הפרמטרים שהועברו לכל פונקציה ב-Stack הדוט-נטי שלנו. אני אומר "מנסה" בגלל ש-SOS יציג לנו את הערכים רק אם  הוא חושב שהוא הצליח למצוא את הערכים הנכונים, ואני אומר "חושב" כי במקרים מסויימים הוא פשוט הולך לטעות.

במקרה שבו SOS יגיע למסקנה שהוא לא יודע מה הערך שהועבר בפרמטר, תודפס התשובה <no data>. זה קורה בגלל שלעיתים ל-SOS אין באמת דרך לדעת בוודאות מה היה ערכו של הפרמטר שהועבר לפונקציה רק על ידי הסתכלות על מצב ה-Stack Frame הרלוונטי. במקרה שבו אנחנו משתמשים ב-Calling Convention כגון fast call, שני הפרמטרים הראשונים (מצד שמאל) שמבחינת הגודל מתאימים להכנס לתוך רג'יסטר, יועברו בתוך ECX ו-EDX במקום להיות מועתקים על המחסנית. עבור Member Functions למשל, ערכו של המצביע this נוהג לעבור בתוך ECX. ההתנהגות הזאת יכולה להוביל לכך שאם לקחנו Dump או עצרנו עם הדיבאגר בנקודה מאוחרת מדי בחיי התוכנית, יתכן שערכם של אותם פרמטרים שהועברו כרג'יסטרים כבר נדרסו.
להבדיל ממקרים בהם SOS מגיע למסקנה שהוא לא בטוח מה היה ערכו של הפרמטר, מדי פעם הוא גם הולך פשוט לטעות ולהדפיס את הערכים הלא נכונים. מה שיכול כמובן להוביל ללא מעט כאב ראש אצל מי שמנסה לדבג את התוכנית (הופעה של ערכים "בלתי הגיוניים", קיום של Execution Paths "בלתי אפשריים" וכו'..)
מה שיפה, זה שלא צריך להתאמץ יותר מדי כדי לגרום לעיוות כזה לבוא לידי ביטוי. ניקח את התוכנית הבאה כדוגמה:

    class Program
    {
        static void Main(string[] args)
        {
            Foo a = new Foo();
            a.Work(1, 2, 3, 4, 5);
        }
    }

    class Foo
    {
        [MethodImpl(MethodImplOptions.NoInlining)]
        public void Work(int x, int y, int z, int k, int p)
        {
            // break with debugger here
        }
    }
כעת נריץ את WinDbg, נטען את SOS, ונראה מה יש ל-CLRStack! יש להגיד על הפרמטרים שהועברו ל-Work:
(הפלט עשוי להראות מעט שונה מגרסה לגרסה. בדוגמה הנ"ל נעשה שימוש בגרסה 4.0.30319.1)

0:000> !clrstack -p
OS Thread Id: 0xfbc (0)
Child SP IP       Call Site
0012f3fc 030300b3 ConsoleApplication1.Foo.Work(Int32, Int32, Int32, Int32, Int32) [...\Program.cs @ 24]

    PARAMETERS:
        this (<CLR reg>) = 0x00b3c358
        x (<CLR reg>) = 0x00000001
        y (0x0012f40c) = 0x00000003
        z (0x0012f408) = 0x00000004
        k (0x0012f404) = 0x00000005
        p (0x0012f400) = 0x03030092

0012f414 03030092 ConsoleApplication1.Program.Main(System.String[]) [...\Program.cs @ 16]
    PARAMETERS:
        args = <no data>

0012f648 791421db [GCFrame: 0012f648]

0:000> r ecx   // holds "this" pointer
ecx=00b3c358
0:000> r edx  // holds "x" parameter
edx=00000001


אם כן, לא קשה לראות שמלבד מצביע ה-this ופרמטר x (שהועברו ברג'יסטרים), עבור כל שאר הפרמטרים הודפסו ערכים שגויים. למעשה, אפשר להבחין שלמעשה התרחש כאן "Shift" אחד בערכים של כל הפרמטרים (y קיבל את ערכו של z, בעוד שזה קיבל את ערכו k וכן הלאה..). כדי להבין קצת יותר טוב מה קרה כאן, נדפיס את הזכרון שנמצא בין שני ה-Stack Pointers הרלוונטים (בגרסאות קודמות מיוצג על ידי עמודת ESP במקום Child SP).

0:000> dp /c 1 0012f3fc  0012f414
0012f3fc  0012f414    // EBP
0012f400  03030092 // Return Address
0012f404  00000005  // p
0012f408  00000004  // k
0012f40c  00000003  // z
0012f410  00000002  // y
0012f414  0012f424   // EBP


אם נשווה עכשיו בין כתובות הפרמטרים על המחסנית ש-SOS נתן לנו, לעומת הכתובות האמיתיות שאנחנו רואים בזכרון, נראה שאכן נעשה Shift בודד לכתובות הזכרון של הפרמטרים שיושבים על המחסנית, כך ש-SOS למעשה מציג לנו עבור כל פרמטר את הערך של כתובת הזכרון השכנה לו.
המקרה הזה הוא דוגמא קלאסית לאופי המעט "Buggy" של SOS. זה לא אומר שצריך להפסיק להשתמש ב-CLRStack!, אבל זה כן צריך להזכיר לנו לא לקחת את הפלט שלו כהאמת המוחלטת, ופשוט לדאוג להשאר עירניים כדי לשים לב לכל מיני התנהגויות "מוזרות" כמו זאת.

Writing a Semi-Local Object Pool

שימוש ב-Object Pools בסביבה דוט-נטית יכול לתרום לנו בעיקר משתי בחינות: א) הורדת זמן האתחול עבור אובייקטים "כבדים" שעלולים לקחת זמן רב עד שישלימו את האתחול הראשוני שלהם, ב) הורדת כמות וקצב ההקצאות הדינאמיות שהתוכנית שלנו מבצעת, ועל ידי כך להוריד את ה-Latency שה-GC עשוי להוסיף לנו ב-Collection'ים עתידיים.
עם זאת, ראוי לציין שבתרחישים מסויימים שימוש ב-Object Pools יכול דווקא לפגוע בביצועים. מאחר ובסביבות מנוהלת (CLR, JVM וכו'..) הזמן שלוקח לנו להקצות זכרון לאובייקט יהיה בדרך כלל מהיר מאוד, לכן אם אנחנו משתמשים ב-Pooling לטובת אובייקטים קטנים שאינם דורשים זמן אתחול ארוך מהרגיל, אנחנו למעשה יכולים לסבול מ-Overhead שהשימוש ב-Pool יביא איתו. Brian Goetz סיכם את זה כך:

Allocation in JVMs was not always so fast -- early JVMs indeed had poor allocation and garbage collection performance, which is almost certainly where this myth got started. In the very early days, we saw a lot of "allocation is slow" advice -- because it was, along with everything else in early JVMs -- and performance gurus advocated various tricks to avoid allocation, such as object pooling. (Public service announcement: Object pooling is now a serious performance loss for all but the most heavyweight of objects, and even then it is tricky to get right without introducing concurrency bottlenecks.)

אם כן, Object Pools יכולים להוות צוואר בקבוק משמעותי באפליקציות שמשתמשות ב-Pool מרכזי אותו חולקים כלל המודולים. הסיבה טמונה בכך שכל גישה ל-Pool לטובת הקצאה או שחרור אובייקט תוביל לעדכון ה-State הפנימי של ה-Pool, כך שבאפליקציה המשתמשת ביותר מת'רד אחד, נצטרך לסנכרן בין כל הת'רדים את הגישה ל-Pool המרכזי.
מימוש פשטני של Object Pool התומך ב-Thread Safety יכלול נעילה של פונקציות ההקצאה והשחרור. הבעיה כאן היא שכל גישה ל-Pool תגרור נעילה של ה-Pool כולו, כך שנוצר כאן פוטנציאל ל-Contention גבוה במידה ות'רדים שונים ינסו להקצות/לשחרר אובייקטים במקביל.
לצורך ההדגמה כתבתי בנצ'מרק קטן שמנצל את כל המעבדים העומדים לרשותו על מנת להקצות ולשחרר מספר קבוע של אובייקטים (כל ת'רד מקבל את החלק היחסי שלו מכלל האובייקטים שצריך להקצות/לשחרר). הגיונית בגלל שאנחנו מחלקים את העבודה ליותר מעבדים אנחנו כביכול אמורים לקבל שיפור בביצועים, אבל בפועל אנחנו יכולים לראות שאנחנו למעשה חווים Slowdown חמור יותר ככול שאנחנו מוסיפים יותר מעבדים. התוצאה הזאת לא מפתיעה במיוחד משום שהיא נגרמת עקב ה-Contention הגבוה הקיים בהקצאת/שחרור האובייקטים:



המימוש הראשוני של ה-Pool בו נעשה שימוש בבדיקה:
(יותר לציין כי דוגמאות הקוד המובאות בפוסט זה באות להדגים את הבדלי הרעיונות הקונספטואלים בין ה-Pool'ים השונים בלבד)

    // holds a dictionary that makes a pool-per-type corelation

    public class SimpleMainPool
    {
        private Dictionary<TypeISubPool> m_main;


        // to make things simpler, the dictionary isn't modified
        // after the first initialization
        public SimpleMainPool(Type[] pooledTypes)

        {
            m_main = new Dictionary<TypeISubPool>();


            foreach (Type curType in pooledTypes)
                m_main.Add(curType, new SemiLocalPool(curType));

        }

        public object Allocate(Type type)
        {
            ISubPool sub = m_main[type];


            object pooledObj = sub.Allocate();
            return pooledObj;
        }

        public void Free(object obj)

        {
            ISubPool sub = m_main[obj.GetType()];
            sub.Free(obj);
        }
    }

    // our simple thread-safe pool
    class SimplePool : ISubPool

    {
        private const int PRIME = 50;

        private Type m_type;

        private Stack<object> m_sharedPool;

        public SimplePool(Type type)

        {
            m_sharedPool = new Stack<object>(PRIME);
            m_type = type;


            for (int i = 0; i < PRIME; i++)
            {
                object sharedObj = Activator.CreateInstance(m_type);

                m_sharedPool.Push(sharedObj);
            }
        }

        public object Allocate()
        {
            lock (m_sharedPool)

            {
                if (m_sharedPool.Count == 0)
                {
                    for (int i = 0; i < PRIME; i++)

                    {
                        object newAlloc = Activator.CreateInstance(m_type);
                        m_sharedPool.Push(newAlloc);

                    }
                }

                object fromLocal = m_sharedPool.Pop();
                return fromLocal;
            }

        }

        public void Free(object obj)
        {
            lock (m_sharedPool)

            {
                m_sharedPool.Push(obj);
            }
        }
    }

    interface ISubPool
    {
        object Allocate();

        void Free(object obj);
    }

כמו תמיד, בנושאים הקשורים ל-Concurrency, אם אין לנו Local'יות, אז יש לנו Sharing, ואם יש לנו Sharing אזי שהסיכוי ל-Contentions גובר, וככל שיש לנו יותר Contentions, נוכל להבחין בפגיעה חמורה יותר בביצועים.
אז אם אנחנו רוצים לשפר את ה-Scalability שלנו, המטרה הברורה היא להוריד כמה שיותר את מידת ה-Sharing שלנו. שהרי אם לא היינו חולקים Pool'ים בין ת'רדים שונים, אזי שכלל לא היו לנו Contentions. דרך פשוטה להשיג את הבידוד הזה היא על ידי הקצאת ה-Pool'ים השונים ב-TLS. בצורה כזאת מצד אחד נרוויח Scalability מושלם משום שאין לנו שיתוף כלשהו בין ת'רדים שונים. מצד שני, ה-Tradeoff של צורת העבודה הזאת יכול להתבטא בגידול משמעותי של ניצול הזכרון בתוכנית שלנו. כך שבמקום שיהיה לנו למשל Pool בודד שיתפוס 10MB זכרון, פתאום על מכונה של 16 מעבדים נוכל למצוא אותנו מקדישים לא פחות מ-160MB אך ורק לטובת ה-Pool'ים הלוקאלים, שלא בטוח שכל ת'רד באמת משתמש בכל האובייקטים הזמינים לו שם.
אם למשל אנחנו מריצים בצורה מקבילית אלגוריתם כלשהו עם 3 ת'רדים, כך שת'רד 1 צריך להשתמש לרוב באובייקט A ות'רד 2 צריך להשתמש לרוב באובייקט B ות'רד 3 צריך להשתמש באובייקט C, אין טעם הרי שלכל אחד משלושת הת'רדים יוקצה Pool שיחזיק גם את A, B ו-C. פתרון אפשרי לבעיה הוא יצירת היררכיה של Pool'ים, כך שבכל פעם שת'רד רוצה להקצות אובייקט כלשהו, הוא קודם כל יגש ל-Pool שהכי "קרוב" אליו, במידה והוא לא מוצא שם מופעים זמינים של אותו אובייקט, הוא ימשיך להתקדם במעלה ההיררכיה עד שהוא יגיע ל-Pool שמחזיק מופע זמין של האובייקט המבוקש. ברגע שהוא נמצא, הוא יוחזר ל-Pool שנמצא קרוב יותר בהיררכיה של אותו ת'רד, מאחר והגיוני להניח שאותו ת'רד ירצה להקצות בעתיד עוד מופעים של אותו אובייקט.
במקום להסתבך עם היררכיות עמוקות ולא ברורת יותר מדי, נדגים את הרעיון בעזרת היררכיה שטוחה שמציעה Pool אחד "גלובלי" שמשותף לכל הת'רדים, ועוד Pool פרטי לכל ת'רד באפליקציה.



הרעיון הוא שהמקום היחיד בו קיים Sharing הוא בגישה ל-Shared Pool, כך שבמצב אופטימלי בתוך כל Local Pool יש בדיוק את סוג האובייקטים וכמות האובייקטים שכל ת'רד ספציפי צריך, כך שבדרך כלל לא יהיה צורך לגשת ל-Shared Pool.
בכל פעם שת'רד רוצה להקצות אובייקט, הוא קודם יגש ל-Local Pool שלו. משום שמדובר ב-Pool פרטי לחלוטין, לעולם לא נצטרך לסבול מ-Contentions בשלב הזה. רק במקרה בו נגמרו לנו האובייקטים, נעבור ל-Shared Pool ונעביר ממנו אל ה-Local Pool עוד "X" אובייקטים מעבר לאובייקט הבודד שהת'רד ביקש להקצות. אנחנו מעוניינים לעשות את האופטימיזציה הזאת על מנת לחסוך פניות עתידיות ל-Shared Pool ולחסוך ב-Contentions מיותרים. גם כן, על מנת לשים הגבלה על כמות הזכרון שאנחנו מעוניינים להקצות פר-ת'רד, נוכל להחליט שכל Local Pool יכול להחזיק עד "Y" אובייקטים בלבד. ברגע שחרגנו מהמספר הזה, בכל פעם שת'רד ירצה לשחרר אובייקט, הוא ישחרר אותו לתוך ה-Shared Pool, כך שאם ת'רד אחר ירצה להקצות אובייקט, נוכל למחזר את האובייקט ששוחרר, ולחסוך במקום (שכמובן יכול לעלות לנו ב-Contentions. אבל כאן נכנס לתמונה שלב שיכול הדעת וה-Fine Tuning של המפתח).

כדי לעדכן את הקוד ממקודם כדי שישתמש ב-Semi-Local Pool המדובר, נצטרך רק להחליף את המימוש של ISubPool. דוגמה למימוש פשטני של הרעיון:

    class SemiLocalPool : ISubPool
    {
        private const int SHARED_PRIME = 50;

        private const int LOCAL_PRIME = 20;
        private const int LOCAL_MAX = 1000;


        [ThreadStatic]
        private static Stack<object> t_localPool;


        private Type m_type;
        private Stack<object> m_sharedPool;


        public SemiLocalPool(Type type)
        {
            m_sharedPool = new Stack<object>(SHARED_PRIME);

            m_type = type;

            for (int i = 0; i < SHARED_PRIME; i++)
            {
                object sharedObj = Activator.CreateInstance(m_type);

                m_sharedPool.Push(sharedObj);
            }
        }

        public static void Init()

        {
            t_localPool = new Stack<object>(LOCAL_PRIME);
        }

        public object Allocate()

        {
            // first, try to allocate from the local pool
            if (t_localPool.Count > 0)
            {
                object localObj = t_localPool.Pop();

                return localObj;
            }

            int allocated = 0;

            lock (m_sharedPool)

            {
                // pass objects from shared to local pool
                for (; m_sharedPool.Count > 0 && allocated < LOCAL_PRIME - 1; allocated++)

                {
                    object sharedObj = m_sharedPool.Pop();
                    t_localPool.Push(sharedObj);
                }

                // prime share pool

                if (m_sharedPool.Count == 0)
                {
                    for (int i = 0; i < SHARED_PRIME; i++)

                    {
                        // bad practice: holding the lock while executing external code
                        object sharedObj = Activator.CreateInstance(m_type);

                        m_sharedPool.Push(sharedObj);
                    }
                }
            }

            // if the shared pool didn't contain enough elements, prime the remaining items

            for (; allocated < LOCAL_PRIME - 1; allocated++)
            {
                object newAlloc = Activator.CreateInstance(m_type);

                t_localPool.Push(newAlloc);
            }


            object fromLocal = Activator.CreateInstance(m_type);

            return fromLocal;
        }

        public void Free(object obj)

        {
            // first return to local pool
            if (t_localPool.Count < LOCAL_MAX)
            {
                t_localPool.Push(obj);

                return;
            }

            // only after reaching LOCAL_MAX push back to the shared pool
            lock (m_sharedPool)

            {
                m_sharedPool.Push(obj);
            }
        }
    }
השאלה איזה שיפור אנחנו צפויים לקבל ב-Scalability בעקבות החלפת מימוש ה-Pool תלויה בצורה חד-משמעית לאופן השימוש של הת'רד ב-Pool ובערכים שנתנו לקבועים  LOCAL_PRIME, LOCAL_MAX וכו'.. אם אנחנו מגיעים למצב בו תמיד יש מספיק אובייקטים ב-Pool הלוקאלי, אז הרי שאנחנו נהנים מלוקאליות מלאה. במידה ומדי פעם אנחנו "חורגים" מהערכים שנקבעו, אז הרי שנצטרך לפנות ל-Shared Pool ולפגוע בלוקאליות שלנו.
כך שלצורך ההדגמה, אם נריץ את אותו הבנצ'מרק ממקודם עם המימוש הזה של ה-Semi-Local Pool (מעבר לחריגות בתחילת הריצה לצורך ה-Prime, נעשה שימוש אופטימלי ב-Local Pool), נוכל לראות את ההבדלים ה-Scalability בין המימוש הקודם:



מאפיין אחד של הפתרון הזה הוא השימוש ב-Thread Affinity. שבעבור שימושים מסויימים יכול לתרום לניצול הזכרון שלנו, ובמקרים אחרים יכול להוציא את כל הטעם מהשימוש ב-Semi-Local Pool.
אם לכל ת'רד באפליקציה שלנו יש שיוך לביצוע של קטע קוד מסויים (שגורם להקצאה של אובייקטים מסויימים), אזי שהשימוש בפתרון הנ"ל יהיה אופטימלי מאחר ואנחנו משייכים כל Local Pool לת'רד דוט-נטי. אנחנו למעשה מניחים שהת'רד המסויים הזה תמיד ינסה להקצות אובייקטים מקבוצה מסויימת. אבל, אם אנחנו מנהלים את הת'רדים שלנו בצורה כזאת בה אותו ת'רד יכול לבצע משימות שונות ברחבי האפליקציה (לא קיים שיוך בין סוג העבודה לבין הת'רד שמבצע אותו) אז הרי שה-Local Pool יתפח לבסוף להכיל את כל סוגי האובייקטים הקיימים, מה שיכול להוביל לחתימת זכרון גבוהה. כדי לשפר את ההתמודדות שלנו עם מצבים כאלה, אנחנו יכולים להחליט על להוסיף סוג של היררכיה נוספת, שתפריד בין "MainPools" על פי אזורים שונים בקוד. כלומר, ת'רדים שמבצעים קוד שקשור לטיפול בהודעות המגיעות משכבת התקשורת יפנו ל-Pool X בעוד שת'רדים שבדיוק מריצים אלגוריתם כלשהו יפנו דווקא ל-Pool Y. בצורה הזאת אנחנו מנסים לבנות היררכיה מסויימת וליצור לוקאליות שמבוססת לאו דווקא על Thread Affinity אלא על "Category Affinity". כל ת'רד שניגש ל-Pool מציין מאיזה איזור בקוד הוא מגיע, כך שהוא יקבל את אותו ה-Pool שת'רדים אחרים (שהריצו את אותו קוד) השתמשו בו בעבר (כך שאפשר להניח שכבר קיימים בו האובייקטים הספציפים שאותו ת'רד יצטרך להקצות גם כן).



ולבסוף מעט קוד כדי להמחיש את הרעיון:


    public class CategorizedMainPool

    {
        private Dictionary<stringSimpleMainPool> m_main;


        public CategorizedMainPool(Tuple<stringType[]>[] pooledCategories)

        {
            m_main = new Dictionary<stringSimpleMainPool>();


            foreach (Tuple<stringType[]> curCategory in pooledCategories)

            {
                SimpleMainPool curSub = new SimpleMainPool(curCategory.Item2);

                m_main.Add(curCategory.Item1, curSub);
            }
        }

        public object Allocate(string category, Type type)

        {
            SimpleMainPool sub = m_main[category];

            object pooledObj = sub.Allocate(type);
            return pooledObj;

        }

        public void Free(string category, object obj)
        {

            SimpleMainPool sub = m_main[catagory];
            sub.Free(obj);
        }
    }

Monitor's Locking Primitive

בדיון שעלה לאחרונה בפורום דוט-נט בתפוז, עלתה השאלה "באיזה פרימיטיב סנכרון ה-CLR משתמש כאשר אנחנו קוראים ל-Monitor.Enter?". האם נוצר Mutex לטובת העניין? אולי Event? ואולי בכלל מדובר בפרימטיב שרץ גם ב-User Mode כגון CriticalSection? מתברר שקיימת איזשהיא אי-בהירות בנושא הזה, כך שהפוסט הזה ידגים איך ניתן לברור את התשובה לשאלה בכלים העומדים לרשותנו.
לפני קרוב לשנה פרסמתי פוסט קצר שתיאר בקווים כללים את האופן בו מנגנון הנעילות של ה-CLR עובד. בקצרה, הרעיון הוא שמתוך כל בלוק Header של אובייקט דוט-נטי אפשר להגיע לאובייקט SyncBlock המקושר אליו, שלמעשה מייצג את יכולות הסנכרון של האובייקט, שה-CLR דואג לחשוף לנו. צריך לזכור שיכולות הסנכרון האלה הן פיצ'ר של ה-CLR, במובן הזה שהן ממומשות בתוך ה-CLR, ולאו דווקא בתוך (או באמצעות) פרימיטיבים השייכים למערכת ההפעלה. כמו ש"על הנייר" ת'רד דוט-נטי לא חייב להתבסס בהכרח על Kernel Thread של מערכת ההפעלה, כך גם אין חובה מצד ה-CLR להשתמש בפרימיטיב סנכרון כלשהו של מערכת ההפעלה. היום זה אינו המצב, ובינתיים גם לא נשמעות שום תוכניות שהמצב הזה הולך להשתנות בעתיד הקרוב או הרחוק.

בסקירה ראשונה בתיעוד ב-MSDN אפשר להתרשם שלא קיים תיעוד אמיתי לגבי הפרימטיב בו נעשה שימוש. אבל מאחר ומדובר באובייקט סנכרון, אפשר להזכר בממשק IHostSyncManager שנחשף לנו דרך יכולות ה-Hosting של ה-CLR. אחת מהפונקציונליות שהממשק הזה חושף לנו היא היכולת להחליף את המימוש של פרימיטיב הסנכרון בו נעשה שימוש בתוך Monitor.Enter. הפונקציונליות הזאת מתאפשרת לנו דרך הפונקציה CreateMonitorEvent.
כבר בשלב הזה אפשר לשים לב למה שנאמר תחת פסקת ה-Remarks:

CreateMonitorEvent returns an IHostAutoEvent  that the CLR uses in its implementation of the managed System.Threading.Monitor type. This method mirrors the Win32 CreateEvent function, with a value of false specified for the bManualReset parameter.

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

static void Main()
{
      Thread t1 = new Thread(() => { lock ("A") { while (true);} });
      t1.Start();

      lock ("A") { while (true);}
}

אחרי שהתוכנית כבר רצה ברקע, נוכל להריץ את WinDbg ולעשות Attache לפרוסס המתאים. כמו תמיד, הדבר הראשון שאנחנו רוצים לעשות הוא לטעון את SOS שיתן לנו כמה עזרים לדבג את התוכנית הדוט-נטית שלנו. עד גרסה 4 של הפריימוורק היינו רגילים לרשום את הפקודה הבאה:

>.loadby sos mscorwks

שלמעשה אומרת שאנחנו רוצים לטעון את sos.dll מהכתובת ממנה נטען mscorwks.dll (למעשה, אנחנו חוסכים חיפוש והקלדה של הנתיב המלא של הקובץ). אבל, כחלק מהשינויים שהפריימוורק עברה בגרסה 4, השם של הקובץ mscorwks עודכן ל-clr. כך שאם ננסה להשתמש באותה פקודה אלמותית, נקבל את השגיאה "Unable to find module mscorwks" מהסיבה הפשוטה שה-dll המדובר כלל לא נטען לפרוסס שלנו. לכן נצטרך לעדכן את הפקודה כך שנשתמש ב-clr.dll. לאחר ההרצה שלה, נוכל לוודא שטענו את ה-dll המתאים על ידי קריאה ל-chain.

>.loadby sos clr
>.chain

Extension DLL search Path:
  [...]
Extension DLL chain:
    C:\WINDOWS\Microsoft.NET\Framework\v4.0.30319\sos: image 4.0.30319.1, API 1.0.0, built Thu Mar 18 10:09:41 2010
        [path: C:\WINDOWS\Microsoft.NET\Framework\v4.0.30319\sos.dll]
    dbghelp: image 6.11.0001.404, API 6.1.6, built Thu Feb 26 03:55:30 2009
        [path: C:\Program Files\Debugging Tools for Windows (x86)\dbghelp.dll]
    ext: image 6.11.0001.404, API 1.0.0, built Thu Feb 26 03:55:30 2009
        [path: C:\Program Files\Debugging Tools for Windows (x86)\winext\ext.dll]
    exts: image 6.11.0001.404, API 1.0.0, built Thu Feb 26 03:55:24 2009
        [path: C:\Program Files\Debugging Tools for Windows (x86)\WINXP\exts.dll]
    uext: image 6.11.0001.404, API 1.0.0, built Thu Feb 26 03:55:26 2009
        [path: C:\Program Files\Debugging Tools for Windows (x86)\winext\uext.dll]
    ntsdexts: image 6.1.7015.0, API 1.0.0, built Thu Feb 26 03:54:43 2009
        [path: C:\Program Files\Debugging Tools for Windows (x86)\WINXP\ntsdexts.dll]


השלב הבא יהיה להגיע לת'רד שהפסיד במרוץ לנעילה, וכעת נאלץ להמתין עד שהיא תשתחרר. לשם כך נדפיס את ה-Managed Stack של כל הת'רדים שלנו, וכשנראה Stack "מתאים", נעבור לקונטקסט שלו:

>~*e!clrstack // execute !clrstack on all of the threads
OS Thread Id: 0xf80 (0)
Child SP IP       Call Site
0012f3ec 030300fe ConsoleApplication1.Program.Main(System.String[]) [...]
0012f648 791421db [GCFrame: 0012f648]
OS Thread Id: 0xf4c (1)
Unable to walk the managed stack. The current thread is likely not a
managed thread. You can run !threads to get a list of managed threads in
the process
OS Thread Id: 0x840 (2)
Child SP IP       Call Site
02ccfe68 7c90e514 [DebuggerU2MCatchHandlerFrame: 02ccfe68]
OS Thread Id: 0xbe0 (3)
Child SP IP       Call Site
0313f67c 7c90e514 [GCFrame: 0313f67c]
0313f794 7c90e514 [GCFrame: 0313f794]
0313f7b0 7c90e514 [HelperMethodFrame_1OBJ: 0313f7b0] System.Threading.Monitor.ReliableEnter(System.Object, Boolean ByRef)
0313f808 79b2e0c4 System.Threading.Monitor. Enter(System.Object, Boolean ByRef)
0313f818 03030163 ConsoleApplication1.Program.< Main>b__3() [...]
0313f848 79b2ae5b System.Threading.ThreadHelper.ThreadStart_Context(System.Object)
0313f858 79ab7ff4 System.Threading. ExecutionContext.Run(System.Threading.ExecutionContext, System.Threading.ContextCallback, System.Object, Boolean)
0313f87c 79ab7f34 System.Threading. ExecutionContext.Run(System.Threading.ExecutionContext, System.Threading.ContextCallback, System.Object)
0313f898 79b2ade8 System.Threading.ThreadHelper. ThreadStart()
0313fabc 791421db [GCFrame: 0313fabc]
0313fd80 791421db [DebuggerU2MCatchHandlerFrame: 0313fd80]
OS Thread Id: 0xe60 (4)
Unable to walk the managed stack. The current thread is likely not a
managed thread. You can run !threads to get a list of managed threads in
the process

>~3s // change the current thread to 3

אחרי שמצאנו את הת'רד המתאים, נצטרך לראות את ה-Native Stack שלו, כך שנשתמש כבר בפקודה kb שתציג לנו גם את 3 הפרמטרים הראשונים המועברים לכל פונקציה:

>kb
ChildEBP RetAddr  Args to Child             
0313f3c8 7c90df4a 7c809590 00000001 0313f3f4 ntdll!KiFastSystemCallRet
0313f3cc 7c809590 00000001 0313f3f4 00000001 ntdll!ZwWaitForMultipleObjects+0xc
0313f468 791f516a 00000001 001820bc 00000000 KERNEL32!WaitForMultipleObjectsEx+0x12c
0313f4cc 791f4f98 00000001 001820bc 00000000 clr!WaitForMultipleObjectsEx_SO_TOLERANT+0x56
0313f4ec 791f4dd8 00000001 001820bc 00000000 clr!Thread::DoAppropriateAptStateWait+0x4d
0313f580 791f4e99 00000001 001820bc 00000000 clr!Thread::DoAppropriateWaitWorker+0x17d
0313f5ec 791f4f17 00000001 001820bc 00000000 clr!Thread::DoAppropriateWait+0x60
0313f640 7919d409 ffffffff 00000001 00000000 clr!CLREvent::WaitEx+0x106
0313f654 792e0160 ffffffff 00000001 00000000 clr!CLREvent::Wait+0x19
0313f6e4 792e0256 001818a0 ffffffff 8079c412 clr!AwareLock::EnterEpilogHelper+0xa8
0313f724 792e029b 001818a0 001818a0 79142c0d clr!AwareLock::EnterEpilog+0x42
0313f744 792c7729 8079cb36 0313f830 00b3c368 clr!AwareLock::Enter+0x5f
0313f800 79b2e0c4 79161f8e 00941f02 0313f840 clr!JIT_MonReliableEnter_Portable+0x104
0313f840 79b2ae5b 00b3c3ec 01b3101c 0313f86c mscorlib_ni+0x2ae0c4
0313f850 79ab7ff4 00b3e010 00000000 00b3c3b8 mscorlib_ni+0x2aae5b
0313f86c 79ab7f34 00000000 00b3c3b8 00000000 mscorlib_ni+0x237ff4
0313f88c 79b2ade8 00b3c3b8 00000000 001818a0 mscorlib_ni+0x237f34
0313f8a4 791421db 000001a7 0313fae0 0313f930 mscorlib_ni+0x2aade8
0313f8b4 79164a2a 0313f980 00000000 0313f950 clr!CallDescrWorker+0x33
0313f930 79164bcc 0313f980 00000000 0313f950 clr!CallDescrWorkerWithHandler+0x8e

בשלב הזה אנחנו כבר יכולים לראות שהדבר האחרון שהת'רד המדובר הספיק לעשות לפני שהפרענו לו הוא לקרוא ל-WaitForMultipleObjectsEx כאשר הפרמטר הראשון 1 והשני הוא 0x001820BC. כך שאפשר להבין שאנחנו למעשה מחכים על Handle בודד, מאחר והפרמטר הראשון מציין את מספר ה-Handle'ים במערך שהועבר בתור הפרמטר השני. אז כל מה שנותר לנו לעשות עכשיו הוא רק להבין מה מסתתר מאחורי אותו Handle שהועבר לפונקציה, מה שיפתור למעשה את תעלומת פרימיטיב הסנכרון שלנו.

>dp 0x001820BC 0x001820BC
001820bc  000006c8 // our handle's value

>!handle 000006c8 F // pass "F" as bitmask to display all of the relevant data
Handle 6c8
  Type             Event
  Attributes       0
  GrantedAccess    0x1f0003:
         Delete,ReadControl,WriteDac,WriteOwner,Synch
         QueryState,ModifyState
  HandleCount      2
  PointerCount     4
  Name             <none>
  Object Specific Information
    Event Type Auto Reset
    Event is Waiting

אם כן, זוהי פיסת האימות שחיפשנו. אישור שהפרימיטיב בו אנחנו עושים שימוש הוא למעשה Event מסוג AutoReset.
מי שמעוניין לראות את השימוש והיצירה של ה-Event בקוד, יכול לקחת בתור רפרנס את הקוד ב-sync.cpp מתוך ה-SSCLI, ולראות כיצד קריאה ל-CLREvent::CreateMonitorEvent גוררת קריאה ל-UnsafeCreateEvent (שלמעשה מהווה typedef ל-CreateEvent המוכר).

עם זאת, צריך לזכור שמדובר בתשובה חלקית בלבד. כפי שהזכרתי בתחילת הפוסט, אין כלל הבטחה שכאשר נקרא ל-Monitor.Enter בכלל נגיע למצב בו נשתמש באובייקט קרנל כלשהו. למעשה, בפוסט הזה דאפי דואג לציין שבמימוש ה-CLR'י, כשת'רדים נתקלים ב-Contention הם ינסו קודם לרוץ קצת ב-Busy Loop בתקווה שהנעילה תשתחרר לפני שהם יתיאשו וילכו להמתין על ה-Event שנמצא ב-SyncBlock (למעשה, התנהגות דומה לזו של אובייקטי CriticalSection למעשה יתכן מצב בו עבור SyncBlock'ים שלא מגיעים אף פעם ל-Contention'ים לחסוך לגמרי את העבודה עם אובייקט הקרנל, ולמעשה לבצע את הנעילה כולה ב-User Mode. כך שגם אם ה-CLR לא נותן מימוש מלא ומאפס של פרימטיב סנכרון משלו, עדיין אין סיבה שהוא לא יוסיף יכולות ואופטימיזציות משלו על גבי היכולות שמספקת מערכת ההפעלה.

Accurately Measuring GC Suspensions

כשבאים לנתח ביצועים של אפליקציית Managed, ורוצים להבין מה הם צווארי הבקבוק שעשויים להכשיל אותה בתרחישי עומס איתם היא אמורה להתמודד, אחד הפרמטרים החשובים שצריך לשים לב אליהם הוא מידת הזמן שהאפליקציה שלנו מבלה ב-GC. בשביל לדעת את זה, תמיד אפשר להריץ במקביל לאפליקציה את Perfmon ולקבל ניתוח כללי של התנהגות ה-GC באפליקציה שלנו, כשהמדד העיקרי שנסתכל עליו בדרך כלל הוא אחוז ה-Time in GC.
התבוננת בנתונים האלה יכולים אמנם לתת לנו "מבט כללי" על השפעת ה-GC על ביצועי האפליקציה, אבל הוא פשוט לא מספיק בשביל לקבל תובנות מעמיקות יותר ולקשר תקיעות שנצפות בפרוסס ל-Collection'ים שיתכן והופעלו באותו הזמן. למשל, גם אם נראה שב-90% מהזמן אנחנו מבלים רק 2% ב-GC, זה עדיין לא אומר שלא יתכן מצב בו ברגע קריטי במערכת פתאום הוחלט להריץ Gen2 שתקע לנו את כל הפרוסס למשך של כ-200 מילישניות. מה שעשוי לגרום לנזק לא מבוטל אצל אפליקציות הרגישות למדי לתקיעות בלתי צפויות שכאלה. כל הרעיון הוא שה-GC לא גורם לתהליכים לעבוד יותר לאט, אלא הוא פשוט יכול לתקוע את כל הפרוסס לחלוטין. לכן הסתכלות נאיבית על גרף ה-Time in GC לא באמת אומרת לנו בצורה מובהקת מה הנזק האמיתי של כל GC במקרה הפרטי שלנו. אם נסתכל על הגרף, יתכן שנראה איזשהיא קפיצה "באזור הזמן" של התקיעה, אבל מאחר והרזולוציה הגבוהה ביותר של Perfmon עומדת על שנייה אחת, ואנחנו לא באמת יודעים באמת כמה זמן כל GC באזור השניה ההיא לקח, אין לנו דרך לקשר בצורה ישירה וברורה בין התקיעה "הכללית" שנצפתה בפרוסס, לבין Collection מסויים שיתכן וקרה או לא קרה באותו הזמן. מה גם, שלא תמיד פשוט לדאוג שבכל מקום שמריצים את האפליקציה ידאגו גם להריץ Collector'ים של Perfmon שידאגו לאסוף את הנתונים האלה בכל רגע נתון.

לכן, אנחנו זקוקים לדרך אמינה שתאפשר לנו למדוד בצורה מדוייקת מתי ה-CLR מחליט לבצע Collection'ים, באיזה Generation'ים מדובר, וכמה זמן כל אחד מהם לוקח.
כיום מחלקת ה-GC לא חושפת לנו דרך לקבל את התונים האלה. אבל על ידי כתיבת CLR Host משלנו, נוכל להתממשק ל-CLR ולקבל ממנו את ההתראות המתאימות שיאמרו לנו מתי מתחיל ונגמר כל GC בפרוסס.
כדי לעשות זאת, נצטרך לממש את IHostGCManager ולהעזר בשניים מתוך שלושת Callback'ים שהוא מציע. הראשון הוא SuspensionStarting שלא מקבל פרמטרים כלשהם, והשני הוא SuspensionEnding שמקבל פרמטר DWORD בודד שמכיל את מספר ה-Genration של ה-Collection שהסתיים. כמו שאפשר לנחש, ה-CLR דואג להפעיל את SuspensionStarting לפני שהוא מפעיל את ה-Collection, ולאחריו, הוא דואג להפעיל את SuspensionEnding.
דבר אחד שצריך לשים לב אליו לגבי SuspensionEnding, הוא שהתיעוד ב-MSDN לא מדוייק ויכול להטעות משתמשים לגבי הטריגרים האפשריים להפעלה שלו. נכון להיום, זה מה שהתיעוד אומר:

"...Notifies the host that the common language runtime (CLR) is resuming execution of tasks on threads that had been suspended for a garbage collection.
[parameter: generation] The garbage collection generation that is just finishing, from which the thread is resuming."


כלומר, התיעוד מספר שה-Callback יופעל אך ורק כתוצאה מקפיאות הנובעות מ-GC. אולם בפועל, זוהי אינה הסיבה היחידה לכך שה-Callback הזה יופעל. למעשה, ה-CLR יפעיל אותה גם לאחר התעוררות מחודשות כתוצאה מהקפאות יזומות אחרות שהוא עשוי לבצע (למשל, טעינה ושחרור של AppDomain'ים). אם נבדוק במימוש של SSCLI היכן ומתי מפעילים את ה-Callback, נוכל להבחין בקטע הקוד הבא:

if (pGCThreadControl)

{

    // If we the suspension was for a GC, tell the host what generation GC.

    DWORD   Generation = (bFinishedGC

        ? GCHeap::GetGCHeap()->GetCondemnedGeneration()

        : ~0U);

 

    pGCThreadControl->SuspensionEnding(Generation);

}


כך שאפשר לראות שעבור כל קפיאה שנגרמה לאו דווקא כתוצאה מ-GC, פרמטר ה-generation שמועבר לפונקציה יכיל את הערך UINT_MAX, כך שיהיה לנו כדאי להתייחס בצורה מתאימה לערך הזה בזמן שאנחנו כותבים את ה-CLR Host שלנו.

באשר למימוש המדידה עצמה, נשתמש ב-QueryPerformanceCounter על מנת לקבל את הדיוק הגבוה ביותר למשך הזמן שלקח ה-GC. מאחר ובדרך כלל רוב ה-Collection'ים שננטר יעצרו את הפרוסס לפרקי זמן קצרים בלבד (מילישניות בודדות), סביר שלא נרצה להשקיע מאמץ בשמירת הנתונים האלה (המנעות מ-IO מיותר). לכן זה יהיה רעיון טוב להתממשק לתשתית Logging ועבור Collection'ים שלוקחים פחות מסף מסויים של זמן, להשתמש ברמת Debug עבור הכתיבות ללוג, בעוד שעבור Collection'ים שחרגו אותו סף זמן, להשתמש ברמת Warn.
עם צירוף של Appender שדואג לזרוק את כל הלוגים ל-Console, והרצת תוכנית שדואגת להקצות זכרון בלי סוף, אנחנו יכולים לקבל פלט בסגנון הזה:



לשם הנוחות, אני מצרף מימוש פשוט של Host לדוגמא שמנטר הקפאות יזומות של ה-CLR ומדפיס ל-Console את משך הזמן שנמדד בכל פעם:

#include <mscoree.h>

#include <windows.h>

#include <assert.h>

#include <iostream>

 

using namespace std;

 

#define APP_STARTUP_EXE L"TestApplication.exe"

#define APP_ENTRY_TYPE L"SomeNamespace.Program"

#define APP_ENTRY_METHOD L"Main"

 

class MyCLRHost : public IHostControl, public IHostGCManager

{

private:

    LONG m_refCount;

    LARGE_INTEGER m_lastGCStart;

    LARGE_INTEGER m_frequency;

 

public:

    MyCLRHost() { QueryPerformanceFrequency(&m_frequency); }

 

    // IHostControl

    HRESULT __stdcall GetHostManager(REFIID riid, void** ppObject)

    {

        if(riid == IID_IHostGCManager)

        {

            *ppObject = static_cast<IHostGCManager*>(this);

            return S_OK;

        }

 

        *ppObject = NULL;

        return E_NOINTERFACE;

    }

 

    // IUnknown

    HRESULT __stdcall QueryInterface(REFIID riid, void** ppvObject)

    {

        if (riid == IID_IHostGCManager)

        {

            *ppvObject = static_cast<IHostGCManager*>(this);

            return S_OK;

        }

 

        *ppvObject = NULL;

        return E_NOINTERFACE;

    }

 

    HRESULT __stdcall SetAppDomainManager(DWORD appDomain, IUnknown* domainManager)

    {

        return S_OK;

    }

 

    ULONG __stdcall AddRef() { return InterlockedIncrement(&m_refCount); }

    ULONG __stdcall Release() { return InterlockedDecrement(&m_refCount); }

 

    // IHostGCManager

    HRESULT __stdcall ThreadIsBlockingForSuspension() { return S_OK; }

 

    HRESULT __stdcall SuspensionStarting()

    {

        m_lastGCStart;

        QueryPerformanceCounter(&m_lastGCStart);

 

        return S_OK;

    }

 

    HRESULT __stdcall SuspensionEnding(DWORD gen)

    {

        LARGE_INTEGER gcEnd;

        QueryPerformanceCounter(&gcEnd);

        double duration = ((gcEnd.QuadPart - m_lastGCStart.QuadPart))

            * 1000.0 / (double)m_frequency.QuadPart;

 

        if(gen != UINT_MAX)

            cout<<"GC generation "<<gen<<" ended: "<<duration<<"ms"<<endl;

        else

            cout<<"CLR suspension ended: "<<duration<<" ms"<<endl;

 

        return S_OK;

    }

};

 

int _tmain(int argc, _TCHAR* argv[])

{

    ICLRRuntimeHost* pCLR;

    DWORD startupFlags = STARTUP_CONCURRENT_GC;

    HRESULT hr = CorBindToRuntimeEx(L"v2.0.50727", L"wks", startupFlags,

        CLSID_CLRRuntimeHost, IID_ICLRRuntimeHost, (LPVOID*)&pCLR);

    assert(SUCCEEDED(hr));

 

    MyCLRHost customHost;

    hr = pCLR->SetHostControl(&customHost);

    assert(SUCCEEDED(hr));

 

    hr = pCLR->Start();

    assert(SUCCEEDED(hr));

 

    DWORD retcode;

    hr = pCLR->ExecuteInDefaultAppDomain(APP_STARTUP_EXE,

        APP_ENTRY_TYPE, APP_ENTRY_METHOD, L"" , &retcode);

    assert(SUCCEEDED(hr));

 

    return 0;

};


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

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.
More Posts Next page »