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

شرح آلية 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 بنداءات الدوال

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

  1. حالات Goexit panic غير قابلة للاسترداد.
  2. حالات Goexit panic غير ضارّة. فهي لا تؤدي إلى انهيار البرنامج.

في أي وقت أثناء عمل البرنامج، قد يرتبط نداء دالة بحالة panic واحدة غير مستردّة على الأكثر، وقد تكون حالة panic عامة أو إشارة Goexit. وعند استدعاء دالة، لا توجد في البداية أي حالة panic مرتبطة بالنداء، بصرف النظر عمّا إذا كان مستدعيها (النداء المُحتضِن) قد دخل طور الخروج أم لا. وبالطبع، قد تحدث حالات panic لاحقاً أثناء تنفيذ نداء الدالة، لذا قد ترتبط حالة 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)
}

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

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

إذن، عندما تنتهي goroutine من الخروج، قد توجد حالة panic واحدة غير مستردّة على الأكثر في الـ goroutine. وإذا خرجت الـ goroutine مع حالة panic غير مستردّة وكانت حالة panic غير المستردّة ليست حالة Goexit panic، ينهار البرنامج بأكمله، وستُبلَّغ معلومات حالة panic غير المستردّة. وإلا، تخرج الـ goroutine بشكل طبيعي (بسلام). ولهذا نقول إن حالات Goexit panic غير ضارّة. سينهار البرنامج المثال التالي عند تشغيله، لأن حالة 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. سيخرج البرنامج التالي بشكل طبيعي عند تشغيله. ونداء runtime.Goexit في النهاية يعمل كعملية استرداد نهائية.

package main

import "runtime"

func f() {
	// ستحل إشارة Goexit محل حالة panic "bye"
	// كحالة panic النهائية (غير الضارّة).
	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 الحالية. وبالطبع، إذا لم تكن أحدث حالة panic غير مستردّة موجودة أو كانت إشارة Goexit غير قابلة للاسترداد، فإن نداء recover هذا يكون بلا تأثير.

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

الخلاصة

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