الكتاب الأساسي

شرح آلية panic/recover بالتفصيل

قد قُدّمت آلية panic و recover سابقاً، وعُرضت عدة حالات لاستخدام panic/recover في المقال السابق. سيشرح هذا المقال الحالي آلية panic/recover بالتفصيل. كما ستُشرح أطوار الخروج لنداءات الدوال بالتفصيل.

أطوار الخروج لنداءات الدوال

في Go، قد يمرّ نداء دالة بطور خروج قبل أن يخرج تماماً. في طور الخروج، تُنفَّذ نداءات الدوال المؤجلة التي دُفعت إلى مكدس نداءات defer أثناء تنفيذ نداء الدالة (بترتيب الدفع العكسي). وعندما تخرج جميع النداءات المؤجلة تماماً، ينتهي طور الخروج ويخرج نداء الدالة أيضاً تماماً.

قد تُسمّى أطوار الخروج أطوار العودة أيضاً في مواضع أخرى.

قد يدخل نداء الدالة في طور خروجه (أو يخرج مباشرةً) عبر ثلاث طرق:

  1. بعد أن يعود النداء عودة طبيعية.
  2. عند حدوث حالة panic في النداء.
  3. بعد استدعاء الدالة runtime.Goexit وخروجها تماماً في النداء.

على سبيل المثال، في مقطع الشيفرة التالي،

  • سيدخل نداء الدالة f0 أو f1 طور خروجه بعد أن يعود عودة طبيعية.
  • سيدخل نداء الدالة f2 طور خروجه بعد حدوث حالة panic القسمة على صفر.
  • سيدخل نداء الدالة f3 طور خروجه بعد أن يخرج نداء الدالة runtime.Goexit تماماً.

import (
	"fmt"
	"runtime"
)

func f0() int {
	var x = 1
	defer fmt.Println("exits normally:", x)
	x++
	return x
}

func f1() {
	var x = 1
	defer fmt.Println("exits normally:", x)
	x++
}

func f2() {
	var x, y = 1, 0
	defer fmt.Println("exits for panicking:", x)
	x = x / y // سيحدث panic
	x++       // غير قابل للوصول
}

func f3() int {
	x := 1
	defer fmt.Println("exits for Goexiting:", x)
	x++
	runtime.Goexit()
	return x+x // غير قابل للوصول
}

بالمناسبة، الدالة runtime.Goexit() ليست مخصصة للاستدعاء في الـ goroutine الرئيسية للبرنامج.

ارتباط حالات panic وإشارات Goexit بنداءات الدوال

عندما تحدث حالة panic مباشرةً في نداء دالة، نقول إن حالة panic (غير المستردّة) تبدأ بالارتباط بنداء الدالة. وبالمثل، عندما تُستدعى الدالة runtime.Goexit في نداء دالة، نقول إن إشارة Goexit تبدأ بالارتباط بنداء الدالة بعد أن يخرج نداء runtime.Goexit تماماً. وكما شُرح في القسم السابق، فإن ارتباط حالة panic أو إشارة Goexit بنداء دالة سيجعل نداء الدالة يدخل طور خروجه فوراً.

لقد تعلمنا أنه يمكن استرداد حالات panic. ومع ذلك، لا توجد طرق لإلغاء إشارة Goexit.

في أي وقت، قد يرتبط نداء دالة بحالة panic واحدة غير مستردّة على الأكثر. وإذا كان النداء مرتبطاً بحالة panic غير مستردّة، فإن:

  • لن يرتبط النداء بأي حالة panic عندما تُستردّ حالة panic غير المستردّة.
  • عندما تحدث حالة panic جديدة في نداء الدالة، ستحل الجديدة محل القديمة لتصبح حالة panic غير المستردّة المرتبطة بنداء الدالة.

على سبيل المثال، في البرنامج التالي، حالة panic المستردّة هي panic 3، وهي آخر حالة panic ترتبط بنداء الدالة main.


package main

import "fmt"

func main() {
	defer func() {
		fmt.Println(recover()) // 3
	}()

	defer panic(3) // ستحل محل حالة panic 2
	defer panic(2) // ستحل محل حالة panic 1
	defer panic(1) // ستحل محل حالة panic 0
	panic(0)
}

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

رغم أن ذلك غير معتاد، قد تتعايش عدة حالات panic غير مستردّة في goroutine واحدة في آن واحد. ترتبط كل واحدة منها بنداء دالة لم يخرج بعد في مكدس نداءات الـ goroutine. وعندما يخرج نداء متداخل تماماً ولا يزال مرتبطاً بحالة panic غير مستردّة، ستنتشر حالة panic غير المستردّة إلى النداء المُحتضِن (مستدعي النداء المتداخل). والتأثير هو نفسه كأن حالة panic حدثت مباشرةً في النداء المُحتضِن. وهذا يعني:

  • إذا كانت هناك حالة panic غير مستردّة قديمة مرتبطة بالنداء المُحتضِن من قبل، فستحل المنتشرة محل القديمة. في هذه الحالة، لا بد أن النداء المُحتضِن قد دخل طور خروجه بالفعل، لذا سيُستدعى نداء الدالة المؤجل التالي في مكدس نداءات defer الخاص به.
  • إذا لم تكن هناك حالة panic غير مستردّة مرتبطة بالنداء المُحتضِن من قبل، فسترتبط المنتشرة بالنداء المُحتضِن. في هذه الحالة، قد يكون النداء المُحتضِن قد دخل طور خروجه أو لم يدخله. وإذا لم يكن قد دخل، فسيدخله فوراً.

إذن، عندما تنتهي goroutine من الخروج، قد توجد حالة panic واحدة غير مستردّة على الأكثر في الـ goroutine. وإذا خرجت الـ goroutine مع حالة panic غير مستردّة، ينهار البرنامج بأكمله، وستُبلَّغ معلومات حالة panic غير المستردّة. وإلا، تخرج الـ goroutine بشكل طبيعي (بسلام).

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

سينهار البرنامج المثال التالي إذا عمل، لأن حالة panic 2 لا تزال غير مستردّة عندما تخرج الـ goroutine الجديدة.


package main

func main() {
	// الـ goroutine الجديدة.
	go func() {
		// هذا نداء مؤجل مجهول.
		// عندما يخرج تماماً، ستنتشر حالة panic 2
		// إلى نداء الدالة الابتدائية للـ goroutine
		// الجديدة، وتحل محل حالة panic 0. ولن
		// تُستردّ حالة panic 2 أبداً.
		defer func() {
			// كما شُرح في المثال السابق،
			// ستحل حالة panic 2 محل حالة panic 1.
			defer panic(2)

			// عندما يخرج نداء الدالة المجهولة تماماً،
			// ستنتشر حالة panic 1 إلى النداء المؤجل
			// المجهول المُحتضِن (وترتبط به).
			func () {
				// بمجرد حدوث حالة panic 1، ستكون هناك
				// حالتا panic غير مستردّتين متعايشتان
				// في الـ goroutine الجديدة. إحداهما
				// (panic 0) مرتبطة بنداء الدالة الابتدائية
				// للـ goroutine الجديدة، والأخرى (panic 1)
				// مرتبطة بنداء الدالة المجهولة الحالي.
				panic(1)
			}()
		}()
		panic(0)
	}()

	select{}
}

المخرجات (عند تصريف البرنامج أعلاه بالمصرِّف القياسي لـ Go الإصدار v1.25.n):


panic: 0
	panic: 1
	panic: 2

...

صيغة المخرجات ليست مثالية، وقد تدفع بعض الناس إلى الاعتقاد بأن حالة panic 0 هي حالة panic النهائية غير المستردّة، بينما حالة panic النهائية غير المستردّة هي في الواقع panic 2.

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

عندما ترتبط إشارة Goexit بنداء دالة، إذا كان نداء الدالة مرتبطاً بحالة panic غير مستردّة، فسيُستردّ الـ panic (بصراحة، هذا غريب بالنسبة لي). على سبيل المثال، سيخرج البرنامج التالي بسلام، لأن حالة panic «bye» ستُستردّ بواسطة إشارة Goexit.


package main

import (
	"runtime"
)

func f() {
	// ستُعطّل إشارة Goexit حالة panic "bye".
	defer runtime.Goexit()
	panic("bye")
}

func main() {
	go f()

	for runtime.NumGoroutine() > 1 {
		runtime.Gosched()
	}
}

بعض نداءات recover بلا تأثير

يجب استدعاء الدالة المدمجة recover في مواضع مناسبة لتؤثّر. وإلا، تكون النداءات بلا تأثير. على سبيل المثال، لا يستردّ أي من نداءات recover في المثال التالي حالة panic «bye».


package main

func main() {
	defer func() {
		defer func() {
			recover() // بلا تأثير
		}()
	}()
	defer func() {
		func() {
			recover() // بلا تأثير
		}()
	}()
	func() {
		defer func() {
			recover() // بلا تأثير
		}()
	}()
	func() {
		defer recover() // بلا تأثير
	}()
	func() {
		recover() // بلا تأثير
	}()
	recover()       // بلا تأثير
	defer recover() // بلا تأثير
	panic("bye")
}

نعلم بالفعل أن نداء recover التالي يؤثّر.


package main

func main() {
	defer func() {
		recover() // يؤثّر
	}()

	panic("bye")
}

فلماذا لا تؤثّر نداءات recover تلك في المثال الأول من القسم الحالي؟ لنقرأ النسخة الحالية من مواصفات Go:

تكون القيمة المُرجَعة من recover هي nil إذا تحقق أي من الشروط التالية:

  • كانت معطى panic هو nil؛
  • الـ goroutine ليست في حالة panic؛
  • لم تُستدعَ recover مباشرةً بواسطة دالة مؤجلة.

يوجد مثال يوضّح حالة الشرط الأول في المقال السابق.

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

  • لم تُستدعَ recover مباشرةً بواسطة نداء دالة مؤجلة استُدعي مباشرةً بواسطة نداء الدالة المرتبط بحالة panic المتوقّع استردادها.

في المثال الأول من القسم الحالي، حالة panic المتوقّع استردادها مرتبطة بنداء الدالة main. النداء الأول لـ recover يُستدعى مباشرةً بواسطة نداء دالة مؤجلة، لكن نداء الدالة المؤجلة هذا ليس مستدعىً مباشرةً بواسطة نداء الدالة main. لهذا السبب يكون النداء الأول لـ recover بلا تأثير.

في الواقع، لا تشرح مواصفات Go الحالية جيداً أيضاً سبب عدم تأثير النداء الثاني لـ recover (بحسب ترتيب أسطر الشيفرة)، المتوقّع أن يستردّ حالة panic 1، في المثال التالي.


// هذا البرنامج يخرج دون استرداد حالة panic 1.
package main

func demo() {
	defer func() {
		defer func() {
			recover() // هذه تستردّ حالة panic 2
		}()

		defer recover() // بلا تأثير

		panic(2)
	}()
	panic(1)
}

func main() {
	demo()
}

ما لا تذكره مواصفات Go هو أن كل نداء recover يُنظر إليه كمحاولة لاسترداد أحدث حالة panic غير مستردّة في الـ goroutine الحالية.

يرى وقت تشغيل Go أن النداء الثاني لـ recover في المثال أعلاه يحاول استرداد أحدث حالة panic غير مستردّة، أي panic 2، وهي مرتبطة بنداء المستدعي للنداء الثاني لـ recover. والنداء الثاني لـ recover ليس مستدعىً مباشرةً بواسطة نداء دالة مؤجلة استدعته الدالة المرتبطة، بل يُستدعى مباشرةً بواسطة الدالة المرتبطة. لهذا السبب يكون النداء الثاني لـ recover بلا تأثير.

الخلاصة

حسناً، الآن، لنجرّب صياغة وصف موجز لأي نداءات recover ستؤثّر:

لا يؤثّر نداء recover إلا إذا كان المستدعي المباشر لنداء recover نداءً مؤجلاً، وكان المستدعي المباشر للنداء المؤجل مرتبطاً بأحدث حالة panic غير مستردّة في الـ goroutine الحالية. ونداء recover الفعّال يفصل أحدث حالة panic غير مستردّة عن نداء الدالة المرتبط بها، ويُرجع القيمة التي مُرّرت إلى نداء panic الذي أنتج أحدث حالة panic غير مستردّة.