Skip to content

gmp

Salah satu fitur terbesar bahasa go adalah dukungan alaminya untuk konkurensi, hanya dengan satu kata kunci sudah dapat memulai sebuah goroutine, seperti yang ditunjukkan dalam contoh berikut.

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

Penggunaan goroutine dalam bahasa go sangat sederhana, bagi pengembang hampir tidak perlu melakukan pekerjaan tambahan apa pun, ini juga salah satu alasan mengapa ia populer. Namun di balik kesederhanaan ini, ada scheduler konkuren yang tidak sederhana mendukung semuanya, namanya pasti sudah terdengar oleh semua orang, karena peserta utamanya masing-masing terdiri dari G (goroutine), M (thread sistem), P (processor), jadi juga disebut scheduler GMP. Desain scheduler GMP mempengaruhi seluruh desain runtime bahasa go, GC, network poller, dapat dikatakan ini adalah bagian paling inti dari seluruh bahasa, jika dapat memahaminya dengan baik, mungkin akan membantu dalam pekerjaan di kemudian hari.

Sejarah

Model scheduler konkuren bahasa Go tidak sepenuhnya orisinal, ia menyerap banyak pengalaman dan pelajaran dari pendahulunya, melalui pengembangan dan perbaikan berkelanjutan baru memiliki tampilan sekarang. Bahasa yang dipelajarinya adalah sebagai berikut:

  • Occam -1983
  • Erlang - 1986
  • Newsqueak - 1988
  • Concurrent ML - 1993
  • Alef - 1995
  • Limbo - 1996

Yang paling berpengaruh besar adalah makalah tentang CSP (Communicate Sequential Process) yang diterbitkan oleh Hoare pada tahun 1978, ide dasar makalah ini adalah proses berkomunikasi satu sama lain melalui komunikasi untuk pertukaran data. Dalam beberapa bahasa pemrograman di atas semuanya dipengaruhi oleh ide CSP, Erlang adalah yang paling khas sebagai bahasa pemrograman berorientasi pesan, middleware antrian pesan open source terkenal RabbitMQ ditulis menggunakan Erlang. Saat ini, dengan perkembangan komputer dan internet, dukungan konkurensi hampir menjadi standar bahasa modern, bahasa go yang menggabungkan ide CSP pun lahir.

Model Scheduler

Pertama mari perkenalkan tiga anggota scheduler GMP

  • G, Goroutine, mengacu pada goroutine dalam bahasa go
  • M, Machine, mengacu pada thread sistem atau disebut worker thread, dijadwalkan oleh sistem operasi
  • P, Processor, bukan mengacu pada CPU processor, adalah konsep abstrak go sendiri, mengacu pada processor yang bekerja di thread sistem, melalui ini untuk menjadwalkan goroutine di setiap thread sistem.

Goroutine adalah thread yang lebih ringan, skalanya lebih kecil, sumber daya yang dibutuhkan juga lebih sedikit, waktu pembuatan dan penghancuran dan waktu penjadwalan diselesaikan oleh runtime bahasa go, bukan sistem operasi, jadi biaya manajemennya jauh lebih rendah daripada thread. Namun goroutine juga bergantung pada thread, waktu eksekusi yang dibutuhkan goroutine berasal dari thread, waktu eksekusi yang dibutuhkan thread berasal dari sistem operasi, dan pergantian antara thread yang berbeda memiliki biaya tertentu, bagaimana membuat goroutine memanfaatkan waktu thread dengan baik adalah kunci desain.

1:N

Cara terbaik untuk menyelesaikan masalah adalah mengabaikan masalah ini, karena pergantian thread memiliki biaya, langsung tidak pergantian saja. Alokasikan semua goroutine ke satu kernel thread, dengan demikian hanya melibatkan pergantian antar goroutine.

Hubungan antara thread dan goroutine adalah 1:N, melakukan ini memiliki kekurangan yang sangat jelas, komputer era modern hampir semuanya CPU multi-core, alokasi seperti ini tidak dapat memanfaatkan kinerja CPU multi-core dengan penuh.

N:N

Cara lain, satu thread berkorespondensi dengan satu goroutine, satu goroutine dapat menikmati semua waktu thread, banyak thread juga dapat memanfaatkan kinerja CPU multi-core. Tetapi, biaya pembuatan dan pergantian thread cukup tinggi, jika hubungan satu banding satu, justru tidak memanfaatkan keunggulan ringan goroutine.

M:N

M thread berkorespondensi dengan N goroutine, dan M kurang dari N. Banyak thread berkorespondensi dengan banyak goroutine, setiap thread akan berkorespondensi dengan beberapa goroutine, processor P bertanggung jawab untuk menjadwalkan bagaimana goroutine G menggunakan waktu thread. Metode ini relatif lebih baik, juga model scheduler yang digunakan Go hingga saat ini.

M hanya dapat mengeksekusi tugas setelah terkait dengan processor P, go akan membuat GOMAXPROCS processor, jadi jumlah thread yang benar-benar dapat digunakan untuk mengeksekusi tugas adalah GOMAXPROCS, nilai defaultnya adalah jumlah core logis CPU mesin saat ini, kita juga dapat secara manual mengatur nilainya.

  • Melalui kode modifikasi runtime.GOMAXPROCS(N), dan dapat disesuaikan secara dinamis saat runtime, panggil langsung STW.

  • Mengatur environment variable export GOMAXPROCS=N, statis.

Dalam situasi aktual, jumlah M akan lebih besar dari jumlah P, karena runtime akan membutuhkan mereka untuk menangani tugas lain, seperti beberapa system call, nilai maksimum adalah 10000.

Ketiga peserta GMP ini dan scheduler itu sendiri memiliki representasi tipe pada runtime, semuanya terletak di file runtime/runtime2.go, di bawah ini akan memperkenalkan struktur mereka secara sederhana, untuk memudahkan pemahaman di bagian belakang.

G

G pada runtime表现为 tipe struktur runtime.g, adalah unit penjadwalan paling dasar dalam model scheduler, strukturnya seperti yang ditunjukkan di bawah ini, untuk memudahkan pemahaman, banyak field dihapus.

go
type g struct {
    stack stack // offset diketahui oleh runtime/cgo

    _panic   *_panic // panic terdalam - offset diketahui oleh liblink
    _defer   *_defer // defer terdalam
    m        *m      // m saat ini; offset diketahui oleh arm liblink
    sched    gobuf

    goid       uint64
    waitsince  int64      // perkiraan waktu ketika g menjadi blocked
    waitreason waitReason // jika status==Gwaiting

    atomicstatus atomic.Uint32

    preempt       bool // sinyal preemption, duplikat stackguard0 = stackpreempt
    startpc       uintptr         // pc dari fungsi goroutine

    parentGoid uint64  // goid dari goroutine yang membuat goroutine ini
    waiting    *sudog  // struktur sudog yang ditunggu goroutine ini (yang memiliki ptr elem valid); dalam urutan lock
}

Field pertama adalah alamat awal dan akhir memori stack milik goroutine ini

go
type stack struct {
  lo uintptr
  hi uintptr
}

_panic dan _defer masing-masing adalah pointer ke stack panic dan stack defer

go
_panic   *_panic // panic terdalam - offset diketahui oleh liblink
_defer   *_defer // defer terdalam

m sedang mengeksekusi g goroutine saat ini

go
m        *m      // m saat ini; offset diketahui oleh arm liblink

preempt menunjukkan apakah goroutine saat ini perlu di-preempt, setara dengan g.stackguard0 = stackpreempt

go
preempt       bool // sinyal preemption, duplikat stackguard0 = stackpreempt

atomicstatus digunakan untuk menyimpan nilai status goroutine G, ia memiliki nilai opsional berikut

NamaDeskripsi
_GidleBaru dialokasikan, dan belum diinisialisasi
_GrunnableMenunjukkan goroutine saat ini dapat berjalan, berada dalam antrian tunggu
_GrunningMenunjukkan goroutine saat ini sedang mengeksekusi kode pengguna
_GsyscallDialokasikan M, digunakan untuk mengeksekusi system call,
_GwaitingGoroutine blocked, alasan blocking lihat di bawah
_GdeadMenunjukkan goroutine saat ini tidak digunakan, mungkin baru saja keluar, atau baru saja diinisialisasi
_GcopystackMenunjukkan stack goroutine sedang dipindahkan, selama periode ini tidak mengeksekusi kode pengguna, juga tidak berada dalam antrian tunggu
_GpreemptedBlocked sendiri masuk preempt, menunggu dibangunkan oleh pihak preempt
_GscanGC sedang memindai ruang stack goroutine, dapat coexist dengan status lain

sched digunakan untuk menyimpan informasi konteks goroutine untuk memulihkan eksekusi goroutine, dapat dilihat di dalamnya menyimpan pointer sp, pc, ret.

go
type gobuf struct {
  sp   uintptr
  pc   uintptr
  g    guintptr
  ctxt unsafe.Pointer
  ret  uintptr
  lr   uintptr
  bp   uintptr // untuk arsitektur yang mendukung framepointer
}

waiting menunjukkan goroutine yang sedang ditunggu goroutine saat ini, waitsince mencatat waktu goroutine mengalami blocking, waitreason menunjukkan alasan goroutine blocking, nilai opsional adalah sebagai berikut.

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 dan parentGoid menunjukkan identifikasi unik goroutine saat ini dan goroutine induk, startpc menunjukkan alamat fungsi入口 goroutine saat ini.

M

M pada runtime表现为 struktur runtime.m, adalah abstraksi untuk worker thread

go
type m struct {
    id            int64

    g0 *g // goroutine dengan scheduling stack
    curg          *g           // goroutine pengguna yang sedang berjalan di worker thread

    gsignal       *g           // goroutine untuk handling sinyal
    goSigStack    gsignalStack // stack handling sinyal yang dialokasikan Go

    p             puintptr     // p yang terkait untuk mengeksekusi kode go (nil jika tidak mengeksekusi kode go)
    nextp         puintptr
    oldp          puintptr // p yang terkait sebelum mengeksekusi syscall

    mallocing     int32
    throwing      throwType
    preemptoff    string // jika != "", tetap jalankan curg di m ini
    locks         int32
    dying         int32

    spinning      bool // m sedang keluar dari pekerjaan dan aktif mencari pekerjaan

    tls           [tlsSlots]uintptr
    ...
}

Sama, field di dalam M juga banyak, di sini hanya memperkenalkan sebagian field untuk memudahkan pemahaman.

  • id, identifikasi unik M
  • g0, goroutine yang memiliki scheduling stack
  • curg, goroutine pengguna yang sedang berjalan di worker thread
  • gsignal, goroutine yang bertanggung jawab untuk handling sinyal thread
  • goSigStack, ruang stack yang dialokasikan go untuk handling sinyal
  • p, alamat processor P, oldp menunjuk ke P sebelum mengeksekusi system call, nextp menunjuk ke P yang baru dialokasikan
  • mallocing, digunakan untuk menunjukkan apakah sedang mengalokasikan ruang memori baru
  • throwing, menunjukkan jenis error yang terjadi pada M
  • preemptoff, identifier preempt, ketika string kosong menunjukkan goroutine yang sedang berjalan dapat di-preempt
  • locks, menunjukkan jumlah "lock" M saat ini, tidak 0时禁止 preempt
  • dying, menunjukkan M mengalami panic yang tidak dapat dipulihkan, memiliki [0,3] empat nilai opsional, dari rendah ke tinggi menunjukkan tingkat keparahan.
  • spinning, menunjukkan M sedang dalam status idle, dan随时可用.
  • tls, thread local storage

P

P pada runtime oleh runtime.p表示,bertanggung jawab untuk menjadwalkan pekerjaan antara M dan G, strukturnya adalah sebagai berikut

go
type p struct {
    id     int32
    status uint32 // salah satu pidle/prunning/...

    schedtick   uint32     // bertambah pada setiap pemanggilan scheduler
    syscalltick uint32     // bertambah pada setiap system call
    sysmontick  sysmontick // tick terakhir yang diamati oleh sysmon

    m      muintptr // back-link ke m yang terkait (nil jika idle)

    // Antrian goroutine yang dapat dijalankan. Diakses tanpa lock.
    runqhead uint32
    runqtail uint32
    runq     [256]guintptr

    runnext guintptr

    // G yang tersedia (status == Gdead)
    gFree struct {
        gList
        n int32
    }

    // preempt diatur untuk menunjukkan bahwa P ini harus masuk
    // scheduler secepatnya (terlepas dari G apa yang berjalan di atasnya).
    preempt bool

    ...
}

status menunjukkan status P, memiliki beberapa nilai opsional berikut

NilaiDeskripsi
_PidleP berada dalam status idle, dapat dialokasikan M oleh scheduler, atau mungkin hanya转换 di antara status lain
_PrunningP terkait dengan M, dan sedang mengeksekusi kode pengguna
_PsyscallMenunjukkan M yang terkait dengan P sedang melakukan system call, selama periode ini P mungkin di-preempt oleh M lain
_PgcstopMenunjukkan P berhenti karena GC
_PdeadSebagian besar sumber daya P telah dicabut, tidak akan digunakan lagi

Beberapa field di bawah ini mencatat antrian lokal runq di P, dapat dilihat jumlah maksimum antrian lokal adalah 256, setelah melebihi jumlah ini G akan ditempatkan di antrian global.

go
runqhead uint32
runqtail uint32
runq     [256]guintptr

runnext menunjukkan G berikutnya yang tersedia

runnext guintptr

Beberapa field lainnya adalah sebagai berikut

  • id, identifikasi unik P
  • schedtick, bertambah seiring peningkatan jumlah penjadwalan goroutine, terlihat di fungsi runtime.execute.
  • syscalltick, bertambah seiring peningkatan jumlah system call
  • sysmontick, mencatat informasi yang terakhir diamati oleh monitor sistem
  • m, M yang terkait dengan P
  • gFree, daftar G idle
  • preempt, menunjukkan P harus masuk penjadwalan lagi

Informasi antrian global disimpan dalam struktur runtime.schedt, adalah representasi scheduler saat runtime, sebagai berikut.

go
type schedt struct {
  ...

  midle        muintptr // m idle menunggu pekerjaan
  ngsys atomic.Int32 // jumlah goroutine sistem
  pidle        puintptr // p idle

  // Antrian global yang dapat dijalankan.
  runq     gQueue
  runqsize int32

  ...
}

Inisialisasi

Inisialisasi scheduler terletak pada tahap bootstrap program go, yang bertanggung jawab untuk bootstrap program go adalah fungsi runtime.rt0_go, ia diimplementasikan oleh assembly terletak di file runtime/asm_*.s, sebagian kode adalah sebagai berikut

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 seharusnya tidak pernah return
  RET

Dapat melihat pemanggilan runtime·osinit dan runtime·schedinit melalui dua baris berikut.

CALL  runtime·osinit(SB)
CALL  runtime·schedinit(SB)

Yang pertama bertanggung jawab untuk menginisialisasi pekerjaan terkait sistem operasi, yang kedua bertanggung jawab untuk inisialisasi scheduler, yaitu fungsi runtime·schedinit. Ia akan menginisialisasi sumber daya yang diperlukan untuk menjalankan scheduler saat program dimulai, berikut adalah kode yang disederhanakan.

go
func schedinit() {
    ...
  gp := getg()

  sched.maxmcount = 10000

  // 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()
    ...
}

Fungsi runtime.getg diimplementasikan oleh assembly, fungsinya adalah mendapatkan representasi runtime goroutine saat ini, yaitu pointer struktur runtime.g. Melalui sched.maxmcount = 10000 dapat dilihat, saat inisialisasi scheduler sudah mengatur jumlah maksimum M adalah 10000, nilai ini tetap dan tidak dapat dimodifikasi. Setelah itu adalah inisialisasi heap stack, kemudian fungsi runtime.mcommoninit untuk menginisialisasi M, implementasi fungsinya adalah sebagai berikut

go
func mcommoninit(mp *m, id int64) {
  gp := getg()

  // g0 stack tidak akan masuk akal untuk user (dan tidak perlu 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
  }

  // Tambahkan ke allm sehingga garbage collector tidak membebaskan g->m
  // ketika hanya ada di register atau thread-local storage.
  mp.alllink = allm

  // NumCgoCall() iterasi atas allm tanpa schedlock,
  // jadi kita perlu mempublikasikannya dengan aman.
  atomicstorep(unsafe.Pointer(&allm), unsafe.Pointer(mp))
  unlock(&sched.lock)
  ...
}

Fungsi ini melakukan pre-inisialisasi M, terutama melakukan pekerjaan berikut

  1. Alokasikan id M
  2. Alokasikan G terpisah untuk handling sinyal thread, diselesaikan oleh fungsi runtime.mpreinit
  3. Jadikan sebagai kepala linked list M global runtime.allm

Selanjutnya inisialisasi P, jumlahnya default adalah jumlah core logis CPU,其次是 nilai environment variable.

go
procs := ncpu
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
    procs = n
}
if procresize(procs) != nil {
    throw("unknown runnable goroutine during bootstrap")
}

Terakhir fungsi runtime.procresize bertanggung jawab untuk menginisialisasi P, ia akan memodifikasi slice global runtime.allp yang menyimpan semua P sesuai dengan jumlah yang传入. Pertama判断 apakah perlu ekspansi berdasarkan ukuran jumlah

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

Kemudian inisialisasi setiap 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))
}

Jika P yang sedang digunakan goroutine saat ini perlu dihancurkan, ganti dengan allp[0], diselesaikan oleh fungsi runtime.acquirep untuk mengasosiasikan M dengan P baru.

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

Kemudian hancurkan P yang tidak lagi diperlukan, saat penghancuran akan melepaskan semua sumber daya P, menempatkan semua G di antrian lokalnya ke antrian global, setelah penghancuran kemudian slice allp.

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

Terakhir hubungkan P idle menjadi linked list, dan akhirnya return kepala linked list

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

Setelah itu, inisialisasi scheduler selesai, oleh runtime.worldStarted memulihkan semua P untuk berjalan.

MOVQ  $runtime·mainPC(SB), AX    // entry
PUSHQ  AX
CALL  runtime·newproc(SB)
POPQ  AX

// start this M
CALL  runtime·mstart(SB)

Kemudian akan membuat goroutine baru melalui fungsi runtime.newproc untuk memulai program go, kemudian memanggil runtime.mstart untuk secara resmi memulai operasi scheduler, ia juga diimplementasikan oleh assembly, di dalamnya akan memanggil fungsi runtime.mstart0 untuk membuat, sebagian kode fungsi ini adalah sebagai berikut

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 saat ini hanya memiliki satu goroutine g0, goroutine ini menggunakan stack sistem thread, bukan ruang stack yang dialokasikan secara terpisah. Fungsi mstart0 akan menginisialisasi batas stack G terlebih dahulu, kemudian交给 mstart1 untuk menyelesaikan sisa pekerjaan inisialisasi.

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

Sebelum memulai, pertama akan mencatat eksekusi saat ini, karena setelah inisialisasi berhasil akan masuk ke loop penjadwalan dan tidak akan pernah return, pemanggilan lain dapat复用 eksekusi dari fungsi mstart1 return untuk mencapai tujuan退出 thread. Setelah pencatatan selesai, oleh dua fungsi runtime.asminit dan runtime.minit bertanggung jawab untuk menginisialisasi stack sistem, kemudian oleh fungsi runtime.mstartm0 mengatur callback untuk handling sinyal. Setelah mengeksekusi fungsi callback m.mstartfn, fungsi runtime.acquirep mengasosiasikan M dengan P yang dibuat sebelumnya, terakhir masuk ke loop penjadwalan.

Pemanggilan runtime.schedule di sini adalah loop penjadwalan pertama runtime go, menandakan scheduler secara resmi mulai bekerja.

Thread

Dalam scheduler, G ingin mengeksekusi kode pengguna harus bergantung pada P, dan P untuk bekerja normal harus terkait dengan M, M mengacu pada thread sistem.

Pembuatan

Pembuatan M diselesaikan oleh fungsi runtime.newm, ia menerima fungsi dan P serta id sebagai parameter, fungsi sebagai parameter tidak boleh closure.

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

Sebelum memulai, newm akan memanggil fungsi runtime.allocm terlebih dahulu untuk membuat representasi runtime thread yaitu M, dalam proses akan menggunakan fungsi runtime.mcommoninit untuk menginisialisasi batas stack M.

go
func allocm(pp *p, fn func(), id int64) *m  {
    allocmLock.rlock()

    // Caller memiliki pp, tetapi kita mungkin meminjam (yaitu, acquirep) itu. Kita harus
    // menonaktifkan preemption untuk memastikan tidak dicuri, yang akan membuat
    // caller kehilangan ownership.
    acquirem()

    gp := getg()
    if gp.m.p == 0 {
        acquirep(pp) // sementara meminjam p untuk mallocs dalam fungsi ini
    }

    mp := new(m)
    mp.mstartfn = fn
    mcommoninit(mp, id)

    mp.g0.m = mp

    releasem(gp.m)
    allocmLock.runlock()
    return mp
}

Kemudian oleh runtime.newm1 memanggil fungsi runtime.newosproc untuk menyelesaikan pembuatan thread sistem yang sebenarnya.

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

Implementasi runtime.newosproc akan berbeda tergantung pada sistem operasi, bagaimana cara pembuatan bukan hal yang perlu kita khawatirkan, diselesaikan oleh sistem operasi, kemudian oleh runtime.mstart untuk memulai pekerjaan M.

Exit

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

Saat inisialisasi disebutkan, saat memanggil fungsi mstart1 eksekusi disimpan di field sched g0, memberikan field ini ke fungsi runtime.gogo (diimplementasikan oleh assembly) dapat membuat thread lompat ke eksekusi untuk melanjutkan eksekusi, saat menyimpan menggunakan getcallerpc(), jadi saat memulihkan eksekusi adalah kembali ke fungsi mstar0.

go
mstart1()

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

Setelah eksekusi dipulihkan, sesuai urutan eksekusi akan masuk ke fungsi mexit untuk退出 thread.

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)

Ia total melakukan beberapa hal berikut

  1. Memanggil runtime.uminit untuk membatalkan pekerjaan runtime.minit
  2. Menghapus M dari variabel global allm
  3. Membuat freem scheduler menunjuk ke M saat ini
  4. Oleh runtime.releasep melepaskan ikatan P dengan M saat ini, dan oleh runtime.handoffp membuat P terkait dengan M lain untuk melanjutkan pekerjaan
  5. Oleh runtime.destroy bertanggung jawab untuk menghancurkan sumber daya M
  6. Terakhir oleh sistem operasi untuk退出 thread

Sampai di sini M berhasil退出.

Pause

Ketika karena penjadwalan scheduler, GC, system call dan alasan lain perlu pause M, akan memanggil fungsi runtime.stopm untuk pause thread, berikut adalah kode yang disederhanakan.

go
func stopm() {
  gp := getg()
  lock(&sched.lock)
  mput(gp.m)
  unlock(&sched.lock)
  mPark()
  acquirep(gp.m.nextp.ptr())
  gp.m.nextp = 0
}

Pertama akan menempatkan M ke daftar M idle global, kemudian oleh mPark() memblokir thread saat ini di notesleep(&gp.m.park) di sini, ketika dibangunkan fungsi ini akan return

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

M yang dibangunkan akan mencari P untuk diikat sehingga melanjutkan eksekusi tugas.

Goroutine

Siklus hidup goroutine tepat berkorespondensi dengan beberapa status goroutine, memahami siklus hidup goroutine akan sangat membantu untuk memahami scheduler, setelah seluruh scheduler dirancang sekitar goroutine, seluruh siklus hidup goroutine adalah seperti yang ditunjukkan pada gambar berikut.

_Gcopystack adalah status yang dimiliki goroutine saat stack goroutine ekspansi, dijelaskan di bagian Stack Goroutine.

Pembuatan

Pembuatan goroutine dari segi sintaks hanya memerlukan kata kunci go ditambah fungsi.

go
go doSomething()

Setelah kompilasi akan berubah menjadi pemanggilan fungsi 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()
    }
  })
}

Oleh runtime.newproc1 untuk menyelesaikan pembuatan yang sebenarnya, saat pembuatan pertama akan mengunci M,禁止 preempt, kemudian akan mencari G idle di daftar gfree lokal P untuk dimanfaatkan kembali, jika tidak ditemukan oleh runtime.malg membuat G baru, dan mengalokasikan ruang stack 2kb. Saat ini status G adalah _Gdead.

go
mp := acquirem() // menonaktifkan preemption karena kita memegang M dan P dalam variabel lokal.
pp := mp.p.ptr()
newg := gfget(pp)
if newg == nil {
    newg = malg(stackMin)
    casgstatus(newg, _Gidle, _Gdead)
    allgadd(newg) // mempublikasikan dengan status g->status Gdead sehingga scanner GC tidak melihat stack yang belum diinisialisasi.
}

Di go1.18 dan setelahnya, penyalinan parameter tidak lagi diselesaikan oleh fungsi newproc1, sebelum ini, akan menggunakan runtime.memmove untuk menyalin parameter fungsi. Sekarang hanya bertanggung jawab untuk mereset ruang stack goroutine, menjadikan runtime.goexit sebagai dasar stack untuk退出 handling goroutine, kemudian mengatur PC fungsi入口 newg.startpc = fn.fn menunjukkan mulai dari sini eksekusi, setelah pengaturan selesai, saat ini status G adalah _Grunnable.

go
totalSize := uintptr(4*goarch.PtrSize + sys.MinFrameSize) // ruang ekstra jika terjadi pembacaan sedikit melebihi frame
totalSize = alignUp(totalSize, sys.StackAlign)
sp := newg.stack.hi - totalSize
spArg := sp
if usesLR {
    // LR pemanggil
    *(*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 sehingga instruksi sebelumnya berada di fungsi yang sama
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)

Terakhir mengatur identifikasi unik G, kemudian melepaskan M, return goroutine G yang dibuat.

go
newg.goid = pp.goidcache
pp.goidcache++
releasem(mp)

return newg

Setelah goroutine dibuat, akan oleh fungsi runtime.runqput mencoba menempatkannya di antrian lokal P, jika tidak muat ditempatkan di antrian global. Dalam seluruh proses pembuatan goroutine, perubahan statusnya pertama adalah dari _Gidle menjadi _Gdead, setelah mengatur fungsi入口 dari _Gdead menjadi _Grunnable.

Exit

Saat pembuatan go sudah mengatur fungsi runtime.goexit sebagai dasar stack goroutine, maka ketika goroutine selesai eksekusi pada akhirnya akan masuk ke fungsi ini, melalui call chain goexit->goexit1->goexit0, akhirnya oleh runtime.goexit0 bertanggung jawab untuk pekerjaan退出 goroutine.

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 // seharusnya true sudah tetapi hanya untuk berjaga-jaga.
  gp._panic = nil // non-nil untuk Goexit selama panic. menunjuk ke data yang dialokasikan stack.
  gp.writebuf = nil
  gp.waitreason = waitReasonZero
  gp.param = nil
  gp.labels = nil
  gp.timer = nil

  dropg()
  ...
  gfput(pp, gp)
    ...
  schedule()
}

Fungsi ini terutama melakukan beberapa hal berikut

  1. Mengatur status menjadi _Gdead
  2. Mereset nilai field
  3. dropg() memutus hubungan antara M dan G
  4. gfput(pp, gp) menempatkan G saat ini ke daftar idle lokal P
  5. schedule() melakukan penjadwalan putaran baru, menyerahkan hak eksekusi M ke G lain

Setelah退出, status goroutine berubah dari _Grunning menjadi _Gdead, di masa depan saat membuat goroutine masih mungkin dimanfaatkan kembali.

System Call

Ketika goroutine G sedang mengeksekusi kode pengguna jika melakukan system call, ada dua cara untuk memicu system call

  1. System call pustaka standar syscall
  2. Pemanggilan cgo

Karena system call akan memblokir worker thread, jadi sebelum itu perlu melakukan persiapan, diselesaikan oleh fungsi runtime.entersyscall, tetapi yang pertama juga hanya pemanggilan sederhana ke fungsi runtime.reentersyscall, pekerjaan sebenarnya diselesaikan oleh yang terakhir. Pertama akan mengunci M saat ini, selama persiapan G禁止 preempt, juga禁止 stack ekspansi, mengatur gp.stackguard0 = stackPreempt menunjukkan setelah persiapan selesai hak eksekusi P akan di-preempt oleh G lain, kemudian menyimpan eksekusi goroutine, memudahkan pemulihan setelah system call return.

go
gp := getg()

// Nonaktifkan preemption karena selama fungsi ini g berada dalam status Gsyscall,
// tetapi dapat memiliki g->sched yang tidak konsisten, jangan biarkan GC mengamatinya.
gp.m.locks++

// Entersyscall tidak boleh memanggil fungsi apa pun yang mungkin split/menumbuhkan stack.
// (Lihat detail dalam komentar di atas.)
// Tangkap panggilan yang mungkin, dengan mengganti stack guard dengan sesuatu yang
// akan memicu pemeriksaan stack apa pun dan meninggalkan flag untuk memberi tahu newstack agar mati.
gp.stackguard0 = stackPreempt
gp.throwsplit = true

// Biarkan SP sekitar untuk GC dan traceback.
save(pc, sp)
gp.syscallsp = sp
gp.syscallpc = pc

Setelah itu, karena untuk mencegah blocking terlalu lama而影响 eksekusi G lain, M dan P akan dilepaskan, M dan G yang dilepaskan akan karena eksekusi system call而 blocked, dan P setelah dilepaskan mungkin terkait dengan M idle lain sehingga G lain di antrian lokal P dapat melanjutkan pekerjaan.

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

Setelah persiapan selesai, melepaskan lock M, selama periode ini status G berubah dari _Grunning menjadi _Gsyscall, status P berubah menjadi _Psyscall.

Ketika system call return, thread M tidak lagi blocked, G yang sesuai juga perlu dijadwalkan lagi untuk mengeksekusi kode pengguna, diselesaikan oleh fungsi runtime.exitsyscall untuk menyelesaikan pekerjaan善后 ini. Pertama mengunci M saat ini, mendapatkan referensi P lama.

go
gp := getg()

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

Saat ini dibagi menjadi dua situasi untuk menangani, situasi pertama adalah apakah ada P yang dapat langsung digunakan, fungsi runtime.exitsyscallfast akan判断 apakah P asli tersedia, yaitu apakah status P adalah _Psyscall, jika tidak akan mencari P idle.

go
func exitsyscallfast(oldp *p) bool {
  gp := getg()

  // Freezetheworld mengatur stopwait tetapi tidak mengambil P kembali.
  if sched.stopwait == freezeStopWait {
    return false
  }

  // Coba ambil kembali P terakhir.
  if oldp != nil && oldp.status == _Psyscall && atomic.Cas(&oldp.status, _Psyscall, _Pidle) {
    // Ada cpu untuk kita, jadi kita dapat berjalan.
    wirep(oldp)
    exitsyscallfast_reacquired()
    return true
  }

  // Coba dapatkan P idle lainnya.
  if sched.pidle != 0 {
    var ok bool
    systemstack(func() {
      ok = exitsyscallfast_pidle()
    })
    if ok {
      return true
    }
  }
  return false
}

Jika berhasil menemukan P yang tersedia, M akan terkait dengan P, G berubah dari status _Gsyscall menjadi status _Grunning, kemudian melalui runtime.Gosched G secara aktif menyerahkan hak eksekusi, P masuk loop penjadwalan untuk mencari G lain yang tersedia.

go
oldp := gp.m.oldp.ptr()
gp.m.oldp = 0
if exitsyscallfast(oldp) {
    // Ada cpu untuk kita, jadi kita dapat berjalan.
    gp.m.p.ptr().syscalltick++
    // Kita perlu cas status dan scan sebelum melanjutkan...
    casgstatus(gp, _Gsyscall, _Grunning)

    // Garbage collector tidak berjalan (karena kita),
    // jadi oke untuk menghapus syscallsp.
    gp.syscallsp = 0
    gp.m.locks--
    if gp.preempt {
        // kembalikan permintaan preempt jika kita sudah menghapusnya di newstack
        gp.stackguard0 = stackPreempt
    } else {
        // jika tidak kembalikan stackGuard yang sebenarnya, kita sudah merusaknya di entersyscall/entersyscallblock
        gp.stackguard0 = gp.stack.lo + stackGuard
    }
    gp.throwsplit = false

    if sched.disable.user && !schedEnabled(gp) {
        // Penjadwalan goroutine ini dinonaktifkan.
        Gosched()
    }

    return
}

Jika tidak menemukan, M akan dilepaskan dengan G, G berubah dari status _Gsyscall menjadi status _Grunnable, kemudian mencoba lagi apakah dapat menemukan P idle, jika tidak ditemukan langsung menempatkan G di antrian global, kemudian masuk loop penjadwalan baru, M lama oleh runtime.stopm masuk status idle, menunggu tugas baru di kemudian hari. Jika P ditemukan, M lama dan G terkait dengan P baru, kemudian melanjutkan eksekusi kode pengguna, status dari _Grunnable menjadi _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) // Tidak pernah return.
  }
  stopm()
  schedule() // Tidak pernah return.
}

Setelah退出 system call, status G pada akhirnya memiliki dua hasil, satu adalah _Grunnable yang menunggu dijadwalkan, satu adalah _Grunning yang terus berjalan.

Suspend

Ketika goroutine saat ini suspend karena beberapa alasan, status akan berubah dari _Grunnable menjadi _Gwaiting, ada banyak alasan untuk suspend, dapat karena channel blocked, select, lock atau time.sleep, lebih banyak alasan lihat Struktur G. Ambil time.Sleep sebagai contoh, ia sebenarnya terhubung ke runtime.timesleep, kode yang terakhir adalah sebagai berikut.

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

Dapat dilihat, ia mendapatkan goroutine saat ini melalui getg, kemudian membuat goroutine saat ini suspend melalui runtime.gopark. runtime.gopark akan memperbarui alasan blocking G dan M, melepaskan lock 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)
// tidak dapat melakukan apa pun yang mungkin memindahkan G antara M di sini.
mcall(park_m)

Kemudian beralih ke stack sistem oleh runtime.park_m untuk mengubah status G menjadi _Gwaiting, kemudian memutus hubungan antara M dan G dan masuk loop penjadwalan baru sehingga menyerahkan hak eksekusi ke G lain. Setelah suspend, G tidak mengeksekusi kode pengguna, juga tidak berada dalam antrian lokal, hanya menjaga referensi ke M dan P.

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

Di fungsi runtime.timesleep ada baris kode seperti ini, menentukan nilai t.f

go
t.f = goroutineReady

Fungsi runtime.goroutineReady ini digunakan untuk membangunkan goroutine yang suspend, ia akan memanggil fungsi runtime.ready untuk membangunkan goroutine

go
status := readgstatus(gp)
// Tandai runnable.
mp := acquirem()
casgstatus(gp, _Gwaiting, _Grunnable)
runqput(mp.p.ptr(), gp, next)
wakep()
releasem(mp)

Setelah dibangunkan, mengubah status G menjadi _Grunnable, kemudian menempatkan G di antrian lokal P menunggu dijadwalkan di kemudian hari.

Stack Goroutine

Goroutine dalam bahasa go adalah goroutine ber-stack yang khas, setiap开启 goroutine akan mengalokasikan ruang stack independen di heap untuknya, dan ia akan tumbuh atau menyusut seiring dengan perubahan penggunaan. Saat inisialisasi scheduler, fungsi runtime.stackinit bertanggung jawab untuk menginisialisasi cache ruang stack global stackpool dan stackLarge.

go
func stackinit() {
  if _StackCacheSize&_PageMask != 0 {
    throw("cache size harus kelipatan ukuran halaman")
  }
  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)
  }
}

Selain itu, setiap P memiliki cache ruang stack independen sendiri 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
}

Cache thread mcache adalah independen untuk setiap thread dan tidak dialokasikan di memori heap, saat akses tidak perlu加锁,ketiga cache stack ini akan digunakan saat alokasi ruang di kemudian hari.

Alokasi

Saat membuat goroutine, jika tidak ada goroutine yang dapat dimanfaatkan kembali, akan memilih untuk mengalokasikan ruang stack baru untuknya, ukurannya default adalah 2KB.

go
newg := gfget(pp)
if newg == nil {
    newg = malg(stackMin)
    casgstatus(newg, _Gidle, _Gdead)
    allgadd(newg) // mempublikasikan dengan status g->status Gdead sehingga scanner GC tidak melihat stack yang belum diinisialisasi.
}

Fungsi yang bertanggung jawab untuk mengalokasikan ruang stack adalah runtime.stackalloc

go
func stackalloc(n uint32) stack

Berdasarkan apakah ukuran memori stack yang diminta kurang dari 32KB dibagi menjadi dua situasi, 32KB juga merupakan standar go untuk判断 apakah objek kecil atau objek besar. Jika kurang dari nilai ini akan mendapatkan dari cache stackpool, ketika M terkait dengan P dan M tidak boleh di-preempt, akan mendapatkan dari cache thread lokal.

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

Jika lebih besar dari 32KB, akan mendapatkan dari cache stackLarge, jika masih tidak cukup akan langsung mengalokasikan memori di heap.

go
else {
    var s *mspan
    npage := uintptr(n) >> _PageShift
    log2npage := stacklog2(npage)

    // Coba dapatkan stack dari cache stack besar.
    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 {
        // Alokasikan stack baru dari heap.
        s = mheap_.allocManual(npage, spanAllocStack)
        if s == nil {
            throw("out of memory")
        }
        osStackAlloc(s)
        s.elemsize = uintptr(n)
    }
    v = unsafe.Pointer(s.base())
}

Setelah selesai return alamat rendah dan alamat tinggi ruang stack

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

Ekspansi

Ukuran stack goroutine default adalah 2KB, cukup ringan, jadi biaya membuat goroutine sangat rendah, tetapi ini tidak selalu cukup, ketika ruang stack tidak cukup perlu ekspansi. Compiler akan menyisipkan fungsi runtime.morestack di awal fungsi untuk memeriksa apakah goroutine saat ini perlu ekspansi stack, jika perlu memanggil runtime.newstack untuk menyelesaikan operasi ekspansi yang sebenarnya.

TIP

Karena morestack hampir disisipkan di awal semua fungsi, jadi waktu pemeriksaan ekspansi stack juga merupakan titik preempt goroutine.

go
thisg := getg()
gp := thisg.m.curg
// Alokasikan segmen yang lebih besar dan pindahkan stack.
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize * 2

// Goroutine harus dieksekusi untuk memanggil newstack,
// jadi harus Grunning (atau Gscanrunning).
casgstatus(gp, _Grunning, _Gcopystack)

// GC konkuren tidak akan memindai stack saat kita melakukan copy karena
// gp berada dalam status Gcopystack.
copystack(gp, newsize)
casgstatus(gp, _Gcopystack, _Grunning)
gogo(&gp.sched)

Dapat dilihat, kapasitas ruang stack yang dihitung adalah dua kali lipat dari aslinya, diselesaikan oleh fungsi runtime.copystack untuk menyelesaikan pekerjaan copy stack, sebelum copy status G berubah dari _Grunning menjadi _Gcopystack.

go
func copystack(gp *g, newsize uintptr) {
  old := gp.stack
  used := old.hi - gp.sched.sp

  // alokasikan stack baru
  new := stackalloc(uint32(newsize))

  // Hitung penyesuaian.
  var adjinfo adjustinfo
  adjinfo.old = old
  adjinfo.delta = new.hi - old.hi

  // Copy stack (atau sisanya) ke lokasi baru
  memmove(unsafe.Pointer(new.hi-ncopy), unsafe.Pointer(old.hi-ncopy), ncopy)

  // Sesuaikan struktur tersisa yang memiliki pointer ke stack.
  // Kita harus melakukan sebagian besar ini sebelum kita traceback stack baru
  // karena gentraceback menggunakannya.
  adjustctxt(gp, &adjinfo)
  adjustdefers(gp, &adjinfo)
  adjustpanics(gp, &adjinfo)
  if adjinfo.sghi != 0 {
    adjinfo.sghi += adjinfo.delta
  }

  // Tukar stack lama dengan stack baru
  gp.stack = new
  gp.stackguard0 = new.lo + stackGuard // CATATAN: mungkin menimpa permintaan preempt
  gp.sched.sp = new.hi - used
  gp.stktopsp += adjinfo.delta

  // Sesuaikan pointer di stack baru.
  var u unwinder
  for u.init(gp, 0); u.valid(); u.next() {
    adjustframe(&u.frame, &adjinfo)
  }

  stackfree(old)
}

Fungsi ini total melakukan beberapa pekerjaan berikut

  1. Alokasikan ruang stack baru
  2. Copy memori stack lama ke ruang stack baru melalui runtime.memmove,
  3. Sesuaikan struktur yang berisi pointer stack, seperti defer, panic, dll
  4. Perbarui field ruang stack G
  5. Sesuaikan pointer yang menunjuk ke memori stack lama melalui runtime.adjustframe
  6. Lepaskan memori stack lama

Setelah selesai, status G berubah dari _Gcopystack menjadi _Grunning, dan oleh fungsi runtime.gogo membuat G melanjutkan eksekusi kode pengguna. Justru karena adanya ekspansi stack goroutine, jadi memori di go tidak stabil.

Kontraksi

Ketika status G adalah _Grunnable, _Gsyscall, _Gwaiting, GC akan memindai ruang memori stack goroutine.

go
func scanstack(gp *g, gcw *gcWork) int64 {
  switch readgstatus(gp) &^ _Gscan {
  case _Grunnable, _Gsyscall, _Gwaiting:
    // ok
  }
    ...

  if isShrinkStackSafe(gp) {
    // Kecilkan stack jika tidak banyak yang digunakan.
    shrinkstack(gp)
  }
    ...
}

Pekerjaan kontraksi stack yang sebenarnya diselesaikan oleh runtime.shrinkstack.

go
func shrinkstack(gp *g) {
  if !isShrinkStackSafe(gp) {
    throw("shrinkstack pada waktu yang salah")
  }

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

Ketika ruang stack yang digunakan kurang dari 1/4 dari aslinya, akan oleh runtime.copystack mengecilkannya menjadi 1/2 dari aslinya, pekerjaan setelahnya tidak berbeda dengan sebelumnya.

Stack Tersegmentasi

Dari proses copystack dapat dilihat, ia akan copy memori stack lama ke ruang stack yang lebih besar, baik stack asli maupun stack baru alamat memorinya kontinu. Sedangkan di masa lalu bahasa go, saat ekspansi stack做法 berbeda dengan sekarang, waktu itu merasa copy memori terlalu消耗 performa, mengadopsi思路 stack tersegmentasi, jika memori ruang stack tidak cukup, akan mengajukan ruang stack baru, memori ruang stack yang ada tidak akan dilepaskan juga tidak akan dicopy,彼此 sebelumnya melalui pointer linked bersama, membentuk stack linked list, ini adalah asal usul stack tersegmentasi, seperti yang ditunjukkan pada gambar berikut

Keuntungan melakukan ini adalah tidak perlu copy stack yang ada, tetapi kekurangannya juga sangat jelas, yaitu akan sangat sering memicu ekspansi dan kontraksi stack. Ketika ruang idle stack hampir habis, pemanggilan fungsi baru akan memicu ekspansi stack, ketika fungsi-fungsi ini return, tidak perlu ruang stack baru lagi akan memicu kontraksi, jika frekuensi pemanggilan fungsi-fungsi ini sangat tinggi, maka akan sangat sering memicu ekspansi dan kontraksi,损耗 performa yang disebabkan oleh operasi ini sangat besar.

Jadi setelah go1.4 diganti menjadi stack kontinu, stack kontinu karena dialokasikan ruang stack dengan kapasitas lebih besar, tidak akan muncul situasi memori yang digunakan mencapai nilai kritis karena pemanggilan fungsi而 sering memicu ekspansi dan kontraksi, dan karena alamat memori kontinu, menurut prinsip lokalitas ruang cache, stack kontinu juga lebih ramah terhadap cache CPU.

Loop Penjadwalan

Di bagian inisialisasi scheduler disebutkan, di fungsi runtime.mstart1, setelah M terkait dengan P berhasil akan masuk runtime.schedule loop penjadwalan pertama secara resmi mulai menjadwalkan G untuk mengeksekusi kode pengguna. Dalam loop penjadwalan, bagian ini terutama P yang berperan. M berkorespondensi dengan thread sistem, G berkorespondensi dengan fungsi入口 yaitu kode pengguna, tetapi P tidak seperti M dan G yang memiliki entitas yang sesuai, ia hanya konsep abstrak, sebagai perantara menangani hubungan antara M dan G.

go
func schedule() {
  mp := getg().m

top:
  pp := mp.p.ptr()
  pp.preempt = false

    if mp.spinning {
      resetspinning()
    }

  gp, inheritTime, tryWakeP := findRunnable() // blocks sampai pekerjaan tersedia

  execute(gp, inheritTime)
}

Kode di atas telah disederhanakan, menghapus banyak判断 kondisi, poin inti hanya dua runtime.findRunnable dan runtime.execute, yang pertama bertanggung jawab untuk menemukan G, dan pasti akan return G yang tersedia, yang kedua bertanggung jawab untuk membuat G melanjutkan eksekusi kode pengguna.

Untuk fungsi findRunnable而言,sumber G pertama adalah antrian lokal P

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

Jika antrian lokal tidak ada G, maka coba dapatkan dari antrian global

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

Jika tidak ditemukan di lokal dan global, akan coba dapatkan dari network poller

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

Jika masih tidak menemukan, pada akhirnya akan mencuri G dari antrian lokal P lain. Saat membuat goroutine disebutkan, salah satu sumber G di antrian lokal P adalah goroutine anak yang diturunkan dari goroutine saat ini, namun tidak semua goroutine akan membuat goroutine anak, dengan demikian mungkin muncul situasi sebagian P sangat sibuk, sebagian P idle, ini akan menyebabkan situasi, sebagian G karena一直在 menunggu而迟迟无法被执行,而另一边的 P 十分清闲,什么事也没有。为了能够压榨所有的 P,让它们发挥最大的工作效率,当 P 找不到 G 的时候,就会去其它 P 的本地队列中"偷取"能够执行的 G,这样一来,每一个 P 都能拥有较为均匀的 G 队列,就很少会出现 P 与 P 之间隔岸观火的情况了。

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

runtime.stealWork akan memilih P secara acak untuk dicuri, pekerjaan mencuri yang sebenarnya diselesaikan oleh fungsi runtime.runqgrab, ia akan mencoba mencuri setengah G dari antrian lokal P tersebut.

go
for {
    h := atomic.LoadAcq(&pp.runqhead) // load-acquire, sinkronisasi dengan konsumen lain
    t := atomic.LoadAcq(&pp.runqtail) // load-acquire, sinkronisasi dengan produsen
    n := t - h
    n = n - n/2
    if n > uint32(len(pp.runq)/2) { // baca h dan t yang tidak konsisten
        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, commit consume
        return n
    }
}

Seluruh pekerjaan mencuri akan dilakukan empat kali, jika empat kali juga tidak mencuri G maka return. Jika pada akhirnya tidak dapat menemukan, M saat ini akan oleh runtime.stopm di-pause, sampai dibangunkan melanjutkan mengulangi langkah di atas. Ketika menemukan dan return sebuah G后,akan交给 runtime.execute untuk menjalankan 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)

Pertama perbarui curg M, kemudian perbarui status G menjadi _Grunning, terakhir交给 runtime.gogo untuk memulihkan eksekusi G.

Secara keseluruhan, dalam loop penjadwalan sumber G berdasarkan prioritas dibagi menjadi empat

  1. Antrian lokal P
  2. Antrian global
  3. Network poller
  4. Mencuri dari antrian lokal P lain

runtime.execute setelah eksekusi tidak akan return, G yang baru diperoleh juga tidak akan selamanya eksekusi, pada suatu时机触发调度后,hak eksekusinya akan dicabut, kemudian masuk loop penjadwalan baru, menyerahkan hak eksekusi ke G lain.

Strategi Penjadwalan

Durasi eksekusi kode pengguna G yang berbeda mungkin berbeda, sebagian G mungkin耗时 sangat panjang, sebagian G耗时 sangat pendek, eksekusi G yang panjang dapat menyebabkan G lain迟迟无法得到 eksekusi, jadi eksekusi G secara bergantian adalah cara yang benar, cara kerja ini dalam sistem operasi disebut konkurensi.

Penjadwalan Kooperatif

Ide dasar penjadwalan kooperatif adalah, membuat G sendiri menyerahkan hak eksekusi ke G lain, terutama ada dua cara.

Cara pertama adalah secara aktif menyerahkan hak eksekusi dalam kode pengguna, go menyediakan fungsi runtime.Gosched(), pengguna dapat sendiri menentukan kapan menyerahkan hak eksekusi, namun dalam banyak kasus detail pekerjaan internal scheduler bagi pengguna adalah black box, sulit untuk判断到底 kapan harus secara aktif menyerahkan hak, persyaratan bagi pengguna cukup tinggi, dan scheduler go berusaha untuk menyembunyikan sebagian besar detail bagi pengguna, mengejar cara penggunaan yang lebih sederhana, dalam situasi ini让 pengguna juga berpartisipasi dalam pekerjaan scheduler bukan hal yang baik.

Cara kedua adalah标记 preempt, meskipun namanya ada kata preempt, tetapi pada dasarnya masih strategi penjadwalan kooperatif. Ide adalah menyisipkan kode deteksi preempt di kepala fungsi runtime.morestack(), proses penyisipan diselesaikan selama periode kompilasi, sebelumnya disebutkan bahwa ia awalnya adalah fungsi yang digunakan untuk deteksi ekspansi stack, karena titik deteksinya adalah pemanggilan setiap fungsi, ini juga merupakan时机 yang baik untuk melakukan deteksi preempt. Bagian atas fungsi runtime.newstack semuanya melakukan deteksi preempt, bagian bawah melakukan deteksi ekspansi stack, sebelumnya untuk menghindari gangguan sudah menghapus bagian ini, sekarang mari lihat bagian ini melakukan apa. Pertama akan判断 preempt berdasarkan gp.stackguard0, jika tidak perlu akan melanjutkan eksekusi kode pengguna.

go
stackguard0 := atomic.Loaduintptr(&gp.stackguard0)
preempt := stackguard0 == stackPreempt
if preempt {
    if !canPreemptM(thisg.m) {
        gp.stackguard0 = gp.stack.lo + stackGuard
        gogo(&gp.sched) // tidak pernah return
    }
}

Ketika g.stackguard0 == stackPreempt时,oleh fungsi runtime.canPreemptM() untuk判断 apakah kondisi goroutine perlu di-preempt, kodenya adalah sebagai berikut,

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

Dapat dilihat dapat di-preempt perlu memenuhi empat kondisi

  1. M tidak dikunci
  2. Tidak sedang mengalokasikan memori
  3. Tidak menonaktifkan preempt
  4. P berada dalam status _Prunning

Sedangkan dalam dua situasi berikut akan mengatur g.stackguard0 menjadi stackPreempt

  • Ketika perlu garbage collection
  • Ketika terjadi system call
go
if preempt {
    if gp.preemptShrink {
        gp.preemptShrink = false
        shrinkstack(gp)
    }
    // Bertindak seperti goroutine memanggil runtime.Gosched.
    gopreempt_m(gp) // tidak pernah return
}

Terakhir akan masuk ke runtime.gopreempt_m() secara aktif menyerahkan hak eksekusi goroutine saat ini. Pertama memutus hubungan antara M dan G, status berubah menjadi _Grunnbale, kemudian menempatkan G di antrian global, terakhir masuk loop penjadwalan menyerahkan hak eksekusi ke G lain.

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

schedule()

Dengan demikian, semua goroutine saat melakukan pemanggilan fungsi都可能 masuk ke fungsi ini untuk deteksi preempt, strategi ini harus bergantung pada时机 pemanggilan fungsi ini untuk memicu preempt dan secara aktif menyerahkan hak. Sebelum 1.14, go selalu沿用 strategi penjadwalan ini, tetapi ini akan memiliki masalah, jika tidak ada pemanggilan fungsi, tidak dapat deteksi,比如 kode klasik berikut, seharusnya sudah muncul di banyak tutorial

go
func main() {
  // Batasi jumlah P hanya 1
  runtime.GOMAXPROCS(1)
    // Goroutine 1
  go func() {
    for {
      // Goroutine ini terus berputar kosong
    }
  }()
  // Masuk system call, goroutine utama menyerahkan hak ke goroutine lain
  time.Sleep(time.Millisecond)
  println("exit")
}

Kode membuat goroutine 1 yang berputar kosong, kemudian goroutine utama karena system call secara aktif menyerahkan hak, saat ini goroutine 1 sedang dijadwalkan, tetapi karena sama sekali tidak memanggil fungsi, juga tidak dapat melakukan deteksi preempt, karena P hanya satu, tidak ada P idle lain, ini akan menyebabkan goroutine utama selamanya tidak dapat dijadwalkan, exit juga selamanya tidak dapat输出,namun masalah ini juga terbatas pada go1.14 sebelumnya.

Penjadwalan Preemptif

官方 di go1.14 menambahkan strategi penjadwalan preemptif berbasis sinyal, ini adalah strategi preemptif asinkron, melalui cara mengirim sinyal thread asinkron untuk melakukan preempt thread, penjadwalan preemptif berbasis sinyal saat ini hanya ada dua入口,masing-masing adalah monitor sistem dan GC.

Dalam loop monitor sistem, akan traversing setiap P, jika G yang dijadwalkan P waktu eksekusinya melebihi 10ms, akan secara paksa memicu preempt. Pekerjaan ini diselesaikan oleh fungsi runtime.retake, berikut adalah kode yang disederhanakan.

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 jika berjalan terlalu lama.
      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)
}

Ketika perlu garbage collection, jika status G adalah _Grunning, yaitu masih berjalan, juga akan memicu preempt.

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

Dua入口 preempt pada akhirnya akan masuk ke fungsi runtime.preemptM, oleh ini untuk menyelesaikan pengiriman sinyal preempt. Ketika sinyal berhasil dikirim, callback handler sinyal runtime.sighandler yang didaftarkan melalui runtime.initsig saat runtime.mstart akan berguna, jika mendeteksi mengirim sinyal preempt, akan mulai preempt.

go
func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
  ...
  if sig == sigPreempt && debug.asyncpreemptoff == 0 && !delayedSignal {
    // Mungkin sinyal preempt.
    doSigPreempt(gp, c)
  }
    ...
}

doSigPreempt akan memodifikasi konteks goroutine target, menyuntikkan pemanggilan runtime.asyncPreempt.

go
func doSigPreempt(gp *g, ctxt *sigctxt) {
  // Periksa apakah G ini ingin di-preempt dan aman untuk
  // preempt.
  if wantAsyncPreempt(gp) {
    if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {
      // Sesuaikan PC dan suntikkan pemanggilan ke asyncPreempt.
      ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), newpc)
    }
  }
...

Dengan demikian ketika beralih kembali ke kode pengguna, goroutine target akan masuk ke fungsi runtime.asyncPreempt, dalam fungsi ini melibatkan pemanggilan runtime.asyncPreempt2.

go
TEXT ·asyncPreempt(SB),NOSPLIT|NOFRAME,$0-0
  PUSHQ BP
  MOVQ SP, BP
  // Simpan flag sebelum merusaknya
  PUSHFQ
  // obj tidak memahami ADD/SUB pada SP, tetapi memahami ADJSP
  ADJSP $368
  // Tetapi vet tidak memahami ADJSP, jadi tekan pemeriksaan stack vet
  ...
  CALL ·asyncPreempt2(SB)
  ...
  RET

Ia akan membuat goroutine saat ini berhenti bekerja dan melakukan loop penjadwalan baru sehingga menyerahkan hak eksekusi ke goroutine lain.

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

Seluruh proses ini terjadi di fungsi runtime.asyncPreempt, ia diimplementasikan oleh assembly (terletak di runtime/preempt_*.s) dan akan memulihkan konteks goroutine yang dimodifikasi sebelumnya setelah penjadwalan selesai, sehingga goroutine ini dapat pulih secara normal di kemudian hari. Setelah mengadopsi strategi preemptif asinkron, contoh sebelumnya tidak lagi akan memblokir goroutine utama selamanya, ketika goroutine berputar kosong berjalan一定 waktu后 akan secara paksa dieksekusi loop penjadwalan, sehingga menyerahkan hak eksekusi ke goroutine utama, pada akhirnya membuat program dapat berakhir secara normal.

Ringkasan

Secara keseluruhan,时机 yang memicu penjadwalan adalah sebagai berikut:

  • Pemanggilan fungsi
  • System call
  • Monitor sistem
  • Garbage collection, garbage collection untuk goroutine dengan waktu eksekusi terlalu lama juga akan melakukan preempt
  • Goroutine karena channel, lock dan alasan lain而 suspend

Strategi penjadwalan terutama dua kategori besar, kooperatif dan preemptif, kooperatif adalah secara aktif menyerahkan hak eksekusi, preemptif adalah secara asinkron preempt hak eksekusi, keduanya coexist membentuk scheduler saat ini.

Golang by www.golangdev.cn edit