Internals Go: التفاف متغيرات حلقة الإغلاق


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


وأود أيضًا أن أدعوكم إلى Golang Conf 2019 ، الذي سيعقد في 7 أكتوبر في موسكو. أنا عضو في لجنة برنامج المؤتمر ، ولقد اخترت أنا وزملائي العديد من التقارير المتشابهة والمثيرة للاهتمام للغاية. ما احبه


تحت القص ، أنقل الكلمة إلى المؤلف.



هناك صفحة على Go wiki بعنوان أخطاء متكررة . الغريب ، هناك مثال واحد فقط: سوء استخدام المتغيرات حلقة مع goroutines:


for _, val := range values { go func() { fmt.Println(val) }() } 

سيقوم هذا الرمز بإخراج القيمة الأخيرة من صفيف قيم len (القيم) مرات. إصلاح الكود بسيط للغاية:


 // assume the type of each value is string for _, val := range values { go func(val string) { fmt.Println(val) }(val) } 

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


الأشياء الأساسية: المرور بالقيمة والتمرير بالرجوع


في Go ، هناك اختلاف في تمرير الكائنات حسب القيمة والمرجع [1]. لنبدأ بالمثال 1 [2]:


 func foobyval(n int) { fmt.Println(n) } func main() { for i := 0; i < 5; i++ { go foobyval(i) } time.Sleep(100 * time.Millisecond) } 

لا أحد ، على الأرجح ، لديه أي شك في أن النتيجة سيتم عرضها القيم من 0 إلى 4. ربما في نوع من الترتيب العشوائي.


لنلقِ نظرة على المثال 2 .


 func foobyref(n *int) { fmt.Println(*n) } func main() { for i := 0; i < 5; i++ { go foobyref(&i) } time.Sleep(100 * time.Millisecond) } 

نتيجة لذلك ، سيتم عرض ما يلي:


5
5
5
5
5


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


والإجابة موجودة في مواصفات لغة Go . يقرأ المواصفات:


يتم إعادة استخدام المتغيرات المعلنة في بيان التهيئة في كل حلقة.

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


دعونا نلقي نظرة على الفرق في رمز الجهاز الذي تم إنشاؤه [3] لمعرفة الحلقة في المثالين 1 و 2. لنبدأ بالمثال 1.


 0x0026 00038 (go-func-byval.go:14) MOVL $8, (SP) 0x002d 00045 (go-func-byval.go:14) LEAQ "".foobyval·f(SB), CX 0x0034 00052 (go-func-byval.go:14) MOVQ CX, 8(SP) 0x0039 00057 (go-func-byval.go:14) MOVQ AX, 16(SP) 0x003e 00062 (go-func-byval.go:14) CALL runtime.newproc(SB) 0x0043 00067 (go-func-byval.go:13) MOVQ "".i+24(SP), AX 0x0048 00072 (go-func-byval.go:13) INCQ AX 0x004b 00075 (go-func-byval.go:13) CMPQ AX, $5 0x004f 00079 (go-func-byval.go:13) JLT 33 

تصبح عبارة Go عبارة عن استدعاء لوظيفة runtime.newproc. آليات هذه العملية مثيرة جدا للاهتمام ، ولكن دعونا نترك هذا للمقال القادم. الآن نحن مهتمون أكثر بما يحدث للمتغير i. يتم تخزينه في سجل AX ، والذي يتم تمريره بعد ذلك بالقيمة خلال الكومة إلى الدالة foobyval [4] كوسيطة لها. يبدو "حسب القيمة" في هذه الحالة نسخ قيمة تسجيل AX على المكدس. وتغيير AX في المستقبل لا يؤثر على ما يتم تمريره إلى وظيفة foobyval.


وإليك ما يشبه المثال 2:


 0x0040 00064 (go-func-byref.go:14) LEAQ "".foobyref·f(SB), CX 0x0047 00071 (go-func-byref.go:14) MOVQ CX, 8(SP) 0x004c 00076 (go-func-byref.go:14) MOVQ AX, 16(SP) 0x0051 00081 (go-func-byref.go:14) CALL runtime.newproc(SB) 0x0056 00086 (go-func-byref.go:13) MOVQ "".&i+24(SP), AX 0x005b 00091 (go-func-byref.go:13) INCQ (AX) 0x005e 00094 (go-func-byref.go:13) CMPQ (AX), $5 0x0062 00098 (go-func-byref.go:13) JLT 57 

الرمز مشابه جدًا - مع اختلاف واحد ، لكن مهم جدًا. الآن في AX هو العنوان i ، وليس قيمته. لاحظ أيضًا أن الزيادة والمقارنة للحلقة تتم على (AX) ، وليس AX. وبعد ذلك ، عندما نضع AX على المكدس ، فإننا ، كما اتضح ، نمرر العنوان i إلى الوظيفة. سيظهر التغيير (AX) بهذه الطريقة في goroutine أيضًا.


لا مفاجآت. في النهاية ، نقوم بتمرير مؤشر إلى رقم في الدالة foobyref.
أثناء العملية ، تنتهي الدورة بشكل أسرع من بدء أي من goroutines التي تم إنشاؤها. عند بدء العمل ، سيكون لديهم مؤشر لنفس المتغير i ، وليس إلى نسخة. وما هي قيمة أنا في هذه اللحظة؟ القيمة هي 5. التي توقفت عنها الدورة. وهذا هو السبب في أن جميع goroutines تستمد 5.


طرق ذات قيمة مقابل أساليب VS مع مؤشر


يمكن ملاحظة سلوك مماثل عند إنشاء goroutines التي تستدعي أي طرق. يشار إلى ذلك بواسطة نفس صفحة الويكي. انظر إلى المثال 3 :


 type MyInt int func (mi MyInt) Show() { fmt.Println(mi) } func main() { ms := []MyInt{50, 60, 70, 80, 90} for _, m := range ms { go m.Show() } time.Sleep(100 * time.Millisecond) } 

يعرض هذا المثال عناصر مجموعة ms. في ترتيب عشوائي ، كما توقعنا. مثال مشابه جدًا 4 يستخدم طريقة مؤشر للأسلوب Show:


 type MyInt int func (mi *MyInt) Show() { fmt.Println(*mi) } func main() { ms := []MyInt{50, 60, 70, 80, 90} for _, m := range ms { go m.Show() } time.Sleep(100 * time.Millisecond) } 

حاول أن تخمن ماذا ستكون النتيجة: 90 ، مطبوعة خمس مرات. السبب هو نفسه كما في المثال الأبسط 2. هنا المشكلة أقل وضوحا بسبب السكر النحوي في Go عند استخدام طرق المؤشر. في الأمثلة ، عند التبديل من المثال 1 إلى المثال 2 ، قمنا بتغيير i إلى & i ، وهنا تبدو المكالمة كما هي! m.Show () في كلا المثالين ، والسلوك مختلف.


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


في معظم الحالات ، هذه الميزة مفيدة. نكتب رمز نظافة. لكن هنا ، يؤدي المرور بالرجوع إلى تأثيرات غير متوقعة.


دوائر قصيرة


وأخيرا نأتي إلى الإغلاق. لنلقِ نظرة على المثال 5 :


 func foobyval(n int) { fmt.Println(n) } func main() { for i := 0; i < 5; i++ { go func() { foobyval(i) }() } time.Sleep(100 * time.Millisecond) } 

سوف يطبع ما يلي:


5
5
5
5
5


وهذا على الرغم من حقيقة أنني مررت بالقيمة إلى foobyval في الإغلاق. على غرار المثال 1. ولكن لماذا؟ لنلقِ نظرة على حلقة المجمّع:


 0x0040 00064 (go-closure.go:14) LEAQ "".main.func1·f(SB), CX 0x0047 00071 (go-closure.go:14) MOVQ CX, 8(SP) 0x004c 00076 (go-closure.go:14) MOVQ AX, 16(SP) 0x0051 00081 (go-closure.go:14) CALL runtime.newproc(SB) 0x0056 00086 (go-closure.go:13) MOVQ "".&i+24(SP), AX 0x005b 00091 (go-closure.go:13) INCQ (AX) 0x005e 00094 (go-closure.go:13) CMPQ (AX), $5 0x0062 00098 (go-closure.go:13) JLT 57 

يشبه الرمز للغاية المثال 2: لاحظ أنه يمثل عنوان في سجل AX. وهذا هو ، نمر أنا بالرجوع. وهذا على الرغم من حقيقة أن foobyval يسمى. يستدعي نص الحلقة الوظيفة باستخدام runtime.newproc ، لكن من أين تأتي هذه الوظيفة؟


يتم إنشاء Func1 بواسطة المحول البرمجي وهو إغلاق. قام المحول البرمجي بتخصيص كود الإغلاق كدالة منفصلة ويدعوه من main. المشكلة الرئيسية في هذا التخصيص هي كيفية التعامل مع المتغيرات التي تغلق الاستخدام ، ولكن من الواضح أنها ليست حجة.


هذا ما يشبه جسم func1:


 0x0000 00000 (go-closure.go:14) MOVQ (TLS), CX 0x0009 00009 (go-closure.go:14) CMPQ SP, 16(CX) 0x000d 00013 (go-closure.go:14) JLS 56 0x000f 00015 (go-closure.go:14) SUBQ $16, SP 0x0013 00019 (go-closure.go:14) MOVQ BP, 8(SP) 0x0018 00024 (go-closure.go:14) LEAQ 8(SP), BP 0x001d 00029 (go-closure.go:15) MOVQ "".&i+24(SP), AX 0x0022 00034 (go-closure.go:15) MOVQ (AX), AX 0x0025 00037 (go-closure.go:15) MOVQ AX, (SP) 0x0029 00041 (go-closure.go:15) CALL "".foobyval(SB) 0x002e 00046 (go-closure.go:16) MOVQ 8(SP), BP 0x0033 00051 (go-closure.go:16) ADDQ $16, SP 0x0037 00055 (go-closure.go:16) RET 

من المثير للاهتمام هنا أن الدالة لها وسيطة في 24 (SP) ، وهي مؤشر على int: ألق نظرة على السطر MOVQ (AX) ، AX ، والذي يأخذ قيمة قبل تمريره إلى foobyval. في الواقع ، يبدو func1 شيئًا مثل هذا:


 func func1(i *int) { foobyval(*i) }    main   - : for i := 0; i < 5; i++ { go func1(&i) } 

تلقى ما يعادل المثال 2 ، وهذا ما يفسر الاستنتاج. في اللغة الفنية ، نقول إنني متغير حر داخل الإغلاق ويتم التقاط هذه المتغيرات بالرجوع في Go.


ولكن هل هذا هو الحال دائما؟ والمثير للدهشة أن الجواب هو لا. في بعض الحالات ، يتم التقاط المتغيرات الحرة حسب القيمة. إليك صيغة مختلفة من مثالنا:


 for i := 0; i < 5; i++ { ii := i go func() { foobyval(ii) }() } 

هذا المثال سينتج 0 ، 1 ، 2 ، 3 ، 4 بترتيب عشوائي. ولكن لماذا يختلف السلوك هنا عن المثال 5؟


اتضح أن هذا السلوك هو قطعة أثرية من مجريات الأمور التي يستخدمها برنامج التحويل البرمجي Go عندما يعمل مع عمليات الإغلاق.


نحن ننظر تحت غطاء محرك السيارة


إذا لم تكن معتادًا على بنية برنامج التحويل البرمجي Go ، فإنني أوصي بأن تقرأ مقالاتي المبكرة حول هذا الموضوع: الجزء 1 ، الجزء 2 .


تبدو شجرة بناء الجملة المحددة (على عكس الملخص) التي يتم الحصول عليها عن طريق تحليل الرمز كما يلي:


 0: *syntax.CallStmt { . Tok: go . Call: *syntax.CallExpr { . . Fun: *syntax.FuncLit { . . . Type: *syntax.FuncType { . . . . ParamList: nil . . . . ResultList: nil . . . } . . . Body: *syntax.BlockStmt { . . . . List: []syntax.Stmt (1 entries) { . . . . . 0: *syntax.ExprStmt { . . . . . . X: *syntax.CallExpr { . . . . . . . Fun: foobyval @ go-closure.go:15:4 . . . . . . . ArgList: []syntax.Expr (1 entries) { . . . . . . . . 0: i @ go-closure.go:15:13 . . . . . . . } . . . . . . . HasDots: false . . . . . . } . . . . . } . . . . } . . . . Rbrace: syntax.Pos {} . . . } . . } . . ArgList: nil . . HasDots: false . } } 

تتمثل الوظيفة المطلوبة في عقدة FuncLit ، وهي دالة ثابتة. عندما يتم تحويل هذه الشجرة إلى AST (شجرة بناء جملة مجردة) ، فإن إبراز هذه الوظيفة الثابتة كواحدة منفصلة ستكون النتيجة. يحدث هذا في طريقة noder.funcLit التي تعيش في gc / closure.go.


ثم يكمل المدقق tipe التحويل ، ونحصل على التمثيل التالي للوظيفة في AST:


 main.func1: . DCLFUNC l(14) tc(1) FUNC-func() . DCLFUNC-body . . CALLFUNC l(15) tc(1) . . . NAME-main.foobyval a(true) l(8) x(0) class(PFUNC) tc(1) used FUNC-func(int) . . CALLFUNC-list . . . NAME-main.il(15) x(0) class(PAUTOHEAP) tc(1) used int 

لاحظ أن القيمة التي تم تمريرها إلى foobyval هي NAME-main.i ، أي أننا نشير بشكل صريح إلى المتغير من الوظيفة التي تلتف الإغلاق.


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


يتم استدعاء // capturevars في مرحلة منفصلة بعد كل اختبارات النوع.
// يقرر ما إذا كان سيتم التقاط المتغير حسب القيمة أم بالإشارة.
/ / نستخدم الالتقاط حسب القيمة للقيم <= 128 بايت لم تعد تغير القيمة بعد الالتقاط (الثوابت في الأساس).


عندما يتم استدعاء capturevars في المثال 5 ، فإنه يقرر أن يتم التقاط متغير الحلقة i بالرجوع إليه ، وإضافة علامة addrtaken المناسبة إليه. يمكن ملاحظة ذلك في إخراج AST:


 FOR l(13) tc(1) . LT l(13) tc(1) bool . . NAME-main.ia(true) g(1) l(13) x(0) class(PAUTOHEAP) esc(h) tc(1) addrtaken assigned used int 

بالنسبة لمتغير الحلقة ، لا يعمل تحديد القيمة "حسب القيمة" ، حيث أن المتغير يغير قيمته بعد المكالمة (تذكر الاقتباس من المواصفات التي يتم إعادة استخدام متغير الحلقة في كل تكرار). لذلك ، يتم التقاط المتغير i بالرجوع.
في هذا التباين في مثالنا ، حيث لدينا ii: = i ، ii لم تعد مستخدمة وبالتالي يتم التقاطها بالقيمة [5].


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


من المحتمل أن يكون هذا السلوك (لا تستخدم متغيرًا جديدًا) خطأ عند تصميم لغة. ربما سنصلحها في الإصدارات المستقبلية ، ولكن نظرًا للتوافق مع الإصدارات السابقة ، لا يمكننا فعل أي شيء في الإصدار 1 من Go.

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




[1] لاحظ بعض القراء أنه ، بالمعنى الدقيق للكلمة ، لا يوجد مفهوم "المرور بالرجوع" في Go ، لأن كل شيء يتم تمريره بالقيمة ، بما في ذلك المؤشرات. في هذه المقالة ، عندما ترى "تمرير حسب المرجع" ، أعني "تمرير حسب العنوان" وهو صريح في بعض الحالات (مثل تمرير & n إلى دالة تتوقع * int) ، وفي بعض الحالات ضمنيًا ، كما في الحالات اللاحقة أجزاء من المادة.


[2] فيما يلي ، استخدم time.Sleep كطريقة سريعة وقذرة لانتظار إتمام جميع goroutines. بدون هذا ، سينتهي الرئيسي قبل بدء goroutines للعمل. الطريقة الصحيحة للقيام بذلك هي استخدام شيء مثل WaitGroup أو قناة تم القيام به.


[3] تم الحصول على تمثيل المجمّع لجميع الأمثلة في هذه المقالة باستخدام الأمر go tool compile -l -S. علامة -l تعطيل وظيفة مضمنة ويجعل رمز المجمّع أكثر قابلية للقراءة.


[4] لا يتم استدعاء Foobyval مباشرة ، لأن المكالمة تمر. بدلاً من ذلك ، يتم تمرير العنوان كالوسيطة الثانية (16 (SP)) إلى وظيفة runtime.newproc ، والوسيطة إلى foobyval (i في هذه الحالة) ترتفع المكدس.


[5] كتمرين ، أضف ii = 10 كسطر أخير من حلقة for (بعد استدعاء go). ماذا كان استنتاجك؟ لماذا؟

Source: https://habr.com/ru/post/ar468863/


All Articles