DCSIMG
Code Sample: WorkerResetEvent - Liran Chen's Blog

Liran Chen's Blog

.Net Internals, Debugging, Multithreading - and More!

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 ליצירת אובייקט הקרנל. לקראת שחרור הגרסה, במיקרוסופט דאגו לפרסם מסמך שנותן דוגמה להבדלי הביצועים בין שימוש בפרימטיבים החדשים והישנים תחת תרחישי שימוש נבחרים.

תוכן התגובה

Liran Chen כתב/ה:

Ipe,

1. אני לא רואה איך עבודה עם ThreadPool במקרה הזה תקל עליך, אלא רק להפך. קודם כל יש את העניין של "מתי לשלוח עבודה ל-Pool?", האם צריך לשלוח בקשה ל-pool כל 100ms? אנחנו הרי לא רוצים שיפתחו לנו מאות ת'רדים במקביל שינסו לעשות את אותה עבודה. במקרה הזה אנחנו רוצים שרק ת'רד אחד יבצע את העבודה ברגע נתון, כך שהכי נוח, בטוח, ומהיר יהיה להשתמש בת'רד אחד, שישן עד להודעה אחרת מצד התוכנית.

לא הבנתי איזו בעיה של אי-דטרמיניסטיות יכולה להתעורר כאן, כך שאתה מוזמן לפרט לגבי זה.

2. שימוש ב-volatile לא היה עוזר כאן. בצורה פשוטה, מה ש-volatile עושה לומר לקומפיילר שיתכן ויגשו למשתנה דרך מספר ת'רדים שונים, ולכן, אסור לו לעשות כל מיני אופטימיזציות בשימושים בו שיכולים לגרום להסתרה של הערך שלו מת'רדים שקוראים/כותבים אליו במקביל. כלומר, שכל ת'רד שניגש אליו יקבל תמיד את הערך הכי עדכני (למרות שגם זה מעט מבלבל כמו שהפוסט הזה של דאפי מסביר: www.bluebytesoftware.com/.../VolatileReadsAndWritesAndTimeliness.aspx )

הסיבה ש-volatile לא היה עוזר כאן היא בגלל השימוש ב-CompareExhange (או כפי שציינתי בפוסט: CAS - Compare and Swap).

מה שהיא בעצם עושה, זה להשוות בין 2 משתנים, אם הערכים שלהם זהים, אז היא מאתחלת את הראשון בערך אחר. וכל זה - נעשה בפעולה אחת אטומית. ללא שימוש ב-Interlocked, היה צורך להשתמש ב-lock על מנת להבטיח את האטומיות של הבדיקה+החלפה הזאת.

תודה

# August 15, 2009 7:53 PM

lpe כתב/ה:

אני לא כ"כ מסכים עם מה שאמרת על volatie. אני מצטט מההגדרה של MSDN:

The system always reads the current value of a volatile object at the point it is requested, even if the previous instruction asked for a value from the same object. Also, the value of the object is written immediately on assignment.

The volatile modifier is usually used for a field that is accessed by multiple threads without using the lock statement to serialize access. Using the volatile modifier ensures that one thread retrieves the most up-to-date value written by another thread.

נשמע כמו בדיוק מה שאתה צריך :)

# August 15, 2009 8:10 PM

Liran Chen כתב/ה:

Ipe,

הציטוט מ-MSDN רק מאשר את מה שאמרתי. שימוש ב-volatile דואג שאתה בכל פעם פונה מחדש למשנה האמיתי (ולא משתמש למשל בכל מיני אופטימיזציות כגון לשמור אותו בתוך register למען גישות מהירות יותר .. מה שיכול לגרום לאי-סנכרון בין ת'רדים.

כלומר, למרות השימוש ב-volatile עדיין יש לך כאן Race Condition בין ההשוואה לכתיבה. השימוש ב-voltaile יפתור לך רק בעיית הכתיבה/קריאה, אבל לא מעבר לזה.

קח לדוגמה את הקוד הבא:

if(X == X)

X++;

else

Console.WriteLine("Whoops");

אם תריץ קוד כזה עם כמה ת'רדים במקביל, יתכן מקרה בו X באמת לא יהיה שווה ל-X, פשוט בגלל ה-race condition שנוצר לך בין ההשוואה לכתיבה. וזה כל העניין.

# August 15, 2009 8:48 PM
שלח תגובה

(שדה חובה)  

(שדה חובה)  

(אופציונלי)

(שדה חובה) 

Please add 8 and 6 and type the answer here:


Enter the numbers above: