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