Penjadwal GO: Sekarang Bukan Koperasi?

Jika Anda membaca catatan rilis untuk versi GO 1.14, Anda mungkin memperhatikan beberapa perubahan yang cukup menarik pada runtime bahasa. Jadi saya sangat tertarik pada item tersebut: "Goroutine sekarang lebih dulu tidak sinkron." Ternyata penjadwal GO (scheduler) sekarang tidak kooperatif? Nah, setelah membaca proposal yang sesuai secara diagonal, rasa penasaran pun puas.

Namun, setelah beberapa saat saya memutuskan untuk meneliti inovasi secara lebih rinci. Saya ingin membagikan hasil studi ini.

gambar

Persyaratan sistem


Hal-hal yang diuraikan di bawah ini diperlukan dari pembaca, selain pengetahuan tentang bahasa GO, pengetahuan tambahan, yaitu:

  • memahami prinsip-prinsip penjadwal (walaupun saya akan mencoba menjelaskan di bawah ini, "dengan jari")
  • memahami cara kerja pengumpul sampah
  • memahami apa itu GO assembler

Pada akhirnya, saya akan meninggalkan beberapa tautan yang, menurut pendapat saya, membahas topik-topik ini dengan baik.

Secara singkat tentang perencana


Pertama, izinkan saya mengingatkan Anda apa multitasking kooperatif dan non-kooperatif.

Dengan multitasking non-kooperatif, kita semua terbiasa dengan contoh penjadwal OS. Penjadwal ini berfungsi di latar belakang, membongkar utas berdasarkan berbagai heuristik, dan alih-alih waktu CPU yang dibongkar, utas lain mulai menerima.

Perencana kooperatif ditandai oleh perilaku yang berbeda - ia tidur sampai salah satu goroutin jelas membangunkannya dengan sedikit kesiapan untuk memberikan tempatnya kepada yang lain. Perencana kemudian akan memutuskan sendiri apakah perlu menghapus goroutine saat ini dari konteksnya, dan jika demikian, siapa yang akan menggantikannya. Begitulah cara penjadwal GO bekerja.

Selain itu, kami mempertimbangkan landasan yang dioperasikan penjadwal:

  • Prosesor P - logis (kita dapat mengubah angka mereka dengan fungsi runtime.GOMAXPROCS), pada setiap prosesor logis satu goroutine dapat dieksekusi secara mandiri pada satu waktu.
  • Utas M - OS. Setiap P berjalan pada utas dari M. Perhatikan bahwa P tidak selalu sama dengan M, misalnya, utas dapat diblokir oleh syscall dan kemudian utas lainnya akan dialokasikan untuk P. Dan ada CGO dan nuansa lainnya dan lainnya.
  • G - gorutins. Nah di sini jelas, G harus dijalankan pada setiap P dan scheduler memonitor ini.

Dan hal terakhir yang perlu Anda ketahui, dan kapan penjadwal benar-benar memanggil goroutine? Ini sederhana, biasanya instruksi untuk memanggil dimasukkan oleh kompiler di awal tubuh (prolog) dari fungsi (sedikit kemudian kita akan membicarakan hal ini secara lebih rinci).

Dan apa masalahnya sebenarnya?


gambar

Dari awal artikel, Anda sudah mengerti bahwa prinsip pekerjaan penjadwal telah berubah di GO, mari kita lihat alasan mengapa perubahan ini dilakukan. Lihatlah kodenya:

di bawah spoiler
func main() {
	runtime.GOMAXPROCS(1)
	go func() {
		var u int
		for {
			u -= 2
			if u == 1 {
				break
			}
		}
	}()
	<-time.After(time.Millisecond * 5) //    main   ,         

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


Jika Anda mengompilasinya dengan versi GO <1.14, maka baris "go 1.13 belum pernah ke sini" Anda tidak akan melihat di layar. Ini terjadi karena, segera setelah penjadwal memberi waktu prosesor kepada goroutine dengan loop tak terbatas, ia sepenuhnya menangkap P, tidak ada panggilan fungsi yang terjadi di dalam goroutine ini, yang berarti kita tidak akan membangunkan penjadwal lagi. Dan hanya panggilan eksplisit ke runtime.Gosched () akan membiarkan program kami berakhir.

Ini hanyalah salah satu contoh di mana goroutine menangkap P dan untuk waktu yang lama mencegah goroutine lain dari mengeksekusi pada P. ini. Opsi lain ketika perilaku ini menyebabkan masalah dapat ditemukan dengan membaca proposal.

Usulkan proposal


Solusi untuk masalah ini cukup sederhana. Mari kita lakukan hal yang sama seperti pada penjadwal OS! Biarkan GO kehabisan goroutine dari P dan letakkan yang lain di sana, dan untuk ini kita akan menggunakan alat OS.

OK, bagaimana cara mengimplementasikannya? Kami akan mengizinkan runtime untuk mengirim sinyal ke aliran tempat goroutine bekerja. Kami akan mendaftarkan prosesor sinyal ini pada setiap aliran dari M, tugas prosesor adalah untuk menentukan apakah goroutine saat ini dapat digantikan. Jika demikian, kami akan menyimpan status saat ini (register dan status tumpukan) dan memberikan sumber daya ke yang lain, jika tidak kami akan terus menjalankan goroutine saat ini. Perlu dicatat bahwa konsep dengan sinyal adalah solusi untuk sistem berbasis UNIX, sementara, misalnya, implementasi untuk Windows sedikit berbeda. Omong-omong, SIGURG dipilih sebagai sinyal untuk pengiriman.

Bagian tersulit dari implementasi ini adalah menentukan apakah goroutine dapat dipaksa keluar. Faktanya adalah bahwa beberapa tempat dalam kode kita harus berupa atom, dari sudut pandang pengumpul sampah. Kami menyebut tempat-tempat semacam itu tidak aman. Jika kita menekan goroutine pada saat eksekusi kode dari titik yang tidak aman, dan kemudian GC mulai, maka itu akan menangkap keadaan goroutine kita yang diambil di titik yang tidak aman dan dapat melakukan hal-hal. Mari kita lihat lebih dekat konsep aman / tidak aman.

Apakah Anda pergi ke sana, GC?


gambar

Dalam versi sebelum 1.12, runtime Gosched menggunakan titik aman di tempat-tempat di mana Anda pasti dapat memanggil penjadwal tanpa takut bahwa kita akan berakhir di bagian atom kode untuk GC. Seperti yang telah kami katakan, data titik aman terletak di prolog suatu fungsi (tetapi tidak untuk setiap fungsi, ingatlah). Jika Anda membongkar assembler go-shn, Anda bisa keberatan - tidak ada panggilan penjadwal yang terlihat di sana. Ya, tetapi Anda dapat menemukan instruksi panggilan runtime.morestack di sana, dan jika Anda melihat ke dalam fungsi ini, panggilan penjadwal akan ditemukan. Di bawah spoiler, saya akan menyembunyikan komentar dari sumber GO, atau Anda dapat menemukan assembler untuk morestack sendiri.

ditemukan dalam sumber
Synchronous safe-points are implemented by overloading the stack bound check in function prologues. To preempt a goroutine at the next synchronous safe-point, the runtime poisons the goroutine's stack bound to a value that will cause the next stack bound check to fail and enter the stack growth implementation, which will detect that it was actually a preemption and redirect to preemption handling.

Jelas, ketika beralih ke konsep crowding-out, sinyal crowding-out dapat menangkap gorutin kita di mana saja. Tetapi penulis GO memutuskan untuk tidak meninggalkan titik aman, tetapi menyatakan titik aman di mana-mana! Yah, tentu saja, ada tangkapan, hampir di mana-mana sebenarnya. Seperti disebutkan di atas, ada beberapa poin tidak aman di mana kami tidak akan memaksa siapa pun. Mari kita menulis poin sederhana yang tidak aman.


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

Untuk memahami apa masalahnya, mari kita coba pada pengumpul sampah. Setiap kali kita pergi bekerja, kita perlu mencari tahu node root (pointer pada stack dan register), yang dengannya kita akan mulai menandai. Karena tidak mungkin untuk mengatakan dalam runtime apakah 64 byte dalam memori adalah sebuah pointer atau hanya sebuah angka, kita beralih ke tumpukan dan mendaftar kartu (beberapa cache dengan informasi meta), disediakan kepada kami oleh kompiler GO. Informasi dalam peta ini memungkinkan kami untuk menemukan petunjuk. Jadi, kami terbangun dan dikirim ke kantor ketika GO menampilkan nomor 4. Tiba di tempat dan melihat kartu-kartu, kami menemukan bahwa itu kosong (dan ini benar, karena uintptr dari sudut pandang GC adalah angka dan bukan penunjuk). Nah, kemarin kita mendengar tentang alokasi memori untuk j, karena sekarang kita tidak bisa mendapatkan memori ini - kita perlu membersihkannya, dan setelah melepas memori kita pergi tidur.Apa berikutnya? Nah, pihak berwenang bangun, pada malam hari, berteriak, yah, Anda sendiri mengerti.

Itu semua dengan teori, saya mengusulkan untuk mempertimbangkan dalam praktek bagaimana semua sinyal ini, poin yang tidak aman dan kartu register dan tumpukan bekerja.

Mari kita lanjutkan berlatih


Saya menjalankan dua kali (pergi 1,14 dan pergi 1,13) contoh dari awal artikel oleh profiler perf untuk melihat apa panggilan sistem yang terjadi dan membandingkannya. Syscall yang diperlukan dalam versi ke-14 ditemukan dengan cukup cepat:

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

Ya, tentu saja runtime mengirim SIGURG ke utas tempat goroutine berputar. Mengambil pengetahuan ini sebagai titik awal, saya pergi untuk melihat komit di GO untuk menemukan di mana dan untuk alasan apa sinyal ini dikirim, serta untuk menemukan tempat di mana pengendali sinyal dipasang. Mari kita mulai dengan mengirim, kita akan menemukan fungsi pengiriman sinyal di runtime / os_linux.go


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

Sekarang kami menemukan tempat di kode runtime, dari mana kami mengirim sinyal:

  1. saat goroutine ditangguhkan, jika sedang dalam kondisi berjalan. Permintaan penangguhan berasal dari pengumpul sampah. Di sini, mungkin, saya tidak akan menambahkan kode, tetapi dapat ditemukan di file runtime / preempt.go (suspendG)
  2. jika penjadwal memutuskan bahwa goroutine bekerja terlalu lama, runtime / proc.go (retake)
    
    if pd.schedwhen+forcePreemptNS <= now {
    	signalM(_p_)
    }
    

    forcePreemptNS - konstan sama dengan 10ms, pd.schedwhen - waktu ketika scheduler untuk aliran pd disebut terakhir kali
  3. serta semua aliran sinyal ini dikirim selama kepanikan, StopTheWorld (GC) dan beberapa kasus lainnya (yang harus saya bypass, karena ukuran artikel sudah akan melampaui batas)

Kami menemukan cara dan kapan runtime mengirim sinyal ke M. Sekarang mari kita cari pawang untuk sinyal ini dan lihat apa yang dilakukan arus ketika diterima.


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

Dari fungsi ini jelas bahwa untuk "mengunci" Anda harus melalui 2 pemeriksaan:

  1. wantAsyncPreempt - kami memeriksa apakah G ingin dipaksa keluar, di sini, misalnya, validitas status goroutine saat ini akan diperiksa.
  2. isAsyncSafePoint - periksa apakah itu bisa ramai sekarang. Yang paling menarik dari cek di sini adalah apakah G berada di titik aman atau tidak aman. Selain itu, kita harus yakin bahwa utas yang menjalankan G juga siap untuk mendahului G.

Jika kedua pemeriksaan dilewatkan, instruksi akan dipanggil dari kode yang dapat dieksekusi yang menyimpan status G dan memanggil penjadwal.

Dan lebih lanjut tentang tidak aman


Saya mengusulkan untuk menganalisis contoh baru, itu akan menggambarkan kasus lain dengan poin tidak aman:

program tanpa akhir lainnya

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

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

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


Seperti yang Anda tebak, tulisan "go 1.13 dan 1.14 tidak pernah ada di sini" tidak akan kita lihat di GO 1.14. Ini karena kami telah secara eksplisit melarang untuk mengganggu fungsi infiniteLoop (go: nosplit). Larangan semacam itu diterapkan hanya menggunakan titik tidak aman, yang merupakan seluruh tubuh fungsi. Mari kita lihat apa yang dikompilasi oleh kompiler untuk fungsi infiniteLoop.

Assembler Perhatian

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


Dalam kasus kami, instruksi PCDATA menarik. Ketika tautan melihat instruksi ini, itu tidak mengubahnya menjadi assembler "nyata". Sebaliknya, nilai argumen 2 dengan kunci sama dengan penghitung program yang sesuai (angka yang dapat diamati di sebelah kiri nama fungsi + baris) akan ditempatkan di register atau tumpukan peta (ditentukan oleh argumen 1).

Seperti yang kita lihat pada baris 10 dan 15, kita menempatkan nilai masing-masing $ 2 dan -1 di peta $ 0 dan $ 1. Mari kita ingat momen ini dan melihat ke dalam fungsi isAsyncSafePoint, yang saya telah menarik perhatian Anda. Di sana kita akan melihat baris berikut:

isAsyncSafePoint

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


Di tempat inilah kami memeriksa apakah goroutine saat ini berada di titik aman. Kami membuka peta register (_PCDATA_RegMapIndex = 0), dan meneruskannya ke pc saat ini kami memeriksa nilainya, jika -2 maka G tidak dalam safe-point'e, yang berarti tidak bisa dihilangkan.

Kesimpulan


Saya menghentikan "penelitian" saya tentang ini, saya harap artikel itu bermanfaat bagi Anda juga.
Saya memposting tautan yang dijanjikan, tetapi harap berhati-hati, karena beberapa informasi dalam artikel ini dapat kedaluwarsa.

Penjadwal GO - sekali dan dua kali .

Assembler GO.

All Articles