Skip to content

gmp

من أهم خصائص لغة Go دعمها الطبيعي للتزامن، حيث يكفي كلمة مفتاحية واحدة لبدء كوروتين، كما في المثال التالي.

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، وهو وحدة الجدولة الأساسية، وهيكله كالتالي (بعد حذف حقول كثيرة للتسهيل):

go
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
}

أول حقل هو عنوان بداية ونهاية ذاكرة المكدس الخاص بالكوروتين:

go
type stack struct {
  lo uintptr
  hi uintptr
}

_panic و _defer مؤشرات لمكدس panic ومكدس defer:

go
_panic   *_panic // innermost panic - offset known to liblink
_defer   *_defer // innermost defer

m هو الخيط الذي ينفذ الكوروتين الحالي:

go
m        *m      // current m; offset known to arm liblink

preempt يشير هل الكوروتين يحتاج للاستباق، يساوي g.stackguard0 = stackpreempt:

go
preempt       bool // preemption signal, duplicates stackguard0 = stackpreempt

atomicstatus يخزن حالة الكوروتين G، وله القيم التالية:

الاسمالوصف
_Gidleتم تخصيصه ولم تتم تهيئته
_Grunnableالكوروتين يمكنه العمل، في قائمة الانتظار
_Grunningالكوروتين ينفذ كود المستخدم
_Gsyscallتم تخصيص M له لتنفيذ استدعاء نظام
_Gwaitingالكوروتين محجوب، سبب الحجب موضح أدناه
_Gdeadالكوروتين غير مستخدم، قد خرج لتوه أو تهيأ لتوه
_Gcopystackمكدس الكوروتين يُنقل، لا ينفذ كود المستخدم ولا في قائمة الانتظار
_Gpreemptedحجب نفسه للاستباق، ينتظر إيقاظه
_GscanGC يمسح مساحة مكدس الكوروتين، يمكن أن يتواجد مع حالات أخرى

sched يخزن معلومات سياق الكوروتين لاستعادة حالة التنفيذ، وبداخله مؤشرات sp، pc، ret:

go
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 سبب الحجب، والقيم الممكنة:

go
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، تجريد لخيط العمل:

go
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، المعرف الفريد لـ M
  • g0، الكوروتين الذي يمتلك مكدس الجدولة
  • curg، كود المستخدم الذي يعمل على خيط العمل
  • gsignal، الكوروتين المسؤول عن معالجة إشارات الخيط
  • goSigStack، مساحة المكدس المخصصة لمعالجة الإشارات
  • p، عنوان المعالج P، oldp يشير لـ P قبل استدعاء النظام، nextp يشير لـ P الجديد
  • mallocing، يشير هل يتم تخصيص ذاكرة جديدة
  • throwing، نوع الخطأ الذي حدث في M
  • preemptoff، معرف الاستباق، عندما يكون سلسلة فارغة يمكن استباق الكوروتين
  • locks، عدد "الأقفال" في M، إذا لم يكن صفرًا يُمنع الاستباق
  • dying، يشير أن M حدث له panic لا يمكن علاجه، له 4 قيم [0,3] تمثل الخطورة
  • spinning، يشير أن M في حالة خمول وجاهز للعمل
  • tls، التخزين المحلي للخيط

P

P في بيئة التشغيل هو runtime.p، مسؤول عن جدولة العمل بين M و G:

go
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، لها القيم التالية:

القيمةالوصف
_PidleP في حالة خمول، يمكن للمجدول تخصيص M له
_PrunningP مرتبط بـ M وينفذ كود المستخدم
_PsyscallM المرتبط بـ P ينفذ استدعاء نظام، P يمكن استباقه من M آخر
_PgcstopP توقف بسبب GC
_Pdeadمعظم موارد P سُحبت، لن يُستخدم بعد الآن

الحقول التالية تسجل قائمة runq المحلية لـ P، الحد الأقصى 256، إذا زاد يوضع G في القائمة العامة:

go
runqhead uint32
runqtail uint32
runq     [256]guintptr

runnext يشير لـ G التالي المتاح:

runnext guintptr

حقول أخرى:

  • id، المعرف الفريد لـ P
  • schedtick، يزداد مع كل جدولة كوروتين
  • syscalltick، يزداد مع كل استدعاء نظام
  • sysmontick، يسجل آخر معلومات من مراقب النظام
  • m، M المرتبط بـ P
  • gFree، قائمة G الحرة
  • preempt، يشير أن P يجب أن يدخل الجدولة مرة أخرى

معلومات القائمة العامة موجودة في runtime.schedt، تمثيل المجدول في بيئة التشغيل:

go
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. تهيئ موارد المجدول عند بدء البرنامج:

go
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:

go
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 مسبقًا، وتقوم بـ:

  1. تخصيص id لـ M
  2. تخصيص G منفصل لمعالجة إشارات الخيط، عبر runtime.mpreinit
  3. جعله عقدة رأس في القائمة العامة M runtime.allm

ثم تهيئة P، عدده الافتراضي عدد النوى المنطقية للمعالج، أو قيمة متغير البيئة:

go
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 حسب العدد. أولاً تتحقق من الحاجة للتوسيع:

go
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:

go
// 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:

go
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 في قائمتها المحلية في القائمة العامة:

go
// 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 الحرة في قائمة وتعيد رأسها:

go
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:

go
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 تكمل التهيئة:

go
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 كمعاملات:

go
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:

go
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 لإنشاء خيط النظام فعليًا:

go
func newm1(mp *m) {
  execLock.rlock()
  newosproc(mp)
  execLock.runlock()
}

runtime.newosproc يختلف حسب نظام التشغيل، و runtime.mstart يبدأ عمل M.

الخروج

go
runtime.gogo(&mp.g0.sched)

ذكرنا سابقًا أن عند استدعاء mstart1 حُفظت حالة التنفيذ في حقل sched لـ g0، وتمريره لـ runtime.gogo (مكتوب بالتجميع) يجعل الخيط يقفز لاستئناف التنفيذ، لأن الحفظ استخدم getcallerpc()، الاستئناف يعود لدالة mstart0:

go
mstart1()

if mStackIsSystemAllocated() {
    osStack = true
}
mexit(osStack)

بعد استئناف حالة التنفيذ، حسب ترتيب التنفيذ يدخل mexit للخروج من الخيط:

go
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)

يقوم بالأعمال الرئيسية التالية:

  1. استدعاء runtime.uminit للتراجع عن عمل runtime.minit
  2. حذف M من المتغير العام allm
  3. جعل freem للمجدول يشير لـ M الحالي
  4. عبر runtime.releasep فصل P عن M الحالي، و runtime.handoffp يجعل P يرتبط بـ M آخر
  5. عبر runtime.destroy تدمير موارد M
  6. أخيرًا نظام التشغيل يخرج الخيط

بهذا يخرج M بنجاح.

الإيقاف

عند الحاجة لإيقاف M بسبب المجدول أو GC أو استدعاء نظام، تُستدعى runtime.stopm:

go
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)، وعند الإيقاظ تعود الدالة:

go
func mPark() {
  gp := getg()
  notesleep(&gp.m.park)
  noteclear(&gp.m.park)
}

بعد الإيقاظ، M يبحث عن P للارتباط به لمتابعة تنفيذ المهام.

الكوروتين

دورة حياة الكوروتين توافق حالاته، فهم دورة الحياة يساعد في فهم المجدول، لأن المجدول مصمم حول الكوروتين:

_Gcopystack حالة امتلاكها عند توسيع مكدس الكوروتين، تُشرح في قسم مكدس الكوروتين.

الإنشاء

إنشاء الكوروتين من الناحية النحوية يحتاج فقط كلمة go مفتاحية ودالة:

go
go doSomething()

بعد الترجمة يصبح استدعاء runtime.newproc:

go
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:

go
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:

go
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:

go
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 تتولى خروج الكوروتين:

go
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()
}

تقوم بشكل رئيسي بـ:

  1. ضبط الحالة _Gdead
  2. إعادة ضبط قيم الحقول
  3. dropg() تقطع العلاقة بين M و G
  4. gfput(pp, gp) تضع G الحالي في قائمة P الحرة المحلية
  5. schedule() تدخل جولة جدولة جديدة، تجعل M يتنازل عن حق التنفيذ لـ G أخرى

بعد الخروج، حالة الكوروتين تتغير من _Grunning لـ _Gdead، وقد يُعاد استخدامه عند إنشاء كوروتينات جديدة.

استدعاءات النظام

عندما ينفذ الكوروتين G كود المستخدم ويقوم باستدعاء نظام، هناك طريقتان:

  1. استدعاءات النظام من مكتبة syscall
  2. استدعاءات cgo

لأن استدعاء النظام يحجب خيط العمل، يجب التحضير قبل ذلك عبر runtime.entersyscall، ولكنها مجرد استدعاء بسيط لـ runtime.reentersyscall التي تقوم بالعمل الفعلي. أولاً تقفل M الحالي، وخلال التحضير يُمنع استباق G، ويُمنع توسيع المكدس، وضبط gp.stackguard0 = stackPreempt يعني أن بعد التحضير حق تنفيذ P سيُستباق من G أخرى، ثم تحفظ حالة تنفيذ الكوروتين لاستعادتها بعد عودة استدعاء النظام:

go
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 المحلية تستمر في العمل:

go
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 القديم:

go
gp := getg()

gp.waitsince = 0
oldp := gp.m.oldp.ptr()
gp.m.oldp = 0

حالتان للمعالجة، الأولى هل يوجد P يمكن استخدامه مباشرة، runtime.exitsyscallfast تحكم هل P الأصلي متاح، أي هل حالته _Psyscall، وإلا تبحث عن P حر:

go
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 أخرى:

go
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.

go
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:

go
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:

go
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:

go
mp := getg().m
casgstatus(gp, _Grunning, _Gwaiting)
dropg()
schedule()

في دالة runtime.timesleep هناك سطر يحدد قيمة t.f:

go
t.f = goroutineReady

هذه الدالة runtime.goroutineReady وظيفتها إيقاظ الكوروتين المعلق، تستدعي runtime.ready:

go
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:

go
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:

go
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:

go
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:

go
func stackalloc(n uint32) stack

حسب حجم المكدس المطلوب أقل من 32KB أم لا تنقسم لحالتين، 32KB هو أيضًا المعيار للتمييز بين الكائنات الصغيرة والكبيرة في Go. إذا أقل من هذه القيمة تُحصل من مخبأ stackpool، وعندما M مرتبط بـ P وM لا يسمح باستباقه، تُحصل من مخبأ الخيط المحلي:

go
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، وإذا لم تكفِ تُخصص مباشرة على الكومة:

go
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())
}

أخيرًا تعيد عنوان المكدس المنخفض والعالي:

go
return stack{uintptr(v), uintptr(v) + uintptr(n)}

التوسيع

حجم مكدس الكوروتين الافتراضي 2KB، خفيف بما يكفي، لذا تكلفة إنشاء كوروتين منخفضة جدًا، لكن هذا قد لا يكفي، وعندما لا يكفي المكدس يحتاج توسيع. المترجم يُدرج دالة runtime.morestack في بداية كل دالة لفحص هل الكوروتين الحالي يحتاج توسيع مكدس، وإذا احتاج يستدعي runtime.newstack للتوسيع الفعلي:

TIP

بما أن morestack تُدرج تقريبًا في بداية كل الدوال، فنقطة فحص توسيع المكدس هي أيضًا نقطة استباق للكوروتين.

go
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:

go
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)
}

تقوم بالأعمال التالية:

  1. تخصيص مساحة مكدس جديدة
  2. نسخ ذاكرة المكدس القديم للمكدس الجديد عبر runtime.memmove
  3. تعديل الهياكل التي تحتوي مؤشرات مكدس، مثل defer و panic
  4. تحديث حقول مساحة مكدس G
  5. عبر runtime.adjustframe تعديل المؤشرات للذاكرة القديمة
  6. تحرير ذاكرة المكدس القديم

بعد الانتهاء، حالة G تتغير من _Gcopystack لـ _Grunning، و runtime.gogo تجعل G يستمر تنفيذ كود المستخدم. بسبب وجود توسيع مكدس الكوروتين، الذاكرة في Go غير مستقرة.

الانكماش

عندما حالة G هي _Grunnable أو _Gsyscall أو _Gwaiting، GC يمسح مساحة مكدس الكوروتين:

go
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:

go
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:

go
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 المحلية:

go
// local runq
if gp, inheritTime := runqget(pp); gp != nil {
    return gp, inheritTime, false
}

إذا لم يوجد G في القائمة المحلية، تحاول الحصول من القائمة العامة:

go
// global runq
if sched.runqsize != 0 {
    lock(&sched.lock)
    gp := globrunqget(pp, 0)
    unlock(&sched.lock)
    if gp != nil {
        return gp, false, false
    }
}

إذا لم تجد في المحلية والعامة، تحاول الحصول من محلقات الشبكة:

go
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 يتفرج بينما الآخر يعمل:

go
gp, inheritTime, tnow, w, newWork := stealWork(now)
if gp != nil {
    // Successfully stole.
    return gp, inheritTime, false
}

runtime.stealWork تختار P عشوائي للسرقة، والسرقة الفعلية عبر runtime.runqgrab، تحاول سرقة نصف G في قائمة P المحلية:

go
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:

go
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 حسب الأولوية أربعة:

  1. القائمة المحلية لـ P
  2. القائمة العامة
  3. محلقات الشبكة
  4. السرقة من قائمة P أخرى

runtime.execute بعد التنفيذ لا تعود، و G المكتسب لا ينفذ للأبد، في نقطة زمنية معينة يُ Trigger جدولة، حق تنفيذه يُنتزع، ثم يدخل جولة جدولة جديدة ويتنازل عن حق التنفيذ لـ G أخرى.

استراتيجية الجدولة

G مختلفة قد تحتاج أوقاتًا مختلفة لتنفيذ كود المستخدم، بعض G قد تستغرق وقتًا طويلًا، بعض G وقتًا قصيرًا، G التي تستغرق وقتًا طويلًا قد تجعل G الأخرى لا تُنفذ، لذا التنفيذ البديل لـ G هو الطريقة الصحيحة، وفي أنظمة التشغيل هذا يسمى التزامن.

الجدولة التعاونية

الفكرة الأساسية للجدولة التعاونية هي جعل G يتنازل عن حق التنفيذ لـ G أخرى، طريقتان رئيسيتان.

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

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

go
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() تحكم هل الكوروتين يحتاج استباض:

go
func canPreemptM(mp *m) bool {
  return mp.locks == 0 && mp.mallocing == 0 && mp.preemptoff == "" && mp.p.ptr().status == _Prunning
}

نرى أن الاستباض يحتاج 4 شروط:

  1. M غير مقفل
  2. لا يخصص ذاكرة حاليًا
  3. الاستباض غير معطل
  4. P في حالة _Prunning

وفي الحالتين التاليتين يُضبط g.stackguard0 لـ stackPreempt:

  • عند الحاجة لجمع القمامة
  • عند حدوث استدعاء نظام
go
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 أخرى:

go
casgstatus(gp, _Grunning, _Grunnable)
dropg()
lock(&sched.lock)
globrunqput(gp)
unlock(&sched.lock)

schedule()

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

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:

go
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، أي لا يزال يعمل، يُفعل الاستباض أيضًا:

go
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 ستعمل، إذا اكتشفت أن الإشارة المرسلة هي إشارة استباض، تبدأ الاستباض:

go
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:

go
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:

go
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

تجعل الكوروتين الحالي يتوقف عن العمل ويدخل جولة جدولة جديدة للتنازل عن حق التنفيذ لكوروتينات أخرى:

go
func asyncPreempt2() {
  gp := getg()
  gp.asyncSafePoint = true
  if gp.preemptStop {
    mcall(preemptPark)
  } else {
    mcall(gopreempt_m)
  }
  gp.asyncSafePoint = false
}

هذه العملية كلها تحدث في دالة runtime.asyncPreempt، مكتوبة بالتجميع (في runtime/preempt_*.s) وبعد الجدولة تستعيد سياق الكوروتين المعدل سابقًا، ليجعل الكوروتين يستأنف طبيعيًا في المستقبل. بعد تبني استراتيجية الاستباض غير المتزامن، المثال السابق لن يحجب الكوروتين الرئيسي للأبد، عندما الكوروتين الدوار يعمل وقتًا معينًا سيُجبر على دخول حلقة الجدولة، وبذلك يتنازل عن حق التنفيذ للكوروتين الرئيسي، وأخيرًا يجعل البرنامج ينتهي طبيعيًا.

ملخص

بشكل عام، نقاط تفعيل الجدولة:

  • استدعاء الدوال
  • استدعاءات النظام
  • مراقب النظام
  • جمع القمامة، جمع القمامة يستبض الكوروتينات التي تعمل وقتًا طويلًا
  • تعليق الكوروتين بسبب القنوات أو الأقفال

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

Golang تم تحريره بواسطة www.golangdev.cn