Behind .locals init
כפי שכולנו מכירים, #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."