ميكانيكا اللغة تهرب من التحليل

مقدمة


هذه هي المقالة الثانية من أربع مقالات في سلسلة ستوفر نظرة ثاقبة على ميكانيكا وتصميم المؤشرات ، والأكوام ، والأكوام ، وتحليل الهروب ، ودلالات Go / pointer. هذا المنشور عن أكوام وتحليل الهروب.

جدول المحتويات:

  1. ميكانيكا اللغة على مداخن ومؤشرات ( ترجمة )
  2. ميكانيكا اللغة في تحليل الهروب
  3. ميكانيكا اللغة في تحديد ملامح الذاكرة
  4. فلسفة التصميم في البيانات والمعاني

المقدمة


في المنشور الأول في هذه السلسلة ، تحدثت عن أساسيات ميكانيكا المؤشر باستخدام مثال يتم فيه توزيع القيمة عبر المكدس بين goroutines. لم أريكم ما يحدث عند تقسيم القيمة على المكدس. لفهم ذلك ، تحتاج إلى معرفة منطقة أخرى من الذاكرة حيث قد تكون القيم: حول "كومة الذاكرة المؤقتة". مع هذه المعرفة ، يمكنك البدء في دراسة "تحليل الهروب".
تحليل الهروب هو عملية يستخدمها المترجم لتحديد موضع القيم التي أنشأها برنامجك. على وجه الخصوص ، يقوم المحول البرمجي بإجراء تحليل ثابت للكود لتحديد ما إذا كان يمكن وضع القيمة على إطار المكدس للدالة التي تبنيها ، أو إذا كان يجب "تخطي" القيمة في الكومة. لا توجد كلمة رئيسية أو وظيفة واحدة في Go يمكنك استخدامها لإخبار المترجم بالقرار الذي يجب اتخاذه. فقط الطريقة التي تكتب بها الكود الخاص بك مشروطة تسمح لك بالتأثير على هذا القرار.

أكوام


الكومة هي منطقة ثانية من الذاكرة ، بجانب المكدس ، تستخدم لتخزين القيم. الكومة ليست ذاتية التنظيف مثل المداخن ، لذا فإن استخدام هذه الذاكرة أكثر تكلفة. بادئ ذي بدء ، ترتبط التكاليف بمجمع القمامة (GC) ، الذي يجب أن يحافظ على نظافة هذه المنطقة. عندما يبدأ GC ، سيستخدم 25٪ من الطاقة المتوفرة للمعالج. بالإضافة إلى ذلك ، من المحتمل أن يؤدي ذلك إلى إنشاء ميكروثانية من التأخيرات "أوقف العالم". ميزة امتلاك GC هي أنه لا داعي للقلق بشأن إدارة ذاكرة كومة الذاكرة المؤقتة التي كانت معقدة تاريخياً وعرضة للخطأ.

تثير القيم في كومة الذاكرة المؤقتة عمليات تخصيص الذاكرة في Go. تضغط عمليات التخصيص هذه على GC لأنه يجب حذف كل قيمة في الكومة لم يعد يشير إليها المؤشر. كلما زادت القيم التي تحتاج إلى التحقق منها وحذفها ، كلما زاد العمل الذي يجب على GC القيام به في كل بداية. لذلك ، تعمل خوارزمية التحفيز باستمرار على موازنة حجم الكومة وسرعة التنفيذ.

مشاركة المكدس


في Go ، لا يُسمح لأي غوروتين بمؤشر يشير إلى ذاكرة على كومة غوروتين آخر. ويرجع ذلك إلى حقيقة أنه يمكن استبدال ذاكرة مكدس goroutines مع كتلة ذاكرة جديدة ، عندما يجب زيادة أو انخفاض المكدس. إذا كان عليك في وقت التشغيل تتبع مؤشرات المكدس في غوروتين آخر ، فسيتعين عليك إدارة الكثير ، وسيكون التأخير "إيقاف العالم" عند تحديث المؤشرات إلى هذه المداخن أمرًا مذهلاً.

فيما يلي مثال على مكدس تم استبداله عدة مرات بسبب النمو. انظر إلى المخرجات في السطور 2 و 6. سترى ضعف تغييرات العنوان لقيمة السلسلة داخل إطار المكدس الرئيسي.

play.golang.org/p/pxn5u4EBSI

ميكانيكا الهروب


في كل مرة يتم فيها مشاركة قيمة خارج منطقة إطار المكدس التابع للدالة ، يتم وضعها (أو تخصيصها) في كومة. مهمة خوارزميات تحليل الهروب هي العثور على مثل هذه المواقف والحفاظ على مستوى النزاهة في البرنامج. النزاهة هي ضمان أن يكون الوصول إلى أي قيمة دائمًا دقيقًا ومتسقًا وفعالًا.

ألق نظرة على هذا المثال لتعلم الآليات الأساسية لتحليل الهروب. قائمة

play.golang.org/p/Y_VZxYteKO

1

01 package main
02
03 type user struct {
04     name  string
05     email string
06 }
07
08 func main() {
09     u1 := createUserV1()
10     u2 := createUserV2()
11
12     println("u1", &u1, "u2", &u2)
13 }
14
15 //go:noinline
16 func createUserV1() user {
17     u := user{
18         name:  "Bill",
19         email: "bill@ardanlabs.com",
20     }
21
22     println("V1", &u)
23     return u
24 }
25
26 //go:noinline
27 func createUserV2() *user {
28     u := user{
29         name:  "Bill",
30         email: "bill@ardanlabs.com",
31     }
32
33     println("V2", &u)
34     return &u
35 }

أستخدم الأمر go: noinline بحيث لا يقوم المترجم بتضمين التعليمات البرمجية لهذه الوظائف مباشرة في main. سيؤدي التضمين إلى إزالة استدعاءات الوظائف وتعقيد هذا المثال. سأتحدث عن الآثار الجانبية للتضمين في المنشور التالي.

تعرض القائمة 1 برنامجًا بوظيفتين مختلفتين تخلق قيمة من نوع المستخدم وتعيده إلى المتصل. يستخدم الإصدار الأول من الدالة دلالات القيمة عند العودة.

قائمة 2

16 func createUserV1() user {
17     u := user{
18         name:  "Bill",
19         email: "bill@ardanlabs.com",
20     }
21
22     println("V1", &u)
23     return u
24 }

قلت أن الدالة تستخدم دلالات القيم عند العودة ، لأنه يتم نسخ قيمة نوع المستخدم الذي تم إنشاؤه بواسطة هذه الوظيفة وتمريرها إلى مكدس الاستدعاء. هذا يعني أن وظيفة الاستدعاء تتلقى نسخة من القيمة نفسها.

يمكنك رؤية إنشاء قيمة من نوع المستخدم ، يتم تنفيذها على الأسطر من 17 إلى 20. ثم ، في السطر 23 ، يتم تمرير نسخة من القيمة إلى مكدس الاستدعاء وإعادتها إلى المتصل. بعد إرجاع الوظيفة ، تبدو المكدس كما يلي.

الصورة 1



في الشكل 1 ، يمكنك أن ترى أن قيمة المستخدم من النوع موجودة في كلا الإطارين بعد استدعاء createUserV1. في الإصدار الثاني من الدالة ، يتم استخدام دلالات المؤشر للعودة.

قائمة 3

27 func createUserV2() *user {
28     u := user{
29         name:  "Bill",
30         email: "bill@ardanlabs.com",
31     }
32
33     println("V2", &u)
34     return &u
35 }

قلت أن دالة تستخدم دلالات المؤشر عند العودة ، لأن قيمة نوع المستخدم الذي تم إنشاؤه بواسطة هذه الوظيفة تتم مشاركتها بواسطة مكدس الاستدعاء. هذا يعني أن وظيفة الاستدعاء تتلقى نسخة من العنوان حيث توجد القيم.

يمكنك أن ترى نفس الحرف الإنشائي المستخدم في السطور من 28 إلى 31 لإنشاء قيمة من النوع المستخدم ، ولكن في السطر 34 يكون العائد من الوظيفة مختلفًا. بدلاً من تمرير نسخة من القيمة إلى مكدس الاستدعاء ، يتم تمرير نسخة من عنوان القيمة. بناءً على ذلك ، قد تعتقد أنه بعد المكالمة يبدو المكدس على هذا النحو.

الصورة 2



إذا كان ما تراه في الشكل 2 يحدث بالفعل ، فستواجه مشكلة في التكامل. يشير المؤشر إلى مجموعة مكالمات من الذاكرة لم تعد صالحة. في المرة التالية التي يتم فيها استدعاء الوظيفة ، سيتم إعادة تهيئة الذاكرة المشار إليها وإعادة تهيئتها.

هنا يبدأ تحليل الهروب للحفاظ على النزاهة. في هذه الحالة ، سيحدد المترجم أنه من غير الآمن إنشاء قيمة من نوع المستخدم داخل إطار مكدس createUserV2 ، لذلك سيقوم بدلاً من ذلك بإنشاء قيمة في الكومة. سيحدث هذا على الفور أثناء البناء على الخط 28.

مقروئية


كما علمت من منشور سابق ، فإن الوظيفة لها وصول مباشر إلى الذاكرة داخل إطارها من خلال مؤشر الإطار ، ولكن الوصول إلى الذاكرة خارج الإطار يتطلب وصولاً غير مباشر. هذا يعني أن الوصول إلى القيم التي تقع في كومة الذاكرة المؤقتة يجب أن يتم بشكل غير مباشر من خلال المؤشر.

تذكر كيف يبدو رمز createUserV2.

قائمة 4

27 func createUserV2() *user {
28     u := user{
29         name:  "Bill",
30         email: "bill@ardanlabs.com",
31     }
32
33     println("V2", &u)
34     return &u
35 }

يخفي بناء الجملة ما يحدث بالفعل في هذا الرمز. يمثل المتغير u المعلن في السطر 28 قيمة للمستخدم النوع. لا يخبرك البناء في Go بالتحديد بمكان تخزين القيمة في الذاكرة ، لذا قبل بيان الإرجاع في السطر 34 ، لا تعرف أنه سيتم كومة القيمة. هذا يعني أنه على الرغم من أن u تمثل قيمة للمستخدم النوع ، يجب أن يكون الوصول إلى هذه القيمة من خلال المؤشر.

يمكنك تصور مكدس يشبه هذا بعد استدعاء دالة.

الصورة 3



يمثل المتغير u في إطار المكدس لـ createUserV2 القيمة في الكومة ، وليس على المكدس. هذا يعني أن استخدام u للوصول إلى قيمة يتطلب الوصول إلى المؤشر ، وليس الوصول المباشر الذي تقترحه البنية. قد تعتقد ، لماذا لا تقوم بعمل مؤشر على الفور ، حيث أن الوصول إلى القيمة التي يمثلها لا يزال يتطلب استخدام المؤشر؟

قائمة 5

27 func createUserV2() *user {
28     u := &user{
29         name:  "Bill",
30         email: "bill@ardanlabs.com",
31     }
32
33     println("V2", u)
34     return u
35 }

إذا قمت بذلك ، فسوف تفقد إمكانية القراءة ، والتي لا يمكن أن تفقدها في التعليمات البرمجية الخاصة بك. ابتعد عن جسم الوظيفة لمدة ثانية وركز فقط على العودة.

قائمة 6

34     return u
35 }

ما الذي تتحدث عنه هذه العودة؟ كل ما يقوله هو أنه يتم دفع نسخة من u إلى مكدس المكالمة. في هذه الأثناء ، ما الذي يخبرك به الإرجاع عندما تستخدم عامل التشغيل &؟

قائمة 7

34     return &u
35 }

بفضل عامل التشغيل & return ، يخبرك الآن بأنك تشارك مكدس المكالمة وبالتالي تخرج في كومة الذاكرة المؤقتة. تذكر أن المؤشرات معدة للاستخدام معًا ، وأثناء قراءة الرمز ، فإنها تستبدل عامل التشغيل & بعبارة "مشاركة". إنه قوي للغاية من حيث سهولة القراءة. هذا شيء لا أريد أن أخسره.

فيما يلي مثال آخر حيث يؤدي إنشاء القيم باستخدام دلالات المؤشر إلى تقليل إمكانية القراءة.

قائمة 8

01 var u *user
02 err := json.Unmarshal([]byte(r), &u)
03 return u, err

لكي يعمل هذا الرمز ، عند استدعاء json.Unmarshal على السطر 02 ، يجب عليك تمرير المؤشر إلى متغير المؤشر. ستنشئ مكالمة json.Unmarshal قيمة من النوع المستخدم وتعيين عنوانه لمتغير المؤشر. play.golang.org/p/koI8EjpeIx

ماذا يقول هذا الرمز:
01: إنشاء مؤشر من نوع المستخدم بقيمة فارغة.
02: مشاركة المتغير مع دالة json.Unmarshal.
03: إعادة نسخة من المتغير u إلى المتصل.

ليس من الواضح تمامًا أن قيمة نوع المستخدم الذي تم إنشاؤه بواسطة وظيفة json.Unmarshal يتم تمريرها إلى المتصل.

كيف تتغير قابلية القراءة عند استخدام دلالات القيم أثناء تعريف المتغير؟

قائمة 9

01 var u user
02 err := json.Unmarshal([]byte(r), &u)
03 return &u, err

ما يقوله هذا الرمز:
01: إنشاء قيمة من نوع المستخدم بقيمة فارغة.
02: مشاركة المتغير مع دالة json.Unmarshal.
03: شارك المتغير مع المتصل.

كل شيء واضح للغاية. يقسم السطر 02 قيمة نوع المستخدم إلى أسفل مكدس المكالمة في json.Unmarshal ، ويقسم الخط 03 قيمة مكدس المكالمات إلى المتصل. ستؤدي هذه المشاركة إلى نقل القيمة إلى كومة الذاكرة المؤقتة.

استخدم دلالات القيم عند إنشاء القيم والاستفادة من إمكانية قراءة عامل التشغيل & لتوضيح كيفية فصل القيم.

تقارير المترجم


لمعرفة القرارات التي اتخذها المترجم ، يمكنك أن تطلب من المترجم تقديم تقرير. كل ما عليك فعله هو استخدام رمز التبديل -ccflags مع الخيار -m عند الاتصال go build.

في الواقع ، يمكنك استخدام 4 مستويات من -m ، ولكن بعد مستويين من المعلومات تصبح أكثر من اللازم. سأستخدم مستويين - م.

قائمة 10

$ go build -gcflags "-m -m"
./main.go:16: cannot inline createUserV1: marked go:noinline
./main.go:27: cannot inline createUserV2: marked go:noinline
./main.go:8: cannot inline main: non-leaf function
./main.go:22: createUserV1 &u does not escape
./main.go:34: &u escapes to heap
./main.go:34:     from ~r0 (return) at ./main.go:34
./main.go:31: moved to heap: u
./main.go:33: createUserV2 &u does not escape
./main.go:12: main &u1 does not escape
./main.go:12: main &u2 does not escape

يمكنك أن ترى أن المترجم يبلغ عن قرارات لإغراق القيمة في الكومة. ماذا يقول المترجم؟ أولاً ، انظر مرة أخرى إلى وظائف createUserV1 و createUserV2 لتحديثها في الذاكرة.

قائمة 13

16 func createUserV1() user {
17     u := user{
18         name:  "Bill",
19         email: "bill@ardanlabs.com",
20     }
21
22     println("V1", &u)
23     return u
24 }

27 func createUserV2() *user {
28     u := user{
29         name:  "Bill",
30         email: "bill@ardanlabs.com",
31     }
32
33     println("V2", &u)
34     return &u
35 }

لنبدأ بهذا السطر في التقرير.

قائمة 14

./main.go:22: createUserV1 &u does not escape

يشير هذا إلى أن استدعاء دالة println داخل دالة createUserV1 لا يتسبب في تفريغ نوع المستخدم في كومة الذاكرة المؤقتة. يجب التحقق من هذه الحالة لأنها تستخدم مع وظيفة println.

بعد ذلك ، انظر إلى هذه السطور في التقرير.

قائمة 15

./main.go:34: &u escapes to heap
./main.go:34:     from ~r0 (return) at ./main.go:34
./main.go:31: moved to heap: u
./main.go:33: createUserV2 &u does not escape

تقول هذه السطور أن قيمة نوع المستخدم المرتبط بالمتغير u ، الذي يحمل نوع المستخدم المسمى والذي تم إنشاؤه في السطر 31 ، يتم التخلص منها في كومة الذاكرة المؤقتة بسبب العائد على السطر 34. يقول السطر الأخير نفس الشيء كما كان من قبل ، طباعة println على الخط 33 لا يعيد تعيين نوع المستخدم.

يمكن أن تكون قراءة هذه التقارير مربكة وقد تختلف قليلاً اعتمادًا على ما إذا كان نوع المتغير المعني يعتمد على نوع مسمى أو حرفي.

قم بتعديل المتغير u ليكون المستخدم من النوع الحرفي بدلاً من المستخدم من النوع المسمى ، كما كان من قبل.

قائمة 16

27 func createUserV2() *user {
28     u := &user{
29         name:  "Bill",
30         email: "bill@ardanlabs.com",
31     }
32
33     println("V2", u)
34     return u
35 }

قم بتشغيل التقرير مرة أخرى.

قائمة 17

./main.go:30: &user literal escapes to heap
./main.go:30:     from u (assigned) at ./main.go:28
./main.go:30:     from ~r0 (return) at ./main.go:34

يقول التقرير الآن أن قيمة نوع المستخدم المشار إليها بواسطة المتغير u ، والتي لها النوع الحرفي * المستخدم والتي تم إنشاؤها في السطر 28 ، يتم التخلص منها في الكومة بسبب العائد على السطر 34.

استنتاج


إنشاء قيمة لا يحدد مكان وجودها. فقط كيفية تقسيم القيمة ستحدد ما سيفعله المترجم بهذه القيمة. في كل مرة تشارك فيها قيمة في مكدس المكالمة ، يتم تفريغها في كومة الذاكرة المؤقتة. هناك أسباب أخرى وراء احتمال هروب قيمة من المكدس. سأتحدث عنها في المنشور التالي.

الغرض من هذه الوظائف هو تقديم إرشادات حول اختيار استخدام دلالات القيمة أو دلالات المؤشر لأي نوع معين. يتم إقران كل دلالات مع الربح والقيمة. تخزن دلالات القيم القيم على المكدس ، مما يقلل من الحمل على GC. ومع ذلك ، هناك نسخ مختلفة من نفس القيمة التي يجب تخزينها وتتبعها وصيانتها. يضع دلالات المؤشر القيم في كومة الذاكرة المؤقتة ، والتي يمكن أن تضغط على GC. ومع ذلك ، فهي فعالة لأنه لا توجد سوى قيمة واحدة يجب تخزينها وتتبعها وصيانتها. النقطة الأساسية هي استخدام كل دلالات بشكل صحيح ومتسق ومتوازن.

All Articles