GO Scheduler: الآن غير متعاون؟

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

ومع ذلك ، قررت بعد فترة البحث في الابتكارات بمزيد من التفصيل. أود أن أشارك نتائج هذه الدراسات.

صورة

متطلبات النظام


الأشياء الموضحة أدناه تتطلب من القارئ ، بالإضافة إلى معرفة لغة GO ، معرفة إضافية ، وهي:

  • فهم مبادئ المجدول (على الرغم من أنني سأحاول أن أشرح أدناه ، "على الأصابع")
  • فهم كيفية عمل جامع القمامة
  • فهم ما هو مجمع GO

في النهاية ، سأترك زوجين من الروابط التي ، في رأيي ، تغطي هذه المواضيع بشكل جيد.

لفترة وجيزة عن المخطط


أولاً ، دعني أذكرك ما هو تعدد المهام التعاوني وغير التعاوني.

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

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

بالإضافة إلى ذلك ، فإننا نعتبر حجر الأساس الذي يعمل عليه المجدول مع:

  • P - المعالجات المنطقية (يمكننا تغيير عددهم مع وظيفة وقت التشغيل. GOMAXPROCS) ، على كل معالج منطقي يمكن تنفيذ goroutine واحد بشكل مستقل في وقت واحد.
  • خيوط M - OS. كل P يعمل على خيط من M. لاحظ أن P لا تساوي دائمًا M ، على سبيل المثال ، يمكن حظر خيط بواسطة syscall ومن ثم سيتم تخصيص خيط آخر لـ P. وهناك CGO وغيرها من الفروق الدقيقة الأخرى.
  • ز - جوروتينات. حسنًا ، من الواضح هنا أنه يجب تنفيذ G على كل جهاز P ويقوم المجدول بمراقبة ذلك.

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

وما هي المشكلة في الواقع؟


صورة

منذ بداية المقال ، أدركت بالفعل أن مبدأ عمل المجدول قد تغير في GO ، فلننظر في أسباب إجراء هذه التغييرات. ألق نظرة على الرمز:

تحت المفسد
func main() {
	runtime.GOMAXPROCS(1)
	go func() {
		var u int
		for {
			u -= 2
			if u == 1 {
				break
			}
		}
	}()
	<-time.After(time.Millisecond * 5) //    main   ,         

	fmt.Println("go 1.13 has never been here")
}


إذا قمت بتجميعها باستخدام الإصدار GO <1.14 ، فإن السطر "go 1.13 لم يكن هنا أبدًا" ولن ترى على الشاشة. يحدث هذا لأنه بمجرد أن يمنح المجدول وقتًا للمعالج إلى goroutine بحلقة لانهائية ، فإنه يلتقط P تمامًا ، ولا تحدث مكالمات وظيفية داخل هذا goroutine ، مما يعني أننا لن نوقظ المجدول بعد الآن. وفقط استدعاء صريح لوقت التشغيل. Gosched () سيسمح بإنهاء برنامجنا.

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

اقتراح تحليل


حل هذه المشكلة بسيط للغاية. لنفعل نفس الشيء كما في جدولة نظام التشغيل! كل ما عليك فعله هو ترك GO ينفد goroutine من P ووضع واحد آخر هناك ، ولهذا سنستخدم أدوات OS.

حسنًا ، كيف يتم تنفيذ ذلك؟ سنسمح لوقت التشغيل بإرسال إشارة إلى التدفق الذي يعمل فيه الغوروتين. سنقوم بتسجيل معالج هذه الإشارة في كل دفق من M ، ومهمة المعالج هي تحديد ما إذا كان يمكن استبدال الغوروتين الحالي. إذا كان الأمر كذلك ، فسنحفظ حالتها الحالية (السجلات وحالة المكدس) ونعطي الموارد إلى دولة أخرى ، وإلا فإننا سنستمر في تنفيذ الغوروتين الحالي. تجدر الإشارة إلى أن المفهوم بالإشارة هو حل للأنظمة الأساسية لـ UNIX ، بينما ، على سبيل المثال ، يختلف تطبيق Windows قليلاً. بالمناسبة ، تم اختيار SIGURG كإشارة للإرسال.

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

هل ذهبت إلى هناك ، GC؟


صورة

في الإصدارات السابقة لـ 1.12 ، استخدم Gosched وقت التشغيل النقاط الآمنة في الأماكن حيث يمكنك بالتأكيد استدعاء المجدول دون خوف من أن ينتهي بنا المطاف في القسم الذري من الرمز الخاص بـ GC. كما قلنا من قبل ، توجد بيانات النقاط الآمنة في مقدمة دالة (ولكن ليس كل وظيفة ، فكر فيك). إذا قمت بتفكيك المجمّع go-shn ، يمكنك الاعتراض - لا توجد مكالمات جدولة واضحة مرئية هناك. نعم ، ولكن يمكنك العثور على تعليمات مكالمات runtime.morestack هناك ، وإذا نظرت داخل هذه الوظيفة ، فسيتم العثور على مكالمة جدولة. تحت المفسد ، سأخفي التعليق من مصادر GO ، أو يمكنك العثور على المجمع لمزيد من الوقف بنفسك.

وجدت في المصدر
Synchronous safe-points are implemented by overloading the stack bound check in function prologues. To preempt a goroutine at the next synchronous safe-point, the runtime poisons the goroutine's stack bound to a value that will cause the next stack bound check to fail and enter the stack growth implementation, which will detect that it was actually a preemption and redirect to preemption handling.

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


j := &someStruct{}
p := unsafe.Pointer(j)
// unsafe-point start
u := uintptr(p)
//do some stuff here
p = unsafe.Pointer(u)
// unsafe-point end

لفهم ماهية المشكلة ، دعنا نحاول على جلد جامع القمامة. في كل مرة نذهب إلى العمل ، نحتاج إلى معرفة العقد الجذرية (المؤشرات على المكدس وفي السجلات) ، والتي سنبدأ في وضع العلامات عليها. نظرًا لأنه من المستحيل أن نقول في وقت التشغيل ما إذا كانت 64 بايت في الذاكرة عبارة عن مؤشر أو مجرد رقم ، فإننا ننتقل إلى المكدس وتسجيل بطاقات (بعض ذاكرة التخزين المؤقت بمعلومات وصفية) ، يرجى توفيرها لنا من قبل مترجم GO. تسمح لنا المعلومات الواردة في هذه الخرائط بالعثور على المؤشرات. لذلك ، استيقظنا وأرسلنا إلى العمل عندما نفذت GO السطر رقم 4. عند الوصول إلى المكان والنظر في البطاقات ، وجدنا أنها كانت فارغة (وهذا صحيح ، لأن uintptr من وجهة نظر GC هو رقم وليس مؤشرًا). حسنًا ، سمعنا بالأمس عن تخصيص الذاكرة لـ j ، حيث لا يمكننا الآن الوصول إلى هذه الذاكرة - نحتاج إلى تنظيفها ، وبعد إزالة الذاكرة نذهب إلى النوم.ماذا بعد؟ حسنًا ، استيقظت السلطات ، في الليل ، تصرخ ، أنت تفهم نفسك.

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

دعنا ننتقل إلى الممارسة


ركضت مرتين (اذهب 1.14 وانتقل 1.13) مثال من بداية المقال من قبل بروفيل بيرفور من أجل معرفة مكالمات النظام التي تحدث ومقارنتها. تم العثور على syscall المطلوب في الإصدار الرابع عشر بسرعة كبيرة:

15.652 ( 0.003 ms): main/29614 tgkill(tgid: 29613 (main), pid: 29613 (main), sig: URG                ) = 0

حسنًا ، من الواضح أن وقت التشغيل أرسل SIGURG إلى الخيط الذي يدور فيه الغوروتين. مع الأخذ في الاعتبار هذه المعرفة كنقطة انطلاق ، نظرت إلى عمليات التنفيذ في GO للعثور على مكان إرسال هذه الإشارة ولأي سبب ، وأيضًا للعثور على المكان الذي تم تثبيت معالج الإشارة فيه. لنبدأ بالإرسال ، سنجد وظيفة إرسال الإشارة في runtime / os_linux.go


func signalM(mp *m, sig int) {
	tgkill(getpid(), int(mp.procid), sig)
}

الآن نجد أماكن في رمز وقت التشغيل ، حيث نرسل الإشارة:

  1. عندما يتم تعليق الغوروتين ، إذا كان في حالة تشغيل. يأتي طلب التعليق من جامع القمامة. هنا ، ربما ، لن أقوم بإضافة كود ، ولكن يمكن العثور عليه في وقت تشغيل الملف / preempt.go (suspendG)
  2. إذا قرر المجدول أن الغوروتين يعمل لفترة طويلة ، فإن وقت التشغيل / proc.go (إعادة)
    
    if pd.schedwhen+forcePreemptNS <= now {
    	signalM(_p_)
    }
    

    forcePreemptNS - ثابت يساوي 10 مللي ثانية ، pd.schedwhen - الوقت الذي تم فيه استدعاء برنامج جدولة تدفق pd آخر مرة
  3. بالإضافة إلى جميع التدفقات التي يتم إرسال هذه الإشارة خلال حالة من الذعر ، StopTheWorld (GC) وعدد قليل من الحالات الأخرى (التي يجب أن أتجاوزها ، لأن حجم المقالة سيتجاوز بالفعل الحدود)

اكتشفنا كيف ومتى يرسل وقت التشغيل إشارة إلى M. الآن دعنا نجد معالج هذه الإشارة ونرى ما يفعله الدفق عند تلقيه.


func doSigPreempt(gp *g, ctxt *sigctxt) {
	if wantAsyncPreempt(gp) && isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()) {
		// Inject a call to asyncPreempt.
		ctxt.pushCall(funcPC(asyncPreempt))
	}
}

من هذه الوظيفة ، من الواضح أنه من أجل "التثبيت" ، يجب أن تمر من خلال عمليتي تحقق:

  1. wantAsyncPreempt - نتحقق مما إذا كان G يريد الإجبار على الخروج ، هنا ، على سبيل المثال ، سيتم التحقق من صحة حالة الغوروتين الحالية.
  2. isAsyncSafePoint - تحقق مما إذا كان يمكن أن يكون مزدحمًا الآن. الأكثر إثارة للاهتمام من عمليات التحقق هنا هو ما إذا كانت G في نقطة آمنة أو غير آمنة. بالإضافة إلى ذلك ، يجب أن نتأكد من أن الخيط الذي يعمل عليه G جاهز أيضًا لاستباق G.

إذا تم تمرير كلا الشيكين ، فسيتم استدعاء التعليمات من التعليمات البرمجية القابلة للتنفيذ التي تحفظ الحالة G وتستدعي المجدول.

وأكثر حول غير آمنة


أقترح تحليل مثال جديد ، سيوضح حالة أخرى بنقطة غير آمنة:

برنامج آخر لا نهاية له

//go:nosplit
func infiniteLoop() {
	var u int
	for {
		u -= 2
		if u == 1 {
			break
		}
	}
}

func main() {
	runtime.GOMAXPROCS(1)
	go infiniteLoop()
	<-time.After(time.Millisecond * 5)

	fmt.Println("go 1.13 and 1.14 has never been here")
}


كما قد تتوقع ، نقش "اذهب 1.13 و 1.14 لم يكن هنا من قبل" لن نرى في GO 1.14. هذا لأننا منعنا بشكل صريح من مقاطعة وظيفة infiniteLoop (go: nosplit). يتم تطبيق مثل هذا الحظر فقط باستخدام نقطة غير آمنة ، وهي عبارة عن جسم الوظيفة بالكامل. دعونا نرى ما تم إنشاؤه للمترجم للدالة infiniteLoop.

مجمع الحذر

        0x0000 00000 (main.go:10)   TEXT    "".infiniteLoop(SB), NOSPLIT|ABIInternal, $0-0
        0x0000 00000 (main.go:10)   PCDATA  $0, $-2
        0x0000 00000 (main.go:10)   PCDATA  $1, $-2
        0x0000 00000 (main.go:10)   FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:10)   FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:10)   FUNCDATA        $2, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:10)   XORL    AX, AX
        0x0002 00002 (main.go:12)   JMP     8
        0x0004 00004 (main.go:13)   ADDQ    $-2, AX
        0x0008 00008 (main.go:14)   CMPQ    AX, $3
        0x000c 00012 (main.go:14)   JNE     4
        0x000e 00014 (main.go:15)   PCDATA  $0, $-1
        0x000e 00014 (main.go:15)   PCDATA  $1, $-1
        0x000e 00014 (main.go:15)   RET


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

كما نرى في السطور 10 و 15 ، وضعنا القيمتين $ 2 و -1 في الخريطة $ 0 و $ 1 ، على التوالي. دعونا نتذكر هذه اللحظة ونلقي نظرة داخل وظيفة isAsyncSafePoint ، التي لفتت انتباهك إليها بالفعل. هناك سنرى الأسطر التالية:

isAsyncSafePoint

	smi := pcdatavalue(f, _PCDATA_RegMapIndex, pc, nil)
	if smi == -2 {
		return false
	}


في هذا المكان نتحقق مما إذا كان الغوروتين موجودًا حاليًا في مكان آمن. ننتقل إلى خريطة التسجيلات (_PCDATA_RegMapIndex = 0) ، ونمررها إلى جهاز الكمبيوتر الحالي الذي نتحقق من القيمة ، إذا كان -2 إذن G ليس في مكان آمن ، مما يعني أنه لا يمكن أن يكون مزدحمًا.

استنتاج


أوقفت "بحثي" حول هذا ، آمل أن تكون المقالة مفيدة لك أيضًا.
أنشر الروابط الموعودة ، ولكن يرجى توخي الحذر ، لأن بعض المعلومات في هذه المقالات قد تكون قديمة.

GO جدولة - مرة و مرتين .

المجمع GO.

All Articles