Liran Chen's Blog

.Net Internals, Development, Multithreading - and More!

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."
שלח תגובה

(שדה חובה)  

(שדה חובה)  

(אופציונלי)

(שדה חובה) 

Please add 8 and 3 and type the answer here:


Enter the numbers above: