כפי שכולנו מכירים, #C דורשת שכל המשתנים הלוקאלים יאותחלו לפני השימוש
בהם.
עם
זאת, למי שיצא להעזר ב-ildasm בשביל להציץ לתוך קוד ה-IL שהקומפיילר
מייצר, בוודאי שם לב שמייד לאחר ההכרזה על שם הפונקציה, מתווספת
שורה בסגנון הבא:
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
// Code size 10 (0xa)
.maxstack 1
.locals init ([0] int32 x) <--- localsinit flag
IL_0000: ldc.i4.4
IL_0001: stloc.0
IL_0002: ldloc.0
IL_0003: call void [mscorlib]System.Console::WriteLine(int32)
}
השורה
הזאת מייצגת את הימצאות הדגל CorILMethod_InitLocals ב-Header של הפונקציה
שאנחנו נמצאים בה. הדגל הזה למעשה מבטיח שה-CLR יאתחל את כל המשתנים
הלוקאלים הנמצאים בפונקציה לערכי ברירת המחדל שלהם. כלומר, לא משנה איזה
ערך דאגתם לתת למשתנה הלוקאלי שלכם (במקרה הזה המשתנה x מקבל את הערך 4),
הסביבה תוודא שלפני שהקוד יתבצע, המשתנה x בהכרח יהיה מאותחל לערך
חוקי (במקרה הזה, 0).
במימוש המיקרוסופטי של הסטנדרט, הדגל הזה
תמיד קיים ב-Header (בהנחה שבאמת נוצרים משתנים לוקאלים בגוף הפונקציה).
מה שיכול לגרום לנו מעט לתהות למה אם כך הקומפיילר ה-#C'י מכריח אותנו
לאתחל את כל המשתנים הלוקאלים שלנו, אם הקוד שהוא מייצר בעצמו גם ככה
מבטיח שכל המשתנים יאותחלו. האילוץ הזה יוצר רושם כמיותר אבל בפועל קיימות
מספר סיבות שגורמות להמצאות הדגל הזה להיות כמעט הכרחית.
לפני שנבדוק מהי המשמעות מאחורי השימוש בדגל localsinit, נחזור לרגע לשאלת האתחול הכפול.
כאמור,
כפי שניתן להבין מקוד ה-IL שנוצר לנו, נראה שבכל פעם שאנחנו יוצרים משתנה
לוקאלי חדש, נוספת לנו תקורה מיותרת הנובעת מהאתחול הכפול של המשתנה (פעם
אחת על ידי הסביבה, ועוד פעם על ידינו). התקורה הזאת היא אומנם מינורית
לגמרי בהיבט של פגיעה בביצועים, אבל היא בכל זאת יכולה להעביר בנו איזשהו vibe לא טוב בגלל שאם אפשר פשוט להודות: הקוד הזה נראה רע.
אך למעשה,
האתחול הכפול הזה אף פעם לא מתקיים. הסיבה לכך טמונה בצורה בה הדגל
localsinit מבטיח את ערכי ברירת המחדל. כל מה שהוא עושה, זה לדאוג שה-JIT
יחולל קוד שיאתחל את המשתנה לפני השימוש בו. במקרה שלנו, ה-JIT יצטרך
לחולל הוראת mov שתאתחל את x ב-0.
ואכן, קוד האסמבלי שאנחנו מקבלים בזמן ריצה (ללא שימוש באופטימיזציות) מאשר זאת:
Normal JIT generated code
ConsoleApplication4.Program.Main(System.String[])
Begin 00e20070, size 30
00E20070 push ebp
00E20071 mov ebp,esp
00E20073 sub esp,8
00E20076 mov dword ptr [ebp-4],ecx
00E20079 cmp dword ptr ds:[00942E14h],0
00E20080 je 00E20087
00E20082 call 7A0CA6C1 (JitHelp: CORINFO_HELP_DBG_IS_JUST_MY_CODE)
-------------------- Generated code due to the LocalsInit flag ----------------
00E20087 xor edx,edx // zero out the EDX register
00E20089 mov dword ptr [ebp-8],edx // assign the value of EDX to the location of 'X'
--------------------- Our own application's code ---------------------------------
00E2008C mov dword ptr [ebp-8],4 // assign the value 4 to the location of 'X'
00E20093 mov ecx,dword ptr [ebp-8]
00E20096 call 79793E74 (System.Console.WriteLine(Int32), mdToken: 060007c3)
00E2009B nop
00E2009C mov esp,ebp
00E2009E pop ebp
00E2009F ret
אם
כן, בדוגמאת הקוד הזאת ניתן לראות באופן מובהק את ההשפעה שלדגל localsinit
יש על חילול הקוד JIT, ועל הדרך אנחנו נחשפים לאותו אתחול כפול של
המשתנה x.
אולם, צריך לזכור שהקוד הזה חולל ללא אופטימיזציות של ה-JIT.
ברגע שנאפשר את השימוש באופטמיזציות, נראה שה-JIT מזהה את האתחול הראשוני
בתור
dead code
משום שאין לו שום השפעה על התוכנית (והמשתנה הלוקאלי הוא בהכרח לא
volatile). כתוצאה מכך, ה-JIT יהיה מספיק חכם כדי להסיר לגמרי את האתחול
הראשוני, וחולל קוד אך ורק לאתחול האמיתי של התוכנית שלנו.
כך שלאחר שנאפשר את השימוש באופטימיזציות, הקוד המחולל נראה כך:
Normal JIT generated code
ConsoleApplication4.Program.Main(System.String[])
Begin 00c80070, size 19
00c80070 push ebp
00c80071 mov ebp,esp
00c80073 call mscorlib_ni+0x22d2f0 (792ed2f0) (System.Console.get_Out(), mdToken: 06000772)
00c80078 mov ecx,eax
00c8007a mov edx,4 // assign 4 to the "virtual representation" of X
00c8007f mov eax,dword ptr [ecx]
00c80081 call dword ptr [eax+0BCh]
הדבר
הראשון שניתן להבחין בו הוא שעכשיו אין לנו למעשה "משתנה x" בזכרון, אלא
יש לנו במקומו register שמחזיק את ערכו. אבל חשוב מכך, ניתן לראות שכעת
אין בקוד שום זכר לאותו אתחול כפול שראינו מקודם. כך בפועל אנחנו לא
סובלים מתקורה כלשהיא עקב השימוש ב-localsinit.
עכשיו, אפשר לבדוק
מהי למעשה המשמעות מאחורי השימוש ב-localsinit והאילוץ של הקומפיילר
שמכריח את המפתח לאתחל את המשתנים הלוקאלים שלו.
הטיעון של מיקרוסופט
בנוגע לשימוש במנגנון ה-Definite Assignment הוא שרוב הפעמים בהן מתכנתים לא מאתחלים משתנים לוקאלים נובעים מבאגים לוגים, ולא בגלל שהוא בונה על זה שהסביבה תאתחל את הערך
ל-0. באחת התגובות של
Eric Lippert בבלוג שלו, הוא מציין בעצמו:
"The reason we require definite assignment is because failure to
definitely assign a local is probably a bug. We do not want to detect
and then silently ignore your bug! We tell you so that you can fix it."
את החשיבות של הדגל localsinit אפשר לסכם במילה אחת: Verfication.
ורפיקציה
היא התהליך שבו ה-CLR מוודא שכל קוד ה-CIL שקיים בתוכנית הוא "בטוח".
זה כולל וידוא שהפונקציות שאנחנו מפעילים מקבלות בדיוק את מספר הפרמטרים
שהן צריכות לקבל, שהפרמטרים שהן מקבלות הם מהטיפוסים הנכונים, שכל המשתנים
הלוקאלים מאותחלים לפני השימוש ועוד...
במידה וה-CLR מגלה קטע קוד שנכשל בתהליך הורפיקציה, תזרק שגיאת
VerficationException.
צריך לשים לב שלא כל קוד CIL חייב בהכרח להיות Verifiable, כפי שמצויין ב-Partition III של הסטנדרט:
"It
is perfectly acceptable to generate correct CIL code that is not
verifiable, but which is known to be memory safe by the compiler
writer. Thus, correct CIL might not be verifiable, even though the
producing compiler might know that it is memory safe."
עם
זאת, ברגע שאנחנו כותבים קוד שהוא לא Verifiable, אנחנו מוכרחים לשנות את
ההרשאות הניתנות לו בעזרת SecurityPermissionAttribute, ולומר ל-CLR
במפורש לא לבצע בדיקות ורפיקציה על הקוד בעזרת הפרופרטי
SkipVerfication
(ה-CLR לא יבצע בדיקת Definite Assignment על הקוד). אחת הפעמים שבאמת
משתמשים ביכולת הזאת, היא כאשר רוצים לכתוב קוד unsafe בתוכנית. במקרה
כזה, אנחנו צריכים לסמן בהגדרות הפרוייקט באופן מפורש שאנחנו רוצים לתמוך
ב-unsafe code, מלבד שכעת הקומפיילר באמת יאפשר לנו לקמפל את הקוד, הוא גם
יוסיף לאסמבלי המחולל את
UnverifiableCodeAttribute, שידאג לספר ל-CLR שכל המודול הזה הוא לא Verifiable.
תהליך
הורפיקציה דורש שכל משתנה לוקאלי יהיה מאותחל. ליתר דיוק, הוא דורש שבמידה
ולא היתה דרישה לדלג על הורפיקציה, אזי שהדגל localsinit חייב להמצא. לכן
ניתן ברפרנס לפקודות ה-CIL השונות ניתן להתקל בהערות מהסוג הזה:
"Local variables are initialized to 0 before entering the method only if the localsinit on the method is true (see Partition I) ... System.VerificationException is thrown if the the localsinit bit for this method has not been set, and the assembly containing this method has not been granted
System.Security.Permissions.SecurityPermission.SkipVerification
(and the CIL does not perform automatic definite-assignment analysis) "
בשלב מאוחר יותר המסמך גם כן מתייחס לאותה תקורה המתווספת כאשר מבצעים ניתוח של Definite Assignment על הקוד:
"Performance
measurements on C++ implementations (which do not require
definite-assignment analysis) indicate that adding this requirement has
almost no impact, even in highly optimized code. Furthermore, customers
incorrectly attribute bugs to the compiler when this zeroing is not
performed, since such code often fails when small, unrelated changes
are made to the program."
אחת התורות הנסתרות בפיתוח מקבילי היא השאלה "בכמה ת'רדים צריך להשתמש כדי להגיע לניצול מירבי של החומרה העומדת לרשותנו?". יש שיגידו שמספר הת'רדים צריך להיות כמספר המעבדים, או כמספר המעבדים +1, או אולי בכלל פי 2 ממספר המעבדים. הסיבה שיש כל כך הרבה תשובות לשאלה, היא פשוט בגלל הסיבה שעבור כל תרחיש מסויים, תתאים תשובה אחרת (לכל אפליקציה יש אופי שונה, למשל האם היא מוגבלת על ידי ה-CPU או ה-IO?). אבל בכל אופן, תמיד הנוסחאות האלה מתבססות באיזשהיא צורה על מספר המעבדים הזמינים לנו (סך הכל, אנחנו רוצים להיות כמה שיותר Scalable כשזה נוגע להוספת מעבדים).
בדרך כלל כשהאפליקציה מריצה את רוטינת האיתחול שלה ומתכוננת ליצור את רשימת ה-Worker Threads שלה, היא תבדוק כמה מעבדים קיימים על המחשב כדי להגיע לאותו "מספר קסם" ממקודם. כדי לדעת מהמספר המעבדים הקיימים, בדרך כלל פונים ל-Environment.ProcessorCount. מה שהפרופרטי הזה בסך הכל עושה, זה לפנות ל-Environment Variable הנקרא "NUMBER_OF_PROCESSORS", ולהחזיר את הערך שלו בתור מספר.
הבעיה היא שאותו ערך לא משקף כלל את מספר המעבדים שבאמת זמינים ל-Process שלנו. בתסריט מסויים, המשתמש שהפעיל את האפליקציה החליט להעניק לה Affinity כלשהו, שבפועל יגרום לאפליקציה להשתמש רק בחלק זעום ממספר המעבדים הקיימים במחשב (נניח שעל המחשב יש 64 מעבדים, אבל לפרוסס נקבע לרוץ רק על אחד מהם). מה שיקרה בסיטואציה הזאת, היא שהאפליקציה אמנם תיצור ת'רדים כמספר המעבדים הקיימים (64), אבל לא כמספר המעבדים הזמינים לה (1). כך שכל אותם ת'רדים למעשה יחלקו את אותו מספר מצומצם של מעבדים, מה שבאופן בלתי נמנע יוביל לכמות לא מבוטלת של Context Switch'ים שפשוט יהרגו את ביצועי האפליקציה.
התסריט של קביעת Affinity הוא רחוק מלהיות מופרך מאחר ובסיטואציה בה האפליקציה שלנו מנצלת כל הזמן את כל המעבדים העומדים לרשותה, ואנחנו מעונינים להריץ על אותו המחשב, במקביל אליה, אפליקציה אינטנסיבית אחרת, נהיה חייבים לקבוע Affinity מתאים עבור 2 האפליקציות כדי שלא "יפריעו" אחת לשני. אך במידה והאפליקציות מתעלמות מה-Affinity שנקבע להן, אנחנו נמצא את עצמנו שורפים Cycle'ים בלי סיבה.
בדוט-נט אפשר לקבל את ערך ה-Affinity דרך הפרופרטי Process.ProcessorAffinity. מדובר ב-Bitmask בו כל ביט דלוק מייצג מעבד עליו הפרוסס שלנו יכול לרוץ (במידה וכולם כבויים, ה-Scheduler יחליט בעצמו באילו מעבדים להשתמש, כך שלמעשה כל המעבדים זמינים). כברירת מחדל עבור כל מעבד שזמין למערכת ההפעלה, הביט התואם יהיה דלוק. מכאן שמערכות הפעלה של 32 ביט יכולות לפנות ל-32 מעבדים, ואילו מערכות הפעלה של 64 ביט יכולות לפנות ל-64 מעבדים). עם זאת, בגרסאות האחרונות של Windows קיימת תמיכה גם במעל ל-64 מעבדים. כדי לפנות לכל המעבדים הללו משתמשים ב-Groups, כאשר כל Group יכול לפנות לעד 64 מעבדים השייכים לו. כך שאותו Bitmask שמייצג את ה-Affinity, למעשה מייצג את ה-Affinity בתוך ה-Group המיוחס (כברירת מחדל, פרוסס משתמש במעבדים מתוך Group אחד בלבד).
אז בכל אופן, כדי לעבוד בצורה מתחשבת ולתמוך ב-Affinity שנקבע לפרוסס שלנו, אנחנו למעשה צריכים לספור את מספר הביטים הדולקים באותו Bitmask .
לצורך ההדגמה, התוכנית הזאת בודקת כמה מעבדים זמינים לפרוסס, ומדפיסה על אילו אינדקסי מעבדים היא יכולה לרוץ.
static void PrintAffinitizedProcessors()
{
// gets the number of affinitized proccesors in the
// current processor group (up to 64 logical processors)
Process currentProcess = Process.GetCurrentProcess();
long affinityMask = (long)currentProcess.ProcessorAffinity;
if (affinityMask == 0)
affinityMask = (long)Math.Pow(Environment.ProcessorCount, 2) - 1;
const int BITS_IN_BYTE = 8;
int numberOfBits = IntPtr.Size * BITS_IN_BYTE;
int counter = 0;
for (int i = 0; i < numberOfBits; i++)
{
if ((affinityMask >> i & 1) == 1)
{
Console.WriteLine(i);
counter++;
}
}
Console.WriteLine("Total: " + counter);
}
אחד החסרונות/יתרונות של דוט-נט הוא השימוש במנגנון ה-JIT (הלא הוא ה-Just
in Time Compilation). למעשה, התהליך שתפקידו להפוך את ה-CIL לשפת מכונה.
אפשר להסתכל על המנגנון הזה בתור יתרון מאחר ובצורה הזאת התוכנית מקומפלת
על מחשב היעד שבאמת מריץ את התוכנה שלנו. בצורה הזאת, בזמן הקומפילציה
ניתן להשתמש בכל היכולות שמעבד היעד תומך בהן. כלומר, בצורה הזאת אנחנו
יכולים להגיע לקוד יעיל, ומהיר יותר בהשוואה לאם היינו מקמפלים את התוכנית
על מחשב נפרד, בלי לדעת על אילו מחשבים יריצו את התוכנה שלנו (מאחר והיינו
צריכים לקמפל את הקוד למכנה המשותף הנמוך ביותר, ולא היינו יכולים להשתמש
ביכולות ייחודיות של מעבדים שונים).
החסרון הבולט של ה-JIT, הוא הזמן
שאנחנו מבזבזים בעצם ביצוע הקומפילציה הזאת אצל המשתמש. מה שקורה, זה
שעבור כל MethodDesc (מבנה נתונים הקיים עבור כל פונקציה דוט-נטית, ומכיל
מעט Metadata על הפונקציה. נמצא בתוך ה-EE Memory), קיים שדה בוליאני
הנקרא IsJitted. כמו שמשתמע מהשם שלו, הוא למעשה אומר האם ה-JIT כבר הופעל
על אותה הפונקציה (נעזר בשדה הזה בהמשך). כשהקוד שלנו קורא לפונקציה בפעם הראשונה, הוא מופנה דרך stub מיוחד אל ה-JIT שגורם לו לחולל את קוד המכונה המתאים. לאחר מכן, אותו stub נדרס עם פקודת jmp שמביאה אותנו הישר לקוד שחולל על ידי ה-JIT (למעשה, מדובר במנגנון שמאוד מזכיר את יכולת ה-delay load הקיימת ב-CPP), אז ה-JIT נכנס לפעולה ומקמפל את הקוד הנ"ל. כך שאנחנו למעשה
סובלים מאיזשהו Overhead קטן בהרצה הראשונה של הקוד שלנו (זאת הסיבה שכאשר
עושים Benchmark'ים לקוד דוט-נטי, לא מתייחסים להרצה הראשונה של קטע הקוד).
זה
המקום להבהיר שה-Overhead שה-JIT גורם לו הוא בדרך כלל מזערי, מאחר ואנחנו
חווים אותו בהרצה הראשונה של הקוד בלבד, ולאחר מכן נהנה מהביצועים
המשופרים של הקוד שעבר אופטימיזציה למכונת המשתמש. כך שהתרחיש הכנראה יחיד
בו בכל זאת יהיה יכול לעניין אותנו כיצד אפשר להפטר מה-Overhead הזה בכל
זאת, הוא כשמדובר באפליקציות עם ממשק משתמש כלשהו. במקרה הזה, המשתמש עשוי
לקבל את הרושם שהתוכנית שלנו מעט "עצלה" בהרצה הראשונה, כך שבתרחיש הזה יש
לנו איזשהיא מוטיבציה לשפר את הביצועים במובן הזה.
הפתרון הקלאסי לבעיה הזאת, הוא שימוש ב-NGen.
מדובר בכלי המסופק על ידי מיקרוסופט, והרעיון שעומד מאחוריו הוא שבזמן
התקנת התוכנה אצל מחשב הלקוח, אנחנו נריץ את ה-JIT על כל הקוד שלנו ולמעשה
נחולל Native Image עבור האפליקציה. בזמן הטעינה, הסביבה כבר תדאג לטעון
את ה-Image הנכון מהדיסק, ולהמנע מ-Jitting מיותר. הבעיה עם השימוש ב-NGen
הוא שהוא מסורבל למדי, ואם אנחנו באמת מעוניינים לנצל אותו כמו שצריך
ולהמנע מ-Overhead שהוא יכול להוסיף בעצמו, נצטרך כבר באמת להקדיש לא מעט
זמן ומחשבה לעניין (קביעת Base Addresses נכונים, המנעות מ-ReBasing, רישום האסמבלי ב-GAC ועוד...)
אולם, קיימת אלטרנטיבה
לשימוש ב-NGen, והיא לאלץ את ה-JIT לעבוד בזמן הרצת התוכנית. לשיטה הזאת
גם כן לא חסרים חסרונות משלה, ובתמונה הכוללת היא לא בהכרח טובה משימוש
ב-NGen, אבל במידה ואנחנו מעוניינים לזרז את ה-JIT תוך כדי מאמץ מינימלי
מצידנו, זאת בהחלט אפשרות שניתן לשקול (ובכלל, תמיד מעניין לדעת מה באמת
אפשר לעשות בדוט-נט).
אז כדי לקפוץ ישר למים, אני אגש להסבר בצורת
Bottom-Up, כך שקודם נבין איך נוכל לבצע את האילוץ עצמו, ואחר כך איך אפשר
להשתמש ביכולת הזאת באפליקציה קיימת.
אם כן, הצעד הראשון שלנו להפעיל את ה-JIT על פונקציה בודדת. לשם כך נשתמש בפונקציה PrepareMethod. באופן טבעי נעשה בה שימוש כאשר רוצים להפעיל פונקציות וירטואליות מתוך אזורי CER,
אבל מה שמעניין אותנו הוא זה שאותה פונקציה למעשה גורמת ל-JIT לקמפל את
פונקצית היעד שהעברנו לה. פרט נוסף שכדאי להיות מודעים אליו, הוא שקריאה
ל-PrepareMethod יכולה לגרור הפעלה של הבנאי הסטאטי במידה והמחלקה מממשת
אותו.
היות ובמקרה שלנו אנחנו לא מעוניינים לפנות פונקציה ספציפית, אלא
לכל הפונקציות הקיימות בשלל האסמבלים שהתוכנית שלנו משתמשת בהם, נצטרך
לכתוב פונקציית עזר שמקבלת אסמבלי, מחלצת את כל הטיפוסים והפונקציות
שמעניינות אותנו, ואז מפעילה על כל פונקציה שמצאנו את PrepareMethod.
לדוגמה:
public static void PreJITMethods(Assembly assembly)
{
Type[] types = assembly.GetTypes();
foreach (Type curType in types)
{
MethodInfo[] methods = curType.GetMethods(
BindingFlags.DeclaredOnly |
BindingFlags.NonPublic |
BindingFlags.Public |
BindingFlags.Instance |
BindingFlags.Static);
foreach (MethodInfo curMethod in methods)
{
if (curMethod.IsAbstract ||
curMethod.ContainsGenericParameters)
continue;
RuntimeHelpers.PrepareMethod(curMethod.MethodHandle);
}
}
}
פרט חשוב לגבי הפונקציה הזאת, הוא משפט ה-If רק לפני הקריאה
ל-PrepareMethod. החלק הראשון והמובן מאליו הוא שעלינו להתעלם מפונקציות
אבסטרקטיות, מאחר ואין להן מימוש, אין ל-JIT מה לעשות איתן, ולכן ניסיון
להפעיל את ה-JIT עליהן יגרור זריקת שגיאה. החלק השני, הוא ההתעלמות
מפונקציות המקבלות פרמטרים גנריים (Generics). אנחנו לא יכולים לגרום
ל-JIT לקמפל את הפונקציה הגנרית, מאחר ובשלב הזה אנחנו עדיין לא יודעים
איזה טיפוס מועבר לפונקציה, ומאחר וטיפוסים שונים יכולים לגרום לנו לחולל
קוד מעט שונה עבור אותו "שלד" של פונקציה גנרית, אנחנו נמנעים מלקמפל אותה
(למרות שתיאורתית לפחות, אפשר לחקור ולראות מי קורא לה ועם איזה טיפוס.
ברגע שהטיפוס הזה ידוע לנו, נוכל לקמפל אותה בנקודה הזאת. למרות שבמציאות,
אין שום טעם אמיתי בלהתאמץ ובאמת לנהוג כך). למעשה מדובר גם באיזשהו חסרון
מול NGen, שעל הדרך מתמודד גם עם קוד גנרי.
השלב הבא יהיה להטמיע
את הקוד הזה בתוך תוכנית דוגמה. כשאנחנו באים לעשות את זה, עומדות לפנינו
מספר נקודות למחשבה לגבי הצורה בה אנו מעונינים להפעיל את התהליך הזה. האם
אנחנו רוצים לבצע אותו פעם אחת, בעליית האפליקציה? או אולי בכל פעם שנטען
אסמבלי חדש? האם אנחנו רוצים שהפעולה תהיה סינכרונית, או שולי נעדיף שתרוץ
במקביל להרצה הרגילה, בת'רד נפרד (שיתכן
ונעדיף להעניק לו עדיפות נמוכה מאשר שאר הת'רדים בהם בהם אנחנו משתמשים),
והאם אנחנו מעוניינים לעשות זאת רק על הקוד שלנו? או גם על הקוד שאנחנו
טוענים מה-BCL? עולות כאן הרבה שאלות, שהתשובה אליהן יכולה להשתנות מתרחיש
לתרחיש.
אני אתייחס לשאלות האלה בעוד רגע, אבל קודם כל, בואו נראה מה עלינו לעשות כדי שכל זה יהיה אפשרי בכלל.
קודם
כל עלינו לזכור שבתור ברירת מחדל, ה-CLR נוהג באופן חסכוני הכל הנוגע
לטעינת אסמבלים. ועל פי הקו המנחה שלו, כל עוד לא השתמשנו בטיפוס הנמצא
באסמבלי נפרד, אין צורך לטעון אותו. כלומר, גם אם התוכנית שלנו משתמשת
ב-100 אסמבלים נוספים ל-exe הראשי, אם נקרא ל-GetAssemblies
ברגע שהתוכנית הופעלה, נגלה שאף אחד מאותם 100 אסמבלים בכלל לא נטענו
עדיין ל-AppDomain, מאותה הסיבה בדיוק. לכן, אם אנחנו רוצים להפעיל את
ה-JIT על כל האסמבלים שעומדים להטען בעתיד, אנחנו נצטרך לטעון אותם באופן
מפורש. נוכל לעשות זאת על ידי קריאה באופן רקורסיבי ל-GetReferencedAssemblies ו-Load. לדוגמה:
// recursively load all of assemblies referenced by the given assembly
public static void ForceLoadAll(Assembly assembly)
{
ForceLoadAll(assembly, new HashSet<Assembly>());
}
private static void ForceLoadAll(Assembly assembly,
HashSet<Assembly> loadedAssmblies)
{
bool alreadyLoaded = !loadedAssmblies.Add(assembly);
if (alreadyLoaded)
return;
AssemblyName[] refrencedAssemblies =
assembly.GetReferencedAssemblies();
foreach (AssemblyName curAssemblyName in refrencedAssemblies)
{
Assembly nextAssembly = Assembly.Load(curAssemblyName);
if (nextAssembly.GlobalAssemblyCache)
continue;
ForceLoadAll(nextAssembly, loadedAssmblies);
}
}
אפשר לשים לב שבדוגמה הזאת, הפונקציה מסננת אסמבלים הנמצאים
ב-GAC, כך שהיא תתעלם באופן אוטומטי מאסמבלים השייכים ל-BCL למשל (וע"י כך
חסכון משמעותית בגודל ה-Working Set).
כמו שאפשר להבין, את הפונקציה הזאת (כמו דוגמת הקוד הקודמת) אפשר לכוון
ולערוך למטרות ספציפיות. יתכן ותרצו לטעון אסמבלים מסויימים, ולהתעלם
מאחרים. בכל אופן, מה שלא תרצו לעשות, זה המקום לעשות זאת.
חשוב לשים
לב שקריאה לפונקציה הזאת תגרום לטעינת כל ה-Statically Referenced
Assemblies. כלומר, רק במידה והקוד שלכם מתייחס לטיפוסים מאותו אסמבלי,
תוכלו לטעון אותו בצורה הזאת. אולם, קיימת אפשרות שתרצו לטעון חלק
מהאסמבלים שלכם בצורה דינאמית (על ידי שימוש ב-Reflection). במידה ותרצו
להיות מודעים לכל טעינת אסמבלי שקוראת אצלכם בתוכנית (ולהגיב בהפעלת ה-JIT
על אותו אסמבלי למשל), תוכלו לעשות זאת דרך רישום לאירוע AssemblyLoad שיופעל בזמן טעינת אסמבלים חדשים.
אם אתם מתעניינים לגבי איך ניתן לדעת בוודאות שה-JIT באמת הופעל ועשה את
העבודה על הטיפוסים שהעברנו לו, אז אפשר לבדוק את הפרט הזה די בקלות עם SOS.
כל מה שתצטרכו זה להגיע ל-MethodDesc של הפונקציה אותה אתם מעוניינים
לחקור, ולראות מה הערך של השדה IsJitted. הנה דוגמה טיפוסית לאיך אפשר
להגיע לנתון הזה:
> !name2ee OtherAssmebly.dll C.ClassC
Module: 00a457b8 (OtherAssmebly.dll)
Token: 0x02000002
MethodTable: 00a48bbc
EEClass: 00f283d4
Name: C.ClassC
> !dumpmt -md 00a48bbc
EEClass: 00f283d4
Module: 00a457b8
Name: C.ClassC
mdToken: 02000002 (C:\Documents and Settings\Liran\My Documents\VisualStudio2008\Projects\ConsoleApplication1\ConsoleApplication1\bin\Debug\OtherAssmebly.dll)
BaseSize: 0xc
ComponentSize: 0x0
Number of IFaces in IFaceMap: 0
Slots in VTable: 6
--------------------------------------
MethodDesc Table
Entry MethodDesc JIT Name
00a4c838 00a48bb0 NONE C.ClassC..ctor()
00a4c830 00a48ba0 JIT C.ClassC.FooMethod()
> !dumpmd 00a48ba0
Method Name: C.ClassC.FooMethod()
Class: 00f283d4
MethodTable: 00a48bbc
mdToken: 06000001
Module: 00a457b8
IsJitted: yes
CodeAddr: 00f50778
אם יש משהו אחד בסיסי שכולם יודעים על פיתוח תוכנה, הוא שלא ניתן ליצור תלות מעגלית בין פרוייקטים (DLL'ים).
אם
למשל יש לנו פרוייקט A, שפונה לפרוייקט B, אז לא יהיה ניתן שאותו פרוייקט
B יפנה בחזרה לפרוייקט A בתור רפרנס. אם היינו עושים דבר כזה, היתה נוצרת
לנו תלות מעגלית בין שני הפרוייקטים. וזה רע, בגלל שכשהקומפיילר ירצה
לקמפל את A, הוא יראה שהוא תלוי ב-B, אז הוא יגש ל-B וינסה לקמפל אותו.
אבל אז הוא יגלה ש-B למעשה תלוי ב-A, ואז הוא יחזור חלילה עד אין קץ..
האמנם?
אם יצא לכם לפשפש מספיק במבנה הספריות של ה-BCL, יתכן ושמתם לב לכל מיני תלויות לא הגיוניות, אם לא "בלתי אפשריות". קחו למשל את המקרה הזה:
- Xml - תלוי ב-System
- Configuration - תלוי ב-System
- System - תלוי ב-Xml ו-Configuration
אכן, תמונות קשות. ברגע אחד, נראה שכל ההבנה שלנו על מה שאפשר, או יותר נכון: אי אפשר
לעשות עם תלויות בין DLL'ים, התנפצה לרסיסים. אבל מה לעשות, זה המצב,
ולמרות שאנחנו נמצאים ב-State of mind שמה שראינו לפני רגע לא יכול
להתקיים ... נראה שהוא מתקיים בכל זאת. והכל בחסות ה-BCL.
אז אחרי ששיפשפנו את העיניים, ושטפנו את הפנים במעט המים, הגיע הזמן להבין איך המצב הזה באמת מתאפשר בפועל.
נחזור לדוגמה איתה פתחתי את הפוסט. לצורך העניין נאמר שיש לנו שני DLL'ים,
A ו-B. בתוך A, יש לנו את המחלקות ClassA, ClassB. לעומת זאת, בתוך B, יש
לנו את המחלקה ClassC. עכשיו, אנחנו רוצים ש-ClassB תירש מ-ClassC בעוד
שזאתי תירש מ-ClassA. כלומר, נוצרת לנו כאן תלות מעגלית קלאסית.
כדי לאפשר את התלות הזאת, נצטרך לעזוב לרגע את Visual Studio, ולעבור
ל-Command Prompt כדי שנוכל לעבוד ישירות מול הקומפיילר (ברמת העקרון ניתן
לעשות זאת גם דרך VS, רק שזה הופך את התהליך להרבה יותר מסורבל).
הרעיון הוא שנבצע את הקומפילציה בשני שלבים. בשלב הראשון נקמפל את
A, ללא החלקים שתלויים ב-B. כלומר, רק את ClassA. לאחר מכן, נקמפל את B
כרגיל (הוא יוכל להשתמש ברפרנס ל-A בגלל שהחלקים שמעניינים אותו כבר
קומפלו בצעד הקודם). הצעד האחרון, הוא לקמפל מחדש את A. הפעם את כל הקוד,
גם זה שתלוי ב-B (שימו לב ש-B הוא כבר DLL מקומפל ומלא לכל דבר).
דרך ה-Command Prompt, זה נראה כך:
>csc /target:library /out:A.dll ClassA.cs // compile a "thin" version of A
>csc /target:library /reference:A.dll /out:B.dll ClassC.cs // compile full B
>csc /target:library /reference:B.dll /out:A.dll ClassA.cs ClassB.cs // compile full A
בתוצאה הסופית, קיבלנו בדיוק מה שרצינו. יש לנו כעת שני DLL'ים, A ו-B.
ובניגוד לכל מה שהגיוני בעולם, קיימת ביניהם תלות מעגלית אחת ונפלאה.
אחרי מסכת חיפושים קצרה שערכתי בגוגל, הגעתי לקצה חוט שלפיו, בתהליך ה-Build הפנימי של מיקרוסופט נעשה שימוש ב-Metadata Assemblies
(אסמבלים המכילים אך ורק Metadata, ללא פרטי מימוש), במקום אסמבלים
אמיתיים. ובצורה זאת התלות המעגלית "נשברת" והסיטואציה המשונה הזאת
מתאפשרת. עם זאת, לא נתקלתי עדיין במאמר מסודר מספיק שמתאר את התהליך
בפרטי פרטים, כך שאין לשער באילו קסמים אחרים יתכן ומשתמשים ברדמונד.
אם יש דבר אחד שאני לא אוהב לראות בקוד, זה שימוש מוגזם ב-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 בודד - לא יגרום לקריסה טוטאלית
של היקום בו אנחנו חיים. ועם זאת, מילת המפתח ומוסר ההשכל הכי חשוב שאפשר
לקחת מהפוסט הזה, הוא "בזהירות!".