DCSIMG
June 2010 - Posts - Liran Chen's Blog

Liran Chen's Blog

.Net Internals, Debugging, Multithreading - and More!

June 2010 - Posts

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);
        }
    }