שימוש ב-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<Type, ISubPool> m_main;
// to make things simpler, the dictionary isn't modified
// after the first initialization
public SimpleMainPool(Type[] pooledTypes)
{
m_main = new Dictionary<Type, ISubPool>();
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<string, SimpleMainPool> m_main;
public CategorizedMainPool(Tuple<string, Type[]>[] pooledCategories)
{
m_main = new Dictionary<string, SimpleMainPool>();
foreach (Tuple<string, Type[]> 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);
}
}