gmp
من أهم خصائص لغة Go دعمها الطبيعي للتزامن، حيث يكفي كلمة مفتاحية واحدة لبدء كوروتين، كما في المثال التالي.
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
fmt.Println("hello world!")
}()
go func() {
defer wg.Done()
fmt.Println("hello world too!")
}()
wg.Wait()
}استخدام الكوروتينات في Go بهذه البساطة، بحيث لا يحتاج المطور لعمل إضافي تقريبًا، وهذا أحد أسباب شعبيتها. لكن خلف هذه البساطة، يوجد جدول تزامن معقد يدعم كل هذا، واسمه يجب أن يكون قد سمع به معظمكم، لأن مشاركه الأساسيون هم G (الكوروتين)، M (خيط النظام)، P (المعالج)، لذا يُسمى جدولة GMP. تصميم جدولة GMP يؤثر على تصميم بيئة تشغيل Go بالكامل، GC، محلقات الشبكة، يمكن القول أنه أهم جزء في اللغة، وفهمه قد يساعد في العمل المستقبلي.
التاريخ
نموذج جدولة التزامن في Go ليس أصليًا بالكامل، بل استفاد من تجارب ودروس السابقين، وبعد التطور والتحسين المستمر وصل لشكله الحالي. اللغات التي استوحى منها تشمل:
- Occam -1983
- Erlang - 1986
- Newsqueak - 1988
- Concurrent ML - 1993
- Alef - 1995
- Limbo - 1996
التأثير الأكبر كان من ورقة هور سنة 1978 عن CSP (Communicate Sequential Process)، فكرتها الأساسية أن العمليات تتبادل البيانات عبر الاتصال. كل اللغات المذكورة تأثرت بفكرة CSP، وErlang هي الأكثر تمثيلًا للغة البرمجة الموجهة للرسائل، والوسيطة الشهيرة RabbitMQ مكتوبة بـ Erlang. اليوم، مع تطور الحواسيب والإنترنت، دعم التزامن أصبح معيارًا في اللغات الحديثة، ولغة Go المدمجة مع أفكار CSP وُلدت.
نموذج الجدولة
نبدأ بتعريف بسيط لأعضاء GMP الثلاثة:
- G، Goroutine،指的是 go 语言中的协程
- M، Machine،指的是系统线程或者叫工作线程(worker thread),由操作系统来负责调度
- P، Processor,并非指 CPU 处理器,是 go 自己抽象的一个概念,指的是工作在系统线程上的处理器,通过它来调度每一个系统线程上的协程
الكوروتين خيط أخف، أصغر حجمًا، يحتاج موارد أقل، وإنشاءه وتدميره وجدولته تتم بواسطة بيئة تشغيل Go وليس نظام التشغيل، لذا تكلفة إدارته أقل بكثير من الخيوط. لكن الكوروتين يعتمد على الخيط، شريحة الوقت للتنفيذ تأتي من الخيط، وشريحة وقت الخيط تأتي من نظام التشغيل، والتبديل بين الخيوط له تكلفة، كيف يجعل الكوروتين يستفيد من شريحة وقت الخيط هو مفتاح التصميم.
1:N
أفضل طريقة لحل المشكلة هي تجاهلها،既然线程切换是有成本的,那直接不切换就行了。将所有的协程都分配到一个内核线程上,这样就只涉及到了协程间的切换。

العلاقة بين الخيط والكوروتين هي 1:N، وهذا له عيب واضح، حواسيب اليوم متعددة النوى، وهذا التوزيع لا يستفيد من أداء المعالجات متعددة النوى.
N:N
طريقة أخرى، خيط واحد لكوروتين واحد، الكوروتين يستفيد من كل شريحة وقت الخيط، وخيوط متعددة تستفيد من المعالجات متعددة النوى. لكن، تكلفة إنشاء وتبديل الخيوط عالية، إذا كانت العلاقة واحد لواحد، لا تُستغل ميزة خفة الكوروتين.

M:N
M خيط لـ N كوروتين، وM أقل من N. خيوط متعددة لكوروتينات متعددة، كل خيط يقابله عدة كوروتينات، والمعالج P يتحكم في كيفية استخدام الكوروتين G لشريحة وقت الخيط. هذه الطريقة أفضل، وهي نموذج الجدولة الذي تستخدمه Go حتى اليوم.
M لا ينفذ المهام إلا بعد الارتباط بالمعالج P، وGo تنشئ GOMAXPROCS معالجًا، لذا عدد الخيوط التي تنفذ المهام هو GOMAXPROCS، وقيمته الافتراضية هي عدد النوى المنطقية للمعالج، ويمكن تعديله يدويًا.
- عبر الكود
runtime.GOMAXPROCS(N)، ويمكن تعديله ديناميكيًا أثناء التشغيل، وبعد الاستدعاء يتوقف العالم مباشرة (STW). - ضبط متغير البيئة
export GOMAXPROCS=N، ثابت.
في الواقع، عدد M أكبر من عدد P، لأن بيئة التشغيل تحتاجها لمهام أخرى مثل استدعاءات النظام، والحد الأقصى هو 10000.

GMP والمجدول نفسه لكل منهم نوع في بيئة التشغيل، موجودة في ملف runtime/runtime2.go، وفيما يلي شرح بسيط لهياكلهم.
G
G في بيئة التشغيل هو البنية runtime.g، وهو وحدة الجدولة الأساسية، وهيكله كالتالي (بعد حذف حقول كثيرة للتسهيل):
type g struct {
stack stack // offset known to runtime/cgo
_panic *_panic // innermost panic - offset known to liblink
_defer *_defer // innermost defer
m *m // current m; offset known to arm liblink
sched gobuf
goid uint64
waitsince int64 // approx time when the g become blocked
waitreason waitReason // if status==Gwaiting
atomicstatus atomic.Uint32
preempt bool // preemption signal, duplicates stackguard0 = stackpreempt
startpc uintptr // pc of goroutine function
parentGoid uint64 // goid of goroutine that created this goroutine
waiting *sudog // sudog structures this g is waiting on (that have a valid elem ptr); in lock order
}أول حقل هو عنوان بداية ونهاية ذاكرة المكدس الخاص بالكوروتين:
type stack struct {
lo uintptr
hi uintptr
}_panic و _defer مؤشرات لمكدس panic ومكدس defer:
_panic *_panic // innermost panic - offset known to liblink
_defer *_defer // innermost deferm هو الخيط الذي ينفذ الكوروتين الحالي:
m *m // current m; offset known to arm liblinkpreempt يشير هل الكوروتين يحتاج للاستباق، يساوي g.stackguard0 = stackpreempt:
preempt bool // preemption signal, duplicates stackguard0 = stackpreemptatomicstatus يخزن حالة الكوروتين G، وله القيم التالية:
| الاسم | الوصف |
|---|---|
| _Gidle | تم تخصيصه ولم تتم تهيئته |
| _Grunnable | الكوروتين يمكنه العمل، في قائمة الانتظار |
| _Grunning | الكوروتين ينفذ كود المستخدم |
| _Gsyscall | تم تخصيص M له لتنفيذ استدعاء نظام |
| _Gwaiting | الكوروتين محجوب، سبب الحجب موضح أدناه |
| _Gdead | الكوروتين غير مستخدم، قد خرج لتوه أو تهيأ لتوه |
| _Gcopystack | مكدس الكوروتين يُنقل، لا ينفذ كود المستخدم ولا في قائمة الانتظار |
| _Gpreempted | حجب نفسه للاستباق، ينتظر إيقاظه |
| _Gscan | GC يمسح مساحة مكدس الكوروتين، يمكن أن يتواجد مع حالات أخرى |
sched يخزن معلومات سياق الكوروتين لاستعادة حالة التنفيذ، وبداخله مؤشرات sp، pc، ret:
type gobuf struct {
sp uintptr
pc uintptr
g guintptr
ctxt unsafe.Pointer
ret uintptr
lr uintptr
bp uintptr // for framepointer-enabled architectures
}waiting يشير للكوروتين الذي ينتظره الحالي، waitsince يسجل وقت الحجب، waitreason سبب الحجب، والقيم الممكنة:
var waitReasonStrings = [...]string{
waitReasonZero: "",
waitReasonGCAssistMarking: "GC assist marking",
waitReasonIOWait: "IO wait",
waitReasonChanReceiveNilChan: "chan receive (nil chan)",
waitReasonChanSendNilChan: "chan send (nil chan)",
waitReasonDumpingHeap: "dumping heap",
waitReasonGarbageCollection: "garbage collection",
waitReasonGarbageCollectionScan: "garbage collection scan",
waitReasonPanicWait: "panicwait",
waitReasonSelect: "select",
waitReasonSelectNoCases: "select (no cases)",
waitReasonGCAssistWait: "GC assist wait",
waitReasonGCSweepWait: "GC sweep wait",
waitReasonGCScavengeWait: "GC scavenge wait",
waitReasonChanReceive: "chan receive",
waitReasonChanSend: "chan send",
waitReasonFinalizerWait: "finalizer wait",
waitReasonForceGCIdle: "force gc (idle)",
waitReasonSemacquire: "semacquire",
waitReasonSleep: "sleep",
waitReasonSyncCondWait: "sync.Cond.Wait",
waitReasonSyncMutexLock: "sync.Mutex.Lock",
waitReasonSyncRWMutexRLock: "sync.RWMutex.RLock",
waitReasonSyncRWMutexLock: "sync.RWMutex.Lock",
waitReasonTraceReaderBlocked: "trace reader (blocked)",
waitReasonWaitForGCCycle: "wait for GC cycle",
waitReasonGCWorkerIdle: "GC worker (idle)",
waitReasonGCWorkerActive: "GC worker (active)",
waitReasonPreempted: "preempted",
waitReasonDebugCall: "debug call",
waitReasonGCMarkTermination: "GC mark termination",
waitReasonStoppingTheWorld: "stopping the world",
}goid و parentGoid هما المعرف الفريد للكوروتين الحالي والأب، startpc عنوان دالة دخول الكوروتين.
M
M في بيئة التشغيل هو البنية runtime.m، تجريد لخيط العمل:
type m struct {
id int64
g0 *g // goroutine with scheduling stack
curg *g // current running goroutine
gsignal *g // signal-handling g
goSigStack gsignalStack // Go-allocated signal handling stack
p puintptr // attached p for executing go code (nil if not executing go code)
nextp puintptr
oldp puintptr // the p that was attached before executing a syscall
mallocing int32
throwing throwType
preemptoff string // if != "", keep curg running on this m
locks int32
dying int32
spinning bool // m is out of work and is actively looking for work
tls [tlsSlots]uintptr
...
}حقول M كثيرة، نشرح بعضها:
id، المعرف الفريد لـ Mg0، الكوروتين الذي يمتلك مكدس الجدولةcurg، كود المستخدم الذي يعمل على خيط العملgsignal، الكوروتين المسؤول عن معالجة إشارات الخيطgoSigStack، مساحة المكدس المخصصة لمعالجة الإشاراتp، عنوان المعالج P،oldpيشير لـ P قبل استدعاء النظام،nextpيشير لـ P الجديدmallocing، يشير هل يتم تخصيص ذاكرة جديدةthrowing، نوع الخطأ الذي حدث في Mpreemptoff، معرف الاستباق، عندما يكون سلسلة فارغة يمكن استباق الكوروتينlocks، عدد "الأقفال" في M، إذا لم يكن صفرًا يُمنع الاستباقdying، يشير أن M حدث لهpanicلا يمكن علاجه، له 4 قيم[0,3]تمثل الخطورةspinning، يشير أن M في حالة خمول وجاهز للعملtls، التخزين المحلي للخيط
P
P في بيئة التشغيل هو runtime.p، مسؤول عن جدولة العمل بين M و G:
type p struct {
id int32
status uint32 // one of pidle/prunning/...
schedtick uint32 // incremented on every scheduler call
syscalltick uint32 // incremented on every system call
sysmontick sysmontick // last tick observed by sysmon
m muintptr // back-link to associated m (nil if idle)
// Queue of runnable goroutines. Accessed without lock.
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr
// Available G's (status == Gdead)
gFree struct {
gList
n int32
}
// preempt is set to indicate that this P should be enter the
// scheduler ASAP (regardless of what G is running on it).
preempt bool
...
}status حالة P، لها القيم التالية:
| القيمة | الوصف |
|---|---|
| _Pidle | P في حالة خمول، يمكن للمجدول تخصيص M له |
| _Prunning | P مرتبط بـ M وينفذ كود المستخدم |
| _Psyscall | M المرتبط بـ P ينفذ استدعاء نظام، P يمكن استباقه من M آخر |
| _Pgcstop | P توقف بسبب GC |
| _Pdead | معظم موارد P سُحبت، لن يُستخدم بعد الآن |
الحقول التالية تسجل قائمة runq المحلية لـ P، الحد الأقصى 256، إذا زاد يوضع G في القائمة العامة:
runqhead uint32
runqtail uint32
runq [256]guintptrrunnext يشير لـ G التالي المتاح:
runnext guintptrحقول أخرى:
id، المعرف الفريد لـ Pschedtick، يزداد مع كل جدولة كوروتينsyscalltick، يزداد مع كل استدعاء نظامsysmontick، يسجل آخر معلومات من مراقب النظامm، M المرتبط بـ PgFree، قائمة G الحرةpreempt، يشير أن P يجب أن يدخل الجدولة مرة أخرى
معلومات القائمة العامة موجودة في runtime.schedt، تمثيل المجدول في بيئة التشغيل:
type schedt struct {
...
midle muintptr // idle m's waiting for work
ngsys atomic.Int32 // number of system goroutines
pidle puintptr // idle p's
// Global runnable queue.
runq gQueue
runqsize int32
...
}التهيئة
تهيئة المجدول في مرحلة توجيه برنامج Go، المسؤول عن التوجيه هو الدالة runtime.rt0_go، مكتوبة بالتجميع في ملف runtime/asm_*.s:
TEXT runtime·rt0_go(SB),NOSPLIT|NOFRAME|TOPFRAME,$0
...
...
CALL runtime·check(SB)
MOVL 24(SP), AX // copy argc
MOVL AX, 0(SP)
MOVQ 32(SP), AX // copy argv
MOVQ AX, 8(SP)
CALL runtime·args(SB)
CALL runtime·osinit(SB)
CALL runtime·schedinit(SB)
// create a new goroutine to start program
MOVQ $runtime·mainPC(SB), AX // entry
PUSHQ AX
CALL runtime·newproc(SB)
POPQ AX
// start this M
CALL runtime·mstart(SB)
CALL runtime·abort(SB) // mstart should never return
RETنرى استدعاء runtime·osinit و runtime·schedinit.
الأولى تهيئ أمور نظام التشغيل، الثانية تهيئ المجدول، أي runtime·schedinit. تهيئ موارد المجدول عند بدء البرنامج:
func schedinit() {
...
gp := getg()
sched.maxmcount = 10000
// The world starts stopped.
worldStopped()
...
stackinit()
mallocinit()
mcommoninit(gp.m, -1)
lock(&sched.lock)
procs := ncpu
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n
}
if procresize(procs) != nil {
throw("unknown runnable goroutine during bootstrap")
}
unlock(&sched.lock)
...
// World is effectively started now, as P's can run.
worldStarted()
...
}runtime.getg مكتوبة بالتجميع، تحصل على تمثيل الكوروتين الحالي، أي مؤشر runtime.g. عبر sched.maxmcount = 10000 نرى أن الحد الأقصى لعدد M هو 10000، ثابت ولا يمكن تغييره. ثم تهيئة المكدس، ثم runtime.mcommoninit تهيئ M:
func mcommoninit(mp *m, id int64) {
gp := getg()
// g0 stack won't make sense for user (and is not necessary unwindable).
if gp != gp.m.g0 {
callers(1, mp.createstack[:])
}
lock(&sched.lock)
if id >= 0 {
mp.id = id
} else {
mp.id = mReserveID()
}
...
mpreinit(mp)
if mp.gsignal != nil {
mp.gsignal.stackguard1 = mp.gsignal.stack.lo + stackGuard
}
// Add to allm so garbage collector doesn't free g->m
// when it is just in a register or thread-local storage.
mp.alllink = allm
// NumCgoCall() iterates over allm w/o schedlock,
// so we need to publish it safely.
atomicstorep(unsafe.Pointer(&allm), unsafe.Pointer(mp))
unlock(&sched.lock)
...
}تهيئ M مسبقًا، وتقوم بـ:
- تخصيص id لـ M
- تخصيص G منفصل لمعالجة إشارات الخيط، عبر
runtime.mpreinit - جعله عقدة رأس في القائمة العامة M
runtime.allm
ثم تهيئة P، عدده الافتراضي عدد النوى المنطقية للمعالج، أو قيمة متغير البيئة:
procs := ncpu
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n
}
if procresize(procs) != nil {
throw("unknown runnable goroutine during bootstrap")
}أخيرًا runtime.procresize تهيئ P، تعدل runtime.allp حسب العدد. أولاً تتحقق من الحاجة للتوسيع:
if nprocs > int32(len(allp)) {
// Synchronize with retake, which could be running
// concurrently since it doesn't run on a P.
lock(&allpLock)
if nprocs <= int32(cap(allp)) {
allp = allp[:nprocs]
} else {
nallp := make([]*p, nprocs)
// Copy everything up to allp's cap so we
// never lose old allocated Ps.
copy(nallp, allp[:cap(allp)])
allp = nallp
}
unlock(&allpLock)
}ثم تهيئ كل P:
// initialize new P's
for i := old; i < nprocs; i++ {
pp := allp[i]
if pp == nil {
pp = new(p)
}
pp.init(i)
atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp))
}إذا كان P الذي يستخدمه الكوروتين الحالي يحتاج للتدمير، يُستبدل بـ allp[0]، عبر runtime.acquirep:
gp := getg()
if gp.m.p != 0 && gp.m.p.ptr().id < nprocs {
gp.m.p.ptr().status = _Prunning
gp.m.p.ptr().mcache.prepareForSweep()
} else {
if gp.m.p != 0 {
gp.m.p.ptr().m = 0
}
gp.m.p = 0
pp := allp[0]
pp.m = 0
pp.status = _Pidle
acquirep(pp)
}ثم تدمر P غير المطلوبة، تحرر مواردها، تضع كل G في قائمتها المحلية في القائمة العامة:
// release resources from unused P's
for i := nprocs; i < old; i++ {
pp := allp[i]
pp.destroy()
// can't free P itself because it can be referenced by an M in syscall
}
// Trim allp.
if int32(len(allp)) != nprocs {
lock(&allpLock)
allp = allp[:nprocs]
unlock(&allpLock)
}أخيرًا تربط P الحرة في قائمة وتعيد رأسها:
var runnablePs *p
for i := nprocs - 1; i >= 0; i-- {
pp := allp[i]
if gp.m.p.ptr() == pp {
continue
}
pp.status = _Pidle
if runqempty(pp) {
pidleput(pp, now)
} else {
pp.m.set(mget())
pp.link.set(runnablePs)
runnablePs = pp
}
}
return runnablePsبعد ذلك، انتهت تهيئة المجدول، و runtime.worldStarted يعيد تشغيل كل P.
MOVQ $runtime·mainPC(SB), AX // entry
PUSHQ AX
CALL runtime·newproc(SB)
POPQ AX
// start this M
CALL runtime·mstart(SB)ثم عبر runtime.newproc يُنشأ كوروتين جديد لبدء برنامج Go، ثم runtime.mstart يبدأ تشغيل المجدول رسميًا، مكتوب بالتجميع، وداخله runtime.mstart0:
gp := getg()
osStack := gp.stack.lo == 0
if osStack {
size := gp.stack.hi
if size == 0 {
size = 16384 * sys.StackGuardMultiplier
}
gp.stack.hi = uintptr(noescape(unsafe.Pointer(&size)))
gp.stack.lo = gp.stack.hi - size + 1024
}
gp.stackguard0 = gp.stack.lo + stackGuard
gp.stackguard1 = gp.stackguard0
mstart1()في هذه المرحلة M يمتلك كوروتين واحد g0، يستخدم مكدس النظام. mstart0 تهيئ حدود مكدس G، ثم mstart1 تكمل التهيئة:
gp := getg()
gp.sched.g = guintptr(unsafe.Pointer(gp))
gp.sched.pc = getcallerpc()
gp.sched.sp = getcallersp()
asminit()
minit()
if gp.m == &m0 {
mstartm0()
}
if fn := gp.m.mstartfn; fn != nil {
fn()
}
if gp.m != &m0 {
acquirep(gp.m.nextp.ptr())
gp.m.nextp = 0
}
schedule()قبل البدء، تسجل حالة التنفيذ الحالية، لأنه بعد التهيئة الناجحة سيدخل حلقة الجدولة ولن يعود أبدًا. بعد التسجيل، runtime.asminit و runtime.minit تهيئان مكدس النظام، ثم runtime.mstartm0 تضبط معاودة الاتصال للإشارات. بعد تنفيذ معاودة الاتصال m.mstartfn، runtime.acquirep تربط M بـ P المُنشأ مسبقًا، وأخيرًا تدخل حلقة الجدولة.
هذا الاستدعاء runtime.schedule هو أول جولة جدولة في بيئة تشغيل Go،标志着 المجدول يبدأ العمل رسميًا.
الخيوط
في المجدول، G لتنفيذ كود المستخدم يحتاج P، و P ليعمل يحتاج الارتباط بـ M، و M指的是 خيط النظام.
الإنشاء
إنشاء M يتم عبر runtime.newm، تقبل دالة و P و id كمعاملات:
func newm(fn func(), pp *p, id int64) {
acquirem()
mp := allocm(pp, fn, id)
mp.nextp.set(pp)
mp.sigmask = initSigmask
newm1(mp)
releasem(getg().m)
}قبل البدء، newm تستدعي runtime.allocm لإنشاء تمثيل الخيط في بيئة التشغيل أي M، و runtime.mcommoninit تهيئ حدود مكدس M:
func allocm(pp *p, fn func(), id int64) *m {
allocmLock.rlock()
// The caller owns pp, but we may borrow (i.e., acquirep) it. We must
// disable preemption to ensure it is not stolen, which would make the
// caller lose ownership.
acquirem()
gp := getg()
if gp.m.p == 0 {
acquirep(pp) // temporarily borrow p for mallocs in this function
}
mp := new(m)
mp.mstartfn = fn
mcommoninit(mp, id)
mp.g0.m = mp
releasem(gp.m)
allocmLock.runlock()
return mp
}ثم runtime.newm1 تستدعي runtime.newosproc لإنشاء خيط النظام فعليًا:
func newm1(mp *m) {
execLock.rlock()
newosproc(mp)
execLock.runlock()
}runtime.newosproc يختلف حسب نظام التشغيل، و runtime.mstart يبدأ عمل M.
الخروج
runtime.gogo(&mp.g0.sched)ذكرنا سابقًا أن عند استدعاء mstart1 حُفظت حالة التنفيذ في حقل sched لـ g0، وتمريره لـ runtime.gogo (مكتوب بالتجميع) يجعل الخيط يقفز لاستئناف التنفيذ، لأن الحفظ استخدم getcallerpc()، الاستئناف يعود لدالة mstart0:
mstart1()
if mStackIsSystemAllocated() {
osStack = true
}
mexit(osStack)بعد استئناف حالة التنفيذ، حسب ترتيب التنفيذ يدخل mexit للخروج من الخيط:
mp := getg().m
unminit()
lock(&sched.lock)
for pprev := &allm; *pprev != nil; pprev = &(*pprev).alllink {
if *pprev == mp {
*pprev = mp.alllink
}
}
mp.freeWait.Store(freeMWait)
mp.freelink = sched.freem
sched.freem = mp
unlock(&sched.lock)
handoffp(releasep())
mdestroy(mp)
exitThread(&mp.freeWait)يقوم بالأعمال الرئيسية التالية:
- استدعاء
runtime.uminitللتراجع عن عملruntime.minit - حذف M من المتغير العام
allm - جعل
freemللمجدول يشير لـ M الحالي - عبر
runtime.releasepفصل P عن M الحالي، وruntime.handoffpيجعل P يرتبط بـ M آخر - عبر
runtime.destroyتدمير موارد M - أخيرًا نظام التشغيل يخرج الخيط
بهذا يخرج M بنجاح.
الإيقاف
عند الحاجة لإيقاف M بسبب المجدول أو GC أو استدعاء نظام، تُستدعى runtime.stopm:
func stopm() {
gp := getg()
lock(&sched.lock)
mput(gp.m)
unlock(&sched.lock)
mPark()
acquirep(gp.m.nextp.ptr())
gp.m.nextp = 0
}أولاً تضع M في قائمة M الحرة العامة، ثم mPark() تحجب الخيط الحالي عند notesleep(&gp.m.park)، وعند الإيقاظ تعود الدالة:
func mPark() {
gp := getg()
notesleep(&gp.m.park)
noteclear(&gp.m.park)
}بعد الإيقاظ، M يبحث عن P للارتباط به لمتابعة تنفيذ المهام.
الكوروتين
دورة حياة الكوروتين توافق حالاته، فهم دورة الحياة يساعد في فهم المجدول، لأن المجدول مصمم حول الكوروتين:

_Gcopystack حالة امتلاكها عند توسيع مكدس الكوروتين، تُشرح في قسم مكدس الكوروتين.
الإنشاء
إنشاء الكوروتين من الناحية النحوية يحتاج فقط كلمة go مفتاحية ودالة:
go doSomething()بعد الترجمة يصبح استدعاء runtime.newproc:
func newproc(fn *funcval) {
gp := getg()
pc := getcallerpc()
systemstack(func() {
newg := newproc1(fn, gp, pc)
pp := getg().m.p.ptr()
runqput(pp, newg, true)
if mainStarted {
wakep()
}
})
}runtime.newproc1 تقوم بالإنشاء الفعلي، أولاً تقفل M وتمنع الاستباق، ثم تبحث في قائمة gfree المحلية لـ P عن G حر لإعادة استخدامه، إذا لم تجد تنشئ G جديد عبر runtime.malg وتخصص له مكدس 2kb. حالة G هنا _Gdead:
mp := acquirem() // disable preemption because we hold M and P in local vars.
pp := mp.p.ptr()
newg := gfget(pp)
if newg == nil {
newg = malg(stackMin)
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
}في go1.18 وما بعده، نسخ المعاملات لم تعد تتم بواسطة newproc1. الآن فقط تعيد ضبط مساحة مكدس الكوروتين، تجعل runtime.goexit في قاع المكدس للتعامل مع خروج الكوروتين، ثم تضبط PC لدالة الدخول newg.startpc = fn.fn، وبعد الضبط حالة G هي _Grunnable:
totalSize := uintptr(4*goarch.PtrSize + sys.MinFrameSize) // extra space in case of reads slightly beyond frame
totalSize = alignUp(totalSize, sys.StackAlign)
sp := newg.stack.hi - totalSize
spArg := sp
if usesLR {
// caller's LR
*(*uintptr)(unsafe.Pointer(sp)) = 0
prepGoExitFrame(sp)
spArg += sys.MinFrameSize
}
memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
newg.sched.sp = sp
newg.stktopsp = sp
newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function
newg.sched.g = guintptr(unsafe.Pointer(newg))
gostartcallfn(&newg.sched, fn)
newg.parentGoid = callergp.goid
newg.gopc = callerpc
newg.ancestors = saveAncestors(callergp)
newg.startpc = fn.fn
casgstatus(newg, _Gdead, _Grunnable)أخيرًا تضبط المعرف الفريد لـ G، ثم تحرر M وتعيد الكوروتين G:
newg.goid = pp.goidcache
pp.goidcache++
releasem(mp)
return newgبعد إنشاء الكوروتين، runtime.runqput تحاول وضعه في قائمة P المحلية، إذا امتلأت تضعه في القائمة العامة. خلال عملية الإنشاء، حالة G تتغير من _Gidle لـ _Gdead، ثم من _Gdead لـ _Grunnable.
الخروج
عند الإنشاء جعلت Go runtime.goexit في قاع مكدس الكوروتين، لذا عند انتهاء الكوروتين يدخل هذه الدالة، عبر سلسلة الاستدعاء goexit->goexit1->goexit0، أخيرًا runtime.goexit0 تتولى خروج الكوروتين:
func goexit0(gp *g) {
mp := getg().m
pp := mp.p.ptr()
...
casgstatus(gp, _Grunning, _Gdead)
...
gp.m = nil
locked := gp.lockedm != 0
gp.lockedm = 0
mp.lockedg = 0
gp.preemptStop = false
gp.paniconfault = false
gp._defer = nil // should be true already but just in case.
gp._panic = nil // non-nil for Goexit during panic. points at stack-allocated data.
gp.writebuf = nil
gp.waitreason = waitReasonZero
gp.param = nil
gp.labels = nil
gp.timer = nil
dropg()
...
gfput(pp, gp)
...
schedule()
}تقوم بشكل رئيسي بـ:
- ضبط الحالة
_Gdead - إعادة ضبط قيم الحقول
dropg()تقطع العلاقة بين M و Ggfput(pp, gp)تضع G الحالي في قائمة P الحرة المحليةschedule()تدخل جولة جدولة جديدة، تجعل M يتنازل عن حق التنفيذ لـ G أخرى
بعد الخروج، حالة الكوروتين تتغير من _Grunning لـ _Gdead، وقد يُعاد استخدامه عند إنشاء كوروتينات جديدة.
استدعاءات النظام
عندما ينفذ الكوروتين G كود المستخدم ويقوم باستدعاء نظام، هناك طريقتان:
- استدعاءات النظام من مكتبة
syscall - استدعاءات cgo
لأن استدعاء النظام يحجب خيط العمل، يجب التحضير قبل ذلك عبر runtime.entersyscall، ولكنها مجرد استدعاء بسيط لـ runtime.reentersyscall التي تقوم بالعمل الفعلي. أولاً تقفل M الحالي، وخلال التحضير يُمنع استباق G، ويُمنع توسيع المكدس، وضبط gp.stackguard0 = stackPreempt يعني أن بعد التحضير حق تنفيذ P سيُستباق من G أخرى، ثم تحفظ حالة تنفيذ الكوروتين لاستعادتها بعد عودة استدعاء النظام:
gp := getg()
// Disable preemption because during this function g is in Gsyscall status,
// but can have inconsistent g->sched, do not let GC observe it.
gp.m.locks++
// Entersyscall must not call any function that might split/grow the stack.
// (See details in comment above.)
// Catch calls that might, by replacing the stack guard with something that
// will trip any stack check and leaving a flag to tell newstack to die.
gp.stackguard0 = stackPreempt
gp.throwsplit = true
// Leave SP around for GC and traceback.
save(pc, sp)
gp.syscallsp = sp
gp.syscallpc = pcبعد ذلك، لمنع الحجب الطويل من التأثير على تنفيذ G أخرى، M و P ينفصلان، M و G المنفصلان يحجبان بسبب استدعاء النظام، و P بعد الانفصال قد يرتبط بـ M حر آخر ليجعل G الأخرى في قائمة P المحلية تستمر في العمل:
casgstatus(gp, _Grunning, _Gsyscall)
gp.m.syscalltick = gp.m.p.ptr().syscalltick
pp := gp.m.p.ptr()
pp.m = 0
gp.m.oldp.set(pp)
gp.m.p = 0
atomic.Store(&pp.status, _Psyscall)
gp.m.locks--بعد التحضير، يُحرر قفل M، وخلال هذه الفترة حالة G تتغير من _Grunning لـ _Gsyscall، وحالة P تصبح _Psyscall.
عند عودة استدعاء النظام، الخيط M لم يعد محجوبًا، و G المقابل يحتاج للجدولة مرة أخرى لتنفيذ كود المستخدم، عبر runtime.exitsyscall. أولاً تقفل M الحالي وتحصل على مرجع P القديم:
gp := getg()
gp.waitsince = 0
oldp := gp.m.oldp.ptr()
gp.m.oldp = 0حالتان للمعالجة، الأولى هل يوجد P يمكن استخدامه مباشرة، runtime.exitsyscallfast تحكم هل P الأصلي متاح، أي هل حالته _Psyscall، وإلا تبحث عن P حر:
func exitsyscallfast(oldp *p) bool {
gp := getg()
// Freezetheworld sets stopwait but does not retake P's.
if sched.stopwait == freezeStopWait {
return false
}
// Try to re-acquire the last P.
if oldp != nil && oldp.status == _Psyscall && atomic.Cas(&oldp.status, _Psyscall, _Pidle) {
// There's a cpu for us, so we can run.
wirep(oldp)
exitsyscallfast_reacquired()
return true
}
// Try to get any other idle P.
if sched.pidle != 0 {
var ok bool
systemstack(func() {
ok = exitsyscallfast_pidle()
})
if ok {
return true
}
}
return false
}إذا نجحت في إيجاد P متاح، M يرتبط بـ P، و G يتغير من _Gsyscall لـ _Grunning، ثم عبر runtime.Gosched يتنازل G عن حق التنفيذ، و P يدخل حلقة الجدولة للبحث عن G أخرى:
oldp := gp.m.oldp.ptr()
gp.m.oldp = 0
if exitsyscallfast(oldp) {
// There's a cpu for us, so we can run.
gp.m.p.ptr().syscalltick++
// We need to cas the status and scan before resuming...
casgstatus(gp, _Gsyscall, _Grunning)
// Garbage collector isn't running (since we are),
// so okay to clear syscallsp.
gp.syscallsp = 0
gp.m.locks--
if gp.preempt {
// restore the preemption request in case we've cleared it in newstack
gp.stackguard0 = stackPreempt
} else {
// otherwise restore the real stackGuard, we've spoiled it in entersyscall/entersyscallblock
gp.stackguard0 = gp.stack.lo + stackGuard
}
gp.throwsplit = false
if sched.disable.user && !schedEnabled(gp) {
// Scheduling of this goroutine is disabled.
Gosched()
}
return
}إذا لم تجد، M ينفصل عن G، و G يتغير من _Gsyscall لـ _Grunnable، ثم تحاول مرة إيجاد P حر، إذا لم تجد تضع G في القائمة العامة وتدخل جولة جدولة جديدة، و M القديم يدخل حالة خمول عبر runtime.stopm. إذا وجدت P، M القديم و G يرتبطان بـ P الجديد ويستمران تنفيذ كود المستخدم، والحالة تتغير من _Grunnable لـ _Grunning.
func exitsyscall0(gp *g) {
casgstatus(gp, _Gsyscall, _Grunnable)
dropg()
lock(&sched.lock)
var pp *p
if schedEnabled(gp) {
pp, _ = pidleget(0)
}
var locked bool
if pp == nil {
globrunqput(gp)
}
unlock(&sched.lock)
if pp != nil {
acquirep(pp)
execute(gp, false) // Never returns.
}
stopm()
schedule() // Never returns.
}بعد الخروج من استدعاء النظام، حالة G لها نتيجتان: إما _Grunnable تنتظر الجدولة، أو _Grunning تستمر في العمل.
التعليق
عند تعليق الكوروتين الحالي لسبب ما، حالته تتغير من _Grunnable لـ _Gwaiting، أسباب التعليق كثيرة، قد يكون بسبب حجب القناة أو select أو القفل أو time.sleep، للمزيد راجع هيكل G. نأخذ time.Sleep كمثال، هي في الواقع ترتبط بـ runtime.timesleep:
func timeSleep(ns int64) {
if ns <= 0 {
return
}
gp := getg()
t := gp.timer
if t == nil {
t = new(timer)
gp.timer = t
}
t.f = goroutineReady
t.arg = gp
t.nextwhen = nanotime() + ns
if t.nextwhen < 0 { // check for overflow.
t.nextwhen = maxWhen
}
gopark(resetForSleep, unsafe.Pointer(t), waitReasonSleep, traceBlockSleep, 1)
}نرى أنها عبر getg تحصل على الكوروتين الحالي، ثم عبر runtime.gopark تعلق الكوروتين. runtime.gopark تحدث سبب حجب G و M، وتحرر قفل M:
mp := acquirem()
gp := mp.curg
status := readgstatus(gp)
if status != _Grunning && status != _Gscanrunning {
throw("gopark: bad g status")
}
mp.waitlock = lock
mp.waitunlockf = unlockf
gp.waitreason = reason
mp.waitTraceBlockReason = traceReason
mp.waitTraceSkip = traceskip
releasem(mp)
// can't do anything that might move the G between Ms here.
mcall(park_m)ثم تنتقل لمكدس النظام عبر runtime.park_m لتغير حالة G لـ _Gwaiting، ثم تقطع العلاقة بين M و G وتدخل حلقة جدولة جديدة لتتنازل عن حق التنفيذ لـ G أخرى. بعد التعليق، G لا ينفذ كود المستخدم ولا في قائمة P المحلية، فقط يحتفظ بمرجع لـ M و P:
mp := getg().m
casgstatus(gp, _Grunning, _Gwaiting)
dropg()
schedule()في دالة runtime.timesleep هناك سطر يحدد قيمة t.f:
t.f = goroutineReadyهذه الدالة runtime.goroutineReady وظيفتها إيقاظ الكوروتين المعلق، تستدعي runtime.ready:
status := readgstatus(gp)
// Mark runnable.
mp := acquirem()
casgstatus(gp, _Gwaiting, _Grunnable)
runqput(mp.p.ptr(), gp, next)
wakep()
releasem(mp)بعد الإيقاظ، حالة G تتغير لـ _Grunnable، ثم تُوضع في قائمة P المحلية تنتظر الجدولة.
مكدس الكوروتين
الكوروتين في Go هو كوروتين بمكدس نموذجي، عند فتح كل كوروتين يُخصص له مساحة مكدس مستقلة على الكومة، وتكبر أو تصغر حسب الاستخدام. عند تهيئة المجدول، runtime.stackinit تهيئ مخابئ المكدس العامة stackpool و stackLarge:
func stackinit() {
if _StackCacheSize&_PageMask != 0 {
throw("cache size must be a multiple of page size")
}
for i := range stackpool {
stackpool[i].item.span.init()
lockInit(&stackpool[i].item.mu, lockRankStackpool)
}
for i := range stackLarge.free {
stackLarge.free[i].init()
lockInit(&stackLarge.lock, lockRankStackLarge)
}
}إضافة لذلك، كل P له مخبأ مكدس خاص mcache:
type p struct {
...
mcache *mcache
...
}
type mcache struct {
_ sys.NotInHeap
nextSample uintptr
scanAlloc uintptr
tiny uintptr
tinyoffset uintptr
tinyAllocs uintptr
alloc [numSpanClasses]*mspan
stackcache [_NumStackOrders]stackfreelist
flushGen atomic.Uint32
}مخبأ الخيط mcache مستقل لكل خيط وليس على ذاكرة الكومة، الوصول إليه لا يحتاج قفل، وهذه المخابئ الثلاثة ستُستخدم عند تخصيص المساحة.
التخصيص
عند إنشاء كوروتين جديد، إذا لم يوجد كوروتين قابل لإعادة الاستخدام، يُخصص له مكدس جديد، حجمه الافتراضي 2KB:
newg := gfget(pp)
if newg == nil {
newg = malg(stackMin)
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
}الدالة المسؤولة عن تخصيص المكدس هي runtime.stackalloc:
func stackalloc(n uint32) stackحسب حجم المكدس المطلوب أقل من 32KB أم لا تنقسم لحالتين، 32KB هو أيضًا المعيار للتمييز بين الكائنات الصغيرة والكبيرة في Go. إذا أقل من هذه القيمة تُحصل من مخبأ stackpool، وعندما M مرتبط بـ P وM لا يسمح باستباقه، تُحصل من مخبأ الخيط المحلي:
if n < fixedStack<<_NumStackOrders && n < _StackCacheSize {
order := uint8(0)
n2 := n
for n2 > fixedStack {
order++
n2 >>= 1
}
var x gclinkptr
if stackNoCache != 0 || thisg.m.p == 0 || thisg.m.preemptoff != "" {
lock(&stackpool[order].item.mu)
x = stackpoolalloc(order)
unlock(&stackpool[order].item.mu)
} else {
c := thisg.m.p.ptr().mcache
x = c.stackcache[order].list
if x.ptr() == nil {
stackcacherefill(c, order)
x = c.stackcache[order].list
}
c.stackcache[order].list = x.ptr().next
c.stackcache[order].size -= uintptr(n)
}
v = unsafe.Pointer(x)
}إذا أكبر من 32KB، تُحصل من مخبأ stackLarge، وإذا لم تكفِ تُخصص مباشرة على الكومة:
else {
var s *mspan
npage := uintptr(n) >> _PageShift
log2npage := stacklog2(npage)
// Try to get a stack from the large stack cache.
lock(&stackLarge.lock)
if !stackLarge.free[log2npage].isEmpty() {
s = stackLarge.free[log2npage].first
stackLarge.free[log2npage].remove(s)
}
unlock(&stackLarge.lock)
lockWithRankMayAcquire(&mheap_.lock, lockRankMheap)
if s == nil {
// Allocate a new stack from the heap.
s = mheap_.allocManual(npage, spanAllocStack)
if s == nil {
throw("out of memory")
}
osStackAlloc(s)
s.elemsize = uintptr(n)
}
v = unsafe.Pointer(s.base())
}أخيرًا تعيد عنوان المكدس المنخفض والعالي:
return stack{uintptr(v), uintptr(v) + uintptr(n)}التوسيع
حجم مكدس الكوروتين الافتراضي 2KB، خفيف بما يكفي، لذا تكلفة إنشاء كوروتين منخفضة جدًا، لكن هذا قد لا يكفي، وعندما لا يكفي المكدس يحتاج توسيع. المترجم يُدرج دالة runtime.morestack في بداية كل دالة لفحص هل الكوروتين الحالي يحتاج توسيع مكدس، وإذا احتاج يستدعي runtime.newstack للتوسيع الفعلي:
TIP
بما أن morestack تُدرج تقريبًا في بداية كل الدوال، فنقطة فحص توسيع المكدس هي أيضًا نقطة استباق للكوروتين.
thisg := getg()
gp := thisg.m.curg
// Allocate a bigger segment and move the stack.
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize * 2
// The goroutine must be executing in order to call newstack,
// so it must be Grunning (or Gscanrunning).
casgstatus(gp, _Grunning, _Gcopystack)
// The concurrent GC will not scan the stack while we are doing the copy since
// the gp is in a Gcopystack status.
copystack(gp, newsize)
casgstatus(gp, _Gcopystack, _Grunning)
gogo(&gp.sched)نرى أن حجم المكدس المحسوب ضعف السابق، و runtime.copystack تقوم بنسخ المكدس، وقبل النسخ حالة G تتغير من _Grunning لـ _Gcopystack:
func copystack(gp *g, newsize uintptr) {
old := gp.stack
used := old.hi - gp.sched.sp
// allocate new stack
new := stackalloc(uint32(newsize))
// Compute adjustment.
var adjinfo adjustinfo
adjinfo.old = old
adjinfo.delta = new.hi - old.hi
// Copy the stack (or the rest of it) to the new location
memmove(unsafe.Pointer(new.hi-ncopy), unsafe.Pointer(old.hi-ncopy), ncopy)
// Adjust remaining structures that have pointers into stacks.
// We have to do most of these before we traceback the new
// stack because gentraceback uses them.
adjustctxt(gp, &adjinfo)
adjustdefers(gp, &adjinfo)
adjustpanics(gp, &adjinfo)
if adjinfo.sghi != 0 {
adjinfo.sghi += adjinfo.delta
}
// Swap out old stack for new one
gp.stack = new
gp.stackguard0 = new.lo + stackGuard // NOTE: might clobber a preempt request
gp.sched.sp = new.hi - used
gp.stktopsp += adjinfo.delta
// Adjust pointers in the new stack.
var u unwinder
for u.init(gp, 0); u.valid(); u.next() {
adjustframe(&u.frame, &adjinfo)
}
stackfree(old)
}تقوم بالأعمال التالية:
- تخصيص مساحة مكدس جديدة
- نسخ ذاكرة المكدس القديم للمكدس الجديد عبر
runtime.memmove - تعديل الهياكل التي تحتوي مؤشرات مكدس، مثل defer و panic
- تحديث حقول مساحة مكدس G
- عبر
runtime.adjustframeتعديل المؤشرات للذاكرة القديمة - تحرير ذاكرة المكدس القديم
بعد الانتهاء، حالة G تتغير من _Gcopystack لـ _Grunning، و runtime.gogo تجعل G يستمر تنفيذ كود المستخدم. بسبب وجود توسيع مكدس الكوروتين، الذاكرة في Go غير مستقرة.
الانكماش
عندما حالة G هي _Grunnable أو _Gsyscall أو _Gwaiting، GC يمسح مساحة مكدس الكوروتين:
func scanstack(gp *g, gcw *gcWork) int64 {
switch readgstatus(gp) &^ _Gscan {
case _Grunnable, _Gsyscall, _Gwaiting:
// ok
}
...
if isShrinkStackSafe(gp) {
// Shrink the stack if not much of it is being used.
shrinkstack(gp)
}
...
}الانكماش الفعلي يتم عبر runtime.shrinkstack:
func shrinkstack(gp *g) {
if !isShrinkStackSafe(gp) {
throw("shrinkstack at bad time")
}
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize / 2
if newsize < fixedStack {
return
}
avail := gp.stack.hi - gp.stack.lo
if used := gp.stack.hi - gp.sched.sp + stackNosplit; used >= avail/4 {
return
}
copystack(gp, newsize)
}عندما المساحة المستخدمة أقل من 1/4 من الأصلية، تُصغر للنصف عبر runtime.copystack، والباقي كما سبق.
المكدس المجزأ
من عملية copystack نرى أنها تنسخ ذاكرة المكدس القديم لمساحة أكبر، المكدس القديم والجديد عناوينهما متصلة. لكن في نسخ Go القديمة، توسيع المكدس كان مختلفًا، كانوا يرون نسخ الذاكرة مكلفًا، فاستخدموا فكرة المكدس المجزأ، إذا لم تكفِ مساحة المكدس، يُخصص مكدس جديد، والمكدس القديم لا يُحرر ولا يُنسخ، يرتبطان بمؤشرات، مشكلين قائمة مكدس، وهذا أصل المكدس المجزأ:

ميزة هذا عدم نسخ المكدس القديم، لكن عيبه واضح جدًا، وهو تكرار توسيع وانكماش المكدس بشكل متكرر جدًا. عندما توشك مساحة المكدس الحرة على النفاد، استدعاء دالة جديدة يُ Trigger توسيع المكدس، وعندما تعود هذه الدوال ولا تحتاج مساحة المكدس الجديدة يُ Trigger انكماش، إذا كانت هذه الاستدعاءات متكررة جدًا، سيُ Trigger توسيع وانكماش متكرر، وهذا له تكلفة أداء عالية.
لذا بعد go1.4 استُبدل بالمكدس المتصل، لأنه يخصص مساحة مكدس أكبر، لا يحدث أن تصل الذاكرة المستخدمة للحد الحرج فيُ Trigger توسيع وانكماش متكرر، وبما أن عناوين الذاكرة متصلة، حسب مبدأ المحلية المكانية للذاكرة المخبئية، المكدس المتصل أفضل لذاكرة CPU المخبئية.
حلقة الجدولة

ذكرنا في قسم تهيئة المجدول، في دالة runtime.mstart1، بعد ارتباط M بـ P بنجاح يدخل أول حلقة جدولة runtime.schedule لتبدأ رسميًا جدولة G لتنفيذ كود المستخدم. في حلقة الجدولة، هذا الجزء هو P يلعب الدور الرئيسي. M يقابله خيط النظام، G يقابله دالة الدخول أي كود المستخدم، لكن P ليس له كيان مثل M و G، هو مجرد مفهوم مجرد، كوسيط يعالج العلاقة بين M و G:
func schedule() {
mp := getg().m
top:
pp := mp.p.ptr()
pp.preempt = false
if mp.spinning {
resetspinning()
}
gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
execute(gp, inheritTime)
}الكود أعلاه مبسط، حُذف منه شروط كثيرة، أهم نقطتين هما runtime.findRunnable و runtime.execute، الأولى تجد G، وتعيد G متاحًا بالتأكيد، الثانية تجعل G يستمر تنفيذ كود المستخدم.
بالنسبة لـ findRunnable، المصدر الأول لـ G هو قائمة P المحلية:
// local runq
if gp, inheritTime := runqget(pp); gp != nil {
return gp, inheritTime, false
}إذا لم يوجد G في القائمة المحلية، تحاول الحصول من القائمة العامة:
// global runq
if sched.runqsize != 0 {
lock(&sched.lock)
gp := globrunqget(pp, 0)
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}إذا لم تجد في المحلية والعامة، تحاول الحصول من محلقات الشبكة:
if netpollinited() && netpollWaiters.Load() > 0 && sched.lastpoll.Load() != 0 {
if list := netpoll(0); !list.empty() { // non-blocking
gp := list.pop()
injectglist(&list)
casgstatus(gp, _Gwaiting, _Grunnable)
if traceEnabled() {
traceGoUnpark(gp, 0)
}
return gp, false, false
}
}إذا لم تجد أيضًا، أخيرًا تسرق G من قائمة P أخرى. ذكرنا عند إنشاء الكوروتين أن مصدر G في قائمة P المحلية هو الكوروتينات الفرعية المشتقة من الكوروتين الحالي، لكن ليس كل الكوروتينات تنشئ كوروتينات فرعية، لذا قد يكون بعض P مشغولًا جدًا وبعض P حريًا، وهذا يؤدي لـ G ينتظر طويلًا ولا يُنفذ، بينما P آخر لا يعمل شيئًا. لاستغلال كل P وإجعله يعمل بأقصى كفاءة، عندما P لا يجد G، يذهب لقائمة P أخرى "يسرق" G قابلة للتنفيذ، وهكذا كل P يمتلك قائمة G متوازنة، ونادرًا ما يحدث أن P يتفرج بينما الآخر يعمل:
gp, inheritTime, tnow, w, newWork := stealWork(now)
if gp != nil {
// Successfully stole.
return gp, inheritTime, false
}runtime.stealWork تختار P عشوائي للسرقة، والسرقة الفعلية عبر runtime.runqgrab، تحاول سرقة نصف G في قائمة P المحلية:
for {
h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with other consumers
t := atomic.LoadAcq(&pp.runqtail) // load-acquire, synchronize with the producer
n := t - h
n = n - n/2
if n > uint32(len(pp.runq)/2) { // read inconsistent h and t
continue
}
for i := uint32(0); i < n; i++ {
g := pp.runq[(h+i)%uint32(len(pp.runq))]
batch[(batchHead+i)%uint32(len(batch))] = g
}
if atomic.CasRel(&pp.runqhead, h, h+n) { // cas-release, commits consume
return n
}
}عملية السرقة تتكرر 4 مرات، إذا لم تنجح تعود. إذا لم تجد G في النهاية، M الحالي يُوقف عبر runtime.stopm، حتى يُوقظ ثم يكرر الخطوات. عندما تجد G وتعيده، تمرره لـ runtime.execute لتشغيل G:
mp := getg().m
mp.curg = gp
gp.m = mp
casgstatus(gp, _Grunnable, _Grunning)
gp.waitsince = 0
gp.preempt = false
gp.stackguard0 = gp.stack.lo + stackGuard
gogo(&gp.sched)أولاً تحدث curg لـ M، ثم تحدث حالة G لـ _Grunning، أخيرًا runtime.gogo يستأنف تشغيل G.
بشكل عام، في حلقة الجدولة مصادر G حسب الأولوية أربعة:
- القائمة المحلية لـ P
- القائمة العامة
- محلقات الشبكة
- السرقة من قائمة P أخرى
runtime.execute بعد التنفيذ لا تعود، و G المكتسب لا ينفذ للأبد، في نقطة زمنية معينة يُ Trigger جدولة، حق تنفيذه يُنتزع، ثم يدخل جولة جدولة جديدة ويتنازل عن حق التنفيذ لـ G أخرى.
استراتيجية الجدولة
G مختلفة قد تحتاج أوقاتًا مختلفة لتنفيذ كود المستخدم، بعض G قد تستغرق وقتًا طويلًا، بعض G وقتًا قصيرًا، G التي تستغرق وقتًا طويلًا قد تجعل G الأخرى لا تُنفذ، لذا التنفيذ البديل لـ G هو الطريقة الصحيحة، وفي أنظمة التشغيل هذا يسمى التزامن.
الجدولة التعاونية
الفكرة الأساسية للجدولة التعاونية هي جعل G يتنازل عن حق التنفيذ لـ G أخرى، طريقتان رئيسيتان.
الطريقة الأولى هي التنازل النشط في كود المستخدم، Go توفر runtime.Gosched()، المستخدم يقرر متى يتنازل عن حق التنفيذ، لكن في كثير من الأحيان تفاصيل عمل المجدول للمستخدم صندوق أسود، من الصعب الحكم متى يجب التنازل النشط، وهذا يتطلب مستوى عالٍ من المستخدم، ومجدل Go يسعى لحجب معظم التفاصيل عن المستخدم وتبسيط الاستخدام، إشراك المستخدم في عمل الجدولة ليس شيئًا جيدًا.
الطريقة الثانية هي علامة الاستباق، رغم أن اسمها يحتوي كلمة استباق، لكنها جوهرًا استراتيجية جدولة تعاونية. الفكرة هي إدراج كود فحص الاستباق runtime.morestack() في رأس الدالة، الإدراج يتم في وقت الترجمة، ذكرنا سابقًا أنها أصلاً لفحص توسيع المكدس، لأن نقطة الفحص هي كل استدعاء دالة، وهي أيضًا نقطة جيدة لفحص الاستباض. الجزء العلوي من runtime.newstack لفحص الاستباض، والجزء السفلي لفحص توسيع المكدس، حذفنا الجزء الأول سابقًا لتجنب التشتيت، الآن نرى ما يفعله. أولاً حسب gp.stackguard0 تحكم الاستباض، إذا لا يحتاج يستمر تنفيذ كود المستخدم:
stackguard0 := atomic.Loaduintptr(&gp.stackguard0)
preempt := stackguard0 == stackPreempt
if preempt {
if !canPreemptM(thisg.m) {
gp.stackguard0 = gp.stack.lo + stackGuard
gogo(&gp.sched) // never return
}
}عند g.stackguard0 == stackPreempt، runtime.canPreemptM() تحكم هل الكوروتين يحتاج استباض:
func canPreemptM(mp *m) bool {
return mp.locks == 0 && mp.mallocing == 0 && mp.preemptoff == "" && mp.p.ptr().status == _Prunning
}نرى أن الاستباض يحتاج 4 شروط:
- M غير مقفل
- لا يخصص ذاكرة حاليًا
- الاستباض غير معطل
- P في حالة
_Prunning
وفي الحالتين التاليتين يُضبط g.stackguard0 لـ stackPreempt:
- عند الحاجة لجمع القمامة
- عند حدوث استدعاء نظام
if preempt {
if gp.preemptShrink {
gp.preemptShrink = false
shrinkstack(gp)
}
// Act like goroutine called runtime.Gosched.
gopreempt_m(gp) // never return
}أخيرًا يصل لـ runtime.gopreempt_m() للتنازل النشط عن حق تنفيذ الكوروتين الحالي. أولاً تقطع العلاقة بين M و G، الحالة تصبح _Grunnable، ثم تضع G في القائمة العامة، وأخيرًا تدخل حلقة الجدولة وتتنازل عن حق التنفيذ لـ G أخرى:
casgstatus(gp, _Grunning, _Grunnable)
dropg()
lock(&sched.lock)
globrunqput(gp)
unlock(&sched.lock)
schedule()بهذا، كل الكوروتينات عند استدعاء الدوال قد تدخل هذه الدالة لفحص الاستباض، هذه الاستراتيجية تعتمد على استدعاء الدوال كنقطة لتفعيل الاستباض والتنازل النشط. قبل 1.14، Go استمرت باستخدام هذه الاستراتيجية، لكن هناك مشكلة، إذا لم يوجد استدعاء دالة، لا يمكن الفحص، مثل الكود الكلاسيكي التالي الذي يظهر في كثير من الدروس:
func main() {
// تقييد عدد P بـ 1
runtime.GOMAXPROCS(1)
// كوروتين 1
go func() {
for {
// هذا الكوروتين يدور فارغًا
}
}()
// دخول استدعاء نظام، الكوروتين الرئيسي يتنازل للكوروتينات الأخرى
time.Sleep(time.Millisecond)
println("exit")
}الكود أنشأ كوروتين 1 يدور فارغًا، ثم الكوروتين الرئيسي يتنازل بسبب استدعاء النظام، في هذه اللحظة كوروتين 1 يُجدول، لكن لأنه لا يستدعي أي دالة، لا يمكن فحص الاستباض، وبما أن P واحد فقط، لا يوجد P حري، هذا يجعل الكوروتين الرئيسي لا يُجدول أبدًا، و exit لا تُطبع أبدًا، لكن هذه المشكلة فقط قبل go1.14.
الجدولة الاستباقية
أضافت Go رسميًا في go1.14 جدولة استباضية قائمة على الإشارات، هذه استراتيجية استباض غير متزامنة، عبر خيط غير متزامن يرسل إشارة لاستباض الخيط، جدولة الاستباض القائمة على الإشارات حاليًا لها مدخلان: مراقب النظام و GC.
في حلقة مراقب النظام، تمر على كل P، إذا G الذي يُجدوله P يستغرق أكثر من 10ms، تُفعل الاستباض إجباريًا. هذا العمل عبر runtime.retake:
func retake(now int64) uint32 {
n := 0
lock(&allpLock)
for i := 0; i < len(allp); i++ {
pp := allp[i]
if pp == nil {
continue
}
pd := &pp.sysmontick
s := pp.status
sysretake := false
if s == _Prunning || s == _Psyscall {
// Preempt G if it's running for too long.
t := int64(pp.schedtick)
if int64(pd.schedtick) != t {
pd.schedtick = uint32(t)
pd.schedwhen = now
} else if pd.schedwhen+forcePreemptNS <= now {
preemptone(pp)
sysretake = true
}
}
}
unlock(&allpLock)
return uint32(n)
}عند الحاجة لجمع القمامة، إذا حالة G هي _Grunning، أي لا يزال يعمل، يُفعل الاستباض أيضًا:
func suspendG(gp *g) suspendGState {
for i := 0; ; i++ {
switch s := readgstatus(gp); s {
case _Grunning:
gp.preemptStop = true
gp.preempt = true
gp.stackguard0 = stackPreempt
casfrom_Gscanstatus(gp, _Gscanrunning, _Grunning)
if preemptMSupported && debug.asyncpreemptoff == 0 && needAsync {
now := nanotime()
if now >= nextPreemptM {
nextPreemptM = now + yieldDelay/2
preemptM(asyncM)
}
}
......
......
func preemptM(mp *m) {
if mp.signalPending.CompareAndSwap(0, 1) {
if GOOS == "darwin" || GOOS == "ios" {
pendingPreemptSignals.Add(1)
}
signalM(mp, sigPreempt)
}
}كلا مدخلي الاستباض يدخلان runtime.preemptM لإرسال إشارة الاستباض. عندما تُرسل الإشارة بنجاح، معاودة الاتصال لمعالج الإشارة runtime.sighandler المسجلة عبر runtime.initsig عند runtime.mstart ستعمل، إذا اكتشفت أن الإشارة المرسلة هي إشارة استباض، تبدأ الاستباض:
func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
...
if sig == sigPreempt && debug.asyncpreemptoff == 0 && !delayedSignal {
// Might be a preemption signal.
doSigPreempt(gp, c)
}
...
}doSigPreempt تعدل سياق الكوروتين المستهدف، تُدخل استدعاء runtime.asyncPreempt:
func doSigPreempt(gp *g, ctxt *sigctxt) {
// Check if this G wants to be preempted and is safe to
// preempt.
if wantAsyncPreempt(gp) {
if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {
// Adjust the PC and inject a call to asyncPreempt.
ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), newpc)
}
}
...بهذا عند العودة لكود المستخدم، الكوروتين المستهدف يدخل دالة runtime.asyncPreempt، وفيها يوجد استدعاء runtime.asyncPreempt2:
TEXT ·asyncPreempt(SB),NOSPLIT|NOFRAME,$0-0
PUSHQ BP
MOVQ SP, BP
// Save flags before clobbering them
PUSHFQ
// obj doesn't understand ADD/SUB on SP, but does understand ADJSP
ADJSP $368
// But vet doesn't know ADJSP, so suppress vet stack checking
...
CALL ·asyncPreempt2(SB)
...
RETتجعل الكوروتين الحالي يتوقف عن العمل ويدخل جولة جدولة جديدة للتنازل عن حق التنفيذ لكوروتينات أخرى:
func asyncPreempt2() {
gp := getg()
gp.asyncSafePoint = true
if gp.preemptStop {
mcall(preemptPark)
} else {
mcall(gopreempt_m)
}
gp.asyncSafePoint = false
}هذه العملية كلها تحدث في دالة runtime.asyncPreempt، مكتوبة بالتجميع (في runtime/preempt_*.s) وبعد الجدولة تستعيد سياق الكوروتين المعدل سابقًا، ليجعل الكوروتين يستأنف طبيعيًا في المستقبل. بعد تبني استراتيجية الاستباض غير المتزامن، المثال السابق لن يحجب الكوروتين الرئيسي للأبد، عندما الكوروتين الدوار يعمل وقتًا معينًا سيُجبر على دخول حلقة الجدولة، وبذلك يتنازل عن حق التنفيذ للكوروتين الرئيسي، وأخيرًا يجعل البرنامج ينتهي طبيعيًا.
ملخص
بشكل عام، نقاط تفعيل الجدولة:
- استدعاء الدوال
- استدعاءات النظام
- مراقب النظام
- جمع القمامة، جمع القمامة يستبض الكوروتينات التي تعمل وقتًا طويلًا
- تعليق الكوروتين بسبب القنوات أو الأقفال
استراتيجيات الجدولة رئيسيًا نوعان: تعاونية واستباضية، التعاونية هي التنازل النشط عن حق التنفيذ، والاستباضية هي انتزاع حق التنفيذ بشكل غير متزامن، كلاهما يتواجدان معًا ليشكلا المجدل الحالي.
