DCSIMG
August 2009 - Posts - Liran Chen's Blog

Liran Chen's Blog

.Net Internals, Debugging, Multithreading - and More!

August 2009 - Posts

DateTime.Now Causes Boxing

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

public override DaylightTime GetDaylightChanges(int year)
{

object key = year;

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

}

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

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

while (true)

{

    Stopwatch sw = Stopwatch.StartNew();

 

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

    {

        DateTime now = DateTime.Now;

    }

 

    Console.WriteLine(sw.ElapsedMilliseconds);

}


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



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

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

public static class TimeUtil

{

    [DllImport("kernel32.dll")]

    static extern void GetLocalTime(out SYSTEMTIME time);

 

    [StructLayout(LayoutKind.Sequential)]

    private struct SYSTEMTIME

    {

        public ushort Year;

        public ushort Month;

        public ushort DayOfWeek;

        public ushort Day;

        public ushort Hour;

        public ushort Minute;

        public ushort Second;

        public ushort Milliseconds;

    }

 

    public static DateTime LocalTime

    {

        get

        {

            SYSTEMTIME nativeTime;

            GetLocalTime(out nativeTime);

 

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

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

                    nativeTime.Milliseconds, DateTimeKind.Local);

        }

    }

}


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

Macro For Automatically Freezing Threads

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

Regions From Hell

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



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

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

Code Sample: WorkerResetEvent

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

AutoResetEvent m_event = new AutoResetEvent(false);

 

private void WorkCycles()

{

    while(true)

    {

        // wait for a signal

        m_event.WaitOne();

 

        // do work..

    }

}


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

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

while (true)

{

    AutoResetEvent eventObj = new AutoResetEvent(false);

 

    Stopwatch sw = Stopwatch.StartNew();

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

    {

        eventObj.Set();

 

        eventObj.WaitOne();

    }

 

    Console.WriteLine(sw.ElapsedMilliseconds);

}


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

WorkerResetEvent

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

public class WorkerResetEvent

{

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

    // merely effect performance

 

    private volatile int m_eventState;

    private AutoResetEvent m_event;

 

    private Thread m_worker;

 

    private const int EVENT_SET     = 1;

    private const int EVENT_NOT_SET = 2;

    private const int EVENT_ON_WAIT = 3;

 

    public WorkerResetEvent(bool initialState, Thread workerThread)

    {

        m_event = new AutoResetEvent(initialState);

        m_eventState = initialState ? EVENT_SET : EVENT_NOT_SET;

 

        m_worker = workerThread;

    }

 

    public void WaitForWork()

    {

        verifyCaller();

 

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

            ref m_eventState, EVENT_NOT_SET, EVENT_SET) == EVENT_SET)

        {

            return;

        }

 

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

            ref m_eventState, EVENT_ON_WAIT, EVENT_NOT_SET) == EVENT_NOT_SET)

        {

            m_event.WaitOne();

        }

    }

 

    public void SignalWork()

    {

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

            ref m_eventState, EVENT_SET, EVENT_NOT_SET) == EVENT_NOT_SET)

        {

            return;

        }

 

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

            ref m_eventState, EVENT_NOT_SET, EVENT_ON_WAIT) == EVENT_ON_WAIT)

        {

            m_event.Set();

        }

    }

 

    // [Conditional("DEBUG")]

    private void verifyCaller()

    {

        if (m_worker != Thread.CurrentThread)

        {

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

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

 

            throw new SynchronizationLockException(errMsg);

        }

    }

}


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

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

Why Thread Priorities Are Evil

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

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

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

Wonders With Visible White Space

לפני מספר שנים רכשתי לעצמי הרגל מגונה כשהתחלתי להשתמש בצורה אובססיבית באפשרות ה-Show Visible White Space בתוך עורך הקוד של Visual Studio.
קודם כל, White Space הוא כל אותו "טקסט שקוף" שיש לנו בקוד. אם זה רווחים בין מילים, או מרווחים שנוצרים עקב Indention. עכשיו, איכשהו הגעתי לשלב בו התחלתי להרגיש שכל הקוד, כל הטקסט.. פשוט "צף" לו על המסך. בלי שום סדר או משהו שמעמיד אותו במקום. כלומר, תחשבו שיש לכם מחלקה, ובתוכה פונקציה, ובתוכה לולאה, ואז עוד תנאי ... נוצר לנו כאן עימוד עמוק של הקוד. שורות הקוד שלנו כל הזמן זזות, פעם הן מתקדמות יותר ימינה ככל שאנחנו מעמיקים לתוך העימוד, ופעם אנחנו חוזרים יותר שמאלה, כשאנחנו יוצאים מה-Scope'ים השונים. כל התזוזות האלו גרמו לי לאי נוחות משגעת, או לפחות.. כזה זה כבר היום.
הפתרון שלי היה להתחיל להשתמש White Space שיעזור לכל הקוד "לעמוד במקום", במקום לצוף ללא משמעות.
היום, זאת צורת העבודה היחידה בה אני יכול לקודד. מהרגלים מגונים אחרים כמו לשנות את ערכת הצבעים למשהו שמזכיר יותר מסכים של מחשבי מיינפריים מתפוררים משנות ה-80 כבר הצלחתי להגמל, אבל מזה - עדיין לא.

אולם, עם כל גרסה חדשה של Visual Studio שיוצאת, נערמים הקשיים על כל מי שרוצה לנסות ולהשתמש בפיצ'ר הזה. כנראה שמישהו ברדמונד החליט להעמיד את משתמשי ה-White Space בעדיפות משנית, אחרת אי אפשר להסביר למה בגרסאות האחרונות של VS כל ה-White Space הוחלף אוטומטית מטאבים לרווחים, ועוד יותר, למה בחרו בצבע כל כך בולט ומזעזע כברירת מחדל ל-White Space.
רק תביטו בקטסטרופה הזאת, איך אפשר לעבוד ככה?


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


אה, ואם במקרה שכחתי להגיד, אז כדי לאפשר את הפיצ'ר הקטן הזה, צריך רק להקליק Ctrl-R + Ctrl-W.
אז זהו, בזה סיימתי לחלוק עוד חלק קטן מהטירוף שלי עם שאר העולם..