
Hari ini saya memutuskan untuk menerjemahkan untuk Anda sebuah artikel pendek tentang bagian dalam implementasi yang disebut penutupan atau penutupan. Selain itu, Anda akan mempelajari cara Go mencoba menentukan secara otomatis apakah menggunakan pointer / tautan atau nilai dalam berbagai kasus. Memahami hal-hal ini akan menghindari kesalahan. Dan hanya saja semua bagian dalam ini sangat menarik, saya pikir!
Dan saya juga ingin mengundang Anda ke Golang Conf 2019 , yang akan diselenggarakan pada 7 Oktober di Moskow. Saya adalah anggota komite program konferensi, dan kolega saya dan saya telah memilih banyak laporan yang sama-sama hardcore dan sangat, sangat menarik. Apa yang saya sukai!
Di bawah potongan, saya menyampaikan kata itu kepada penulis.
Ada halaman di Go wiki berjudul Frequent Kesalahan . Anehnya, hanya ada satu contoh: penyalahgunaan variabel loop dengan goroutine:
for _, val := range values { go func() { fmt.Println(val) }() }
Kode ini akan menampilkan nilai terakhir dari array nilai len (values) kali. Memperbaiki kodenya sangat sederhana:
Contoh ini cukup untuk memahami masalah dan tidak pernah lagi membuat kesalahan. Tetapi jika Anda tertarik untuk mengetahui detail implementasi, artikel ini akan memberi Anda pemahaman yang mendalam tentang masalah dan solusinya.
Hal-hal dasar: lewat nilai dan lewat referensi
Dalam Go, ada perbedaan dalam melewatkan objek berdasarkan nilai dan referensi [1]. Mari kita mulai dengan contoh 1 [2]:
func foobyval(n int) { fmt.Println(n) } func main() { for i := 0; i < 5; i++ { go foobyval(i) } time.Sleep(100 * time.Millisecond) }
Tidak seorang pun, kemungkinan besar, memiliki keraguan bahwa hasilnya akan ditampilkan nilai dari 0 hingga 4. Mungkin dalam semacam urutan acak.
Mari kita lihat contoh 2 .
func foobyref(n *int) { fmt.Println(*n) } func main() { for i := 0; i < 5; i++ { go foobyref(&i) } time.Sleep(100 * time.Millisecond) }
Akibatnya, berikut ini akan ditampilkan:
5
5
5
5
5
Memahami mengapa hasilnya hanya itu akan memberi kita sudah 80% dari pemahaman tentang esensi masalah. Karena itu, mari luangkan waktu untuk menemukan alasannya.
Dan jawabannya ada di sana dalam spesifikasi bahasa Go . Spesifikasi berbunyi:
Variabel yang dideklarasikan dalam pernyataan inisialisasi digunakan kembali di setiap loop.
Ini berarti bahwa ketika program sedang berjalan, hanya ada satu objek atau sepotong memori untuk variabel i, dan bukan yang baru dibuat untuk setiap siklus. Objek ini mengambil nilai baru di setiap iterasi.
Mari kita lihat perbedaan dalam kode mesin yang dihasilkan [3] untuk loop dalam contoh 1 dan 2. Mari kita mulai dengan contoh 1.
0x0026 00038 (go-func-byval.go:14) MOVL $8, (SP) 0x002d 00045 (go-func-byval.go:14) LEAQ "".foobyval·f(SB), CX 0x0034 00052 (go-func-byval.go:14) MOVQ CX, 8(SP) 0x0039 00057 (go-func-byval.go:14) MOVQ AX, 16(SP) 0x003e 00062 (go-func-byval.go:14) CALL runtime.newproc(SB) 0x0043 00067 (go-func-byval.go:13) MOVQ "".i+24(SP), AX 0x0048 00072 (go-func-byval.go:13) INCQ AX 0x004b 00075 (go-func-byval.go:13) CMPQ AX, $5 0x004f 00079 (go-func-byval.go:13) JLT 33
Pernyataan Go menjadi panggilan ke fungsi runtime.newproc. Mekanisme dari proses ini sangat menarik, tetapi mari kita tinggalkan ini untuk artikel selanjutnya. Sekarang kita lebih tertarik pada apa yang terjadi pada variabel i. Ini disimpan dalam register AX, yang kemudian diteruskan oleh nilai melalui tumpukan ke fungsi foobyval [4] sebagai argumennya. “Berdasarkan nilai” dalam hal ini seperti menyalin nilai register AX ke stack. Dan mengubah AX di masa depan tidak mempengaruhi apa yang diteruskan ke fungsi foobyval.
Dan berikut ini contohnya 2:
0x0040 00064 (go-func-byref.go:14) LEAQ "".foobyref·f(SB), CX 0x0047 00071 (go-func-byref.go:14) MOVQ CX, 8(SP) 0x004c 00076 (go-func-byref.go:14) MOVQ AX, 16(SP) 0x0051 00081 (go-func-byref.go:14) CALL runtime.newproc(SB) 0x0056 00086 (go-func-byref.go:13) MOVQ "".&i+24(SP), AX 0x005b 00091 (go-func-byref.go:13) INCQ (AX) 0x005e 00094 (go-func-byref.go:13) CMPQ (AX), $5 0x0062 00098 (go-func-byref.go:13) JLT 57
Kode ini sangat mirip - hanya dengan satu perbedaan, tetapi sangat penting. Sekarang di AX adalah alamat i, dan bukan nilainya. Perhatikan juga bahwa penambahan dan perbandingan untuk loop dilakukan pada (AX), bukan AX. Dan kemudian, ketika kita menaruh AX di tumpukan, kita, ternyata, meneruskan alamat i ke fungsi. Perubahan (AX) juga akan terlihat di goroutine.
Tidak ada kejutan. Pada akhirnya, kita meneruskan sebuah pointer ke angka dalam fungsi foobyref.
Selama operasi, siklus berakhir lebih cepat daripada goroutine yang dibuat mulai bekerja. Ketika mereka mulai bekerja, mereka akan memiliki pointer ke variabel i yang sama, dan bukan ke salinan. Dan apa nilai saya saat ini? Nilainya 5. Sangat di mana siklus berhenti. Dan itulah sebabnya semua goroutine berasal dari 5.
Metode dengan metode nilai VS dengan pointer
Perilaku serupa dapat diamati ketika membuat goroutine yang menggunakan metode apa pun. Ini ditunjukkan oleh halaman wiki yang sama. Lihat contoh 3 :
type MyInt int func (mi MyInt) Show() { fmt.Println(mi) } func main() { ms := []MyInt{50, 60, 70, 80, 90} for _, m := range ms { go m.Show() } time.Sleep(100 * time.Millisecond) }
Contoh ini menampilkan elemen-elemen dari array ms. Secara acak, seperti yang kami harapkan. Contoh yang sangat mirip 4 menggunakan metode pointer untuk metode Show:
type MyInt int func (mi *MyInt) Show() { fmt.Println(*mi) } func main() { ms := []MyInt{50, 60, 70, 80, 90} for _, m := range ms { go m.Show() } time.Sleep(100 * time.Millisecond) }
Coba tebak kesimpulannya nanti: 90, dicetak lima kali. Alasannya sama seperti pada contoh 2. Di sini masalahnya kurang terlihat karena gula sintaksis di Go saat menggunakan metode pointer. Jika dalam contoh, ketika beralih dari contoh 1 ke contoh 2, kami mengubah i ke & i, di sini panggilannya terlihat sama! m.Tampilkan () dalam kedua contoh, dan perilaku berbeda.
Bukan kombinasi yang sangat menyenangkan dari dua fitur Go, menurut saya. Tidak ada di tempat panggilan yang menunjukkan transmisi dengan referensi. Dan Anda perlu melihat implementasi dari metode Show untuk melihat dengan tepat bagaimana panggilan akan terjadi (dan metode ini, tentu saja, dapat berupa file atau paket yang sama sekali berbeda).
Dalam kebanyakan kasus, fitur ini bermanfaat. Kami menulis kode pembersih. Tapi di sini, lewat referensi mengarah ke efek yang tidak terduga.
Sirkuit pendek
Akhirnya kami sampai pada penutupan. Mari kita lihat contoh 5 :
func foobyval(n int) { fmt.Println(n) } func main() { for i := 0; i < 5; i++ { go func() { foobyval(i) }() } time.Sleep(100 * time.Millisecond) }
Dia akan mencetak yang berikut ini:
5
5
5
5
5
Dan ini terlepas dari kenyataan bahwa saya diteruskan dengan nilai ke foobyval di penutupan. Mirip dengan contoh 1. Tapi mengapa? Mari kita lihat tampilan loop assembler:
0x0040 00064 (go-closure.go:14) LEAQ "".main.func1·f(SB), CX 0x0047 00071 (go-closure.go:14) MOVQ CX, 8(SP) 0x004c 00076 (go-closure.go:14) MOVQ AX, 16(SP) 0x0051 00081 (go-closure.go:14) CALL runtime.newproc(SB) 0x0056 00086 (go-closure.go:13) MOVQ "".&i+24(SP), AX 0x005b 00091 (go-closure.go:13) INCQ (AX) 0x005e 00094 (go-closure.go:13) CMPQ (AX), $5 0x0062 00098 (go-closure.go:13) JLT 57
Kode ini sangat mirip dengan Contoh 2: perhatikan bahwa saya diwakili oleh alamat dalam register AX. Artinya, kami melewati saya dengan referensi. Dan ini terlepas dari fakta bahwa foobyval dipanggil. Tubuh loop memanggil fungsi menggunakan runtime.newproc, tetapi dari mana fungsi ini berasal?
Func1 dibuat oleh kompiler, dan itu adalah penutup. Kompiler telah mengalokasikan kode penutupan sebagai fungsi terpisah dan memanggilnya dari main. Masalah utama dengan alokasi ini adalah bagaimana menangani variabel yang menggunakan penutupan, tetapi yang jelas bukan argumen.
Seperti inilah bentuk tubuh func1:
0x0000 00000 (go-closure.go:14) MOVQ (TLS), CX 0x0009 00009 (go-closure.go:14) CMPQ SP, 16(CX) 0x000d 00013 (go-closure.go:14) JLS 56 0x000f 00015 (go-closure.go:14) SUBQ $16, SP 0x0013 00019 (go-closure.go:14) MOVQ BP, 8(SP) 0x0018 00024 (go-closure.go:14) LEAQ 8(SP), BP 0x001d 00029 (go-closure.go:15) MOVQ "".&i+24(SP), AX 0x0022 00034 (go-closure.go:15) MOVQ (AX), AX 0x0025 00037 (go-closure.go:15) MOVQ AX, (SP) 0x0029 00041 (go-closure.go:15) CALL "".foobyval(SB) 0x002e 00046 (go-closure.go:16) MOVQ 8(SP), BP 0x0033 00051 (go-closure.go:16) ADDQ $16, SP 0x0037 00055 (go-closure.go:16) RET
Sangat menarik di sini bahwa fungsi memiliki argumen dalam 24 (SP), yang merupakan pointer ke int: lihat garis MOVQ (AX), AX, yang mengambil nilai sebelum meneruskannya ke foobyval. Bahkan, func1 terlihat seperti ini:
func func1(i *int) { foobyval(*i) } main - : for i := 0; i < 5; i++ { go func1(&i) }
Menerima yang setara dengan contoh 2, dan ini menjelaskan kesimpulannya. Dalam bahasa teknis, kami akan mengatakan bahwa saya adalah variabel bebas di dalam penutupan dan variabel tersebut ditangkap dengan referensi di Go.
Tetapi apakah ini selalu terjadi? Anehnya, jawabannya adalah tidak. Dalam beberapa kasus, variabel bebas ditangkap oleh nilai. Berikut adalah variasi dari contoh kami:
for i := 0; i < 5; i++ { ii := i go func() { foobyval(ii) }() }
Contoh ini akan menampilkan 0, 1, 2, 3, 4 dalam urutan acak. Tetapi mengapa perilaku di sini berbeda dari Contoh 5?
Ternyata perilaku ini merupakan artefak heuristik yang digunakan kompilator Go ketika bekerja dengan penutupan.
Kami melihat di bawah tenda
Jika Anda tidak terbiasa dengan arsitektur kompiler Go, saya sarankan Anda membaca artikel awal saya tentang topik ini: Bagian 1 , Bagian 2 .
Pohon sintaksis spesifik (yang bertentangan dengan abstrak) yang diperoleh dengan menguraikan kode terlihat seperti ini:
0: *syntax.CallStmt { . Tok: go . Call: *syntax.CallExpr { . . Fun: *syntax.FuncLit { . . . Type: *syntax.FuncType { . . . . ParamList: nil . . . . ResultList: nil . . . } . . . Body: *syntax.BlockStmt { . . . . List: []syntax.Stmt (1 entries) { . . . . . 0: *syntax.ExprStmt { . . . . . . X: *syntax.CallExpr { . . . . . . . Fun: foobyval @ go-closure.go:15:4 . . . . . . . ArgList: []syntax.Expr (1 entries) { . . . . . . . . 0: i @ go-closure.go:15:13 . . . . . . . } . . . . . . . HasDots: false . . . . . . } . . . . . } . . . . } . . . . Rbrace: syntax.Pos {} . . . } . . } . . ArgList: nil . . HasDots: false . } }
Fungsi yang disebut diwakili oleh simpul FuncLit, fungsi konstan. Ketika pohon ini dikonversi ke AST (pohon sintaksis abstrak), menyoroti fungsi konstan ini sebagai yang terpisah akan menjadi hasilnya. Ini terjadi pada metode noder.funcLit, yang hidup di gc / closure.go.
Kemudian pemeriksa tipe menyelesaikan transformasi, dan kami mendapatkan representasi berikut untuk fungsi di AST:
main.func1: . DCLFUNC l(14) tc(1) FUNC-func() . DCLFUNC-body . . CALLFUNC l(15) tc(1) . . . NAME-main.foobyval a(true) l(8) x(0) class(PFUNC) tc(1) used FUNC-func(int) . . CALLFUNC-list . . . NAME-main.il(15) x(0) class(PAUTOHEAP) tc(1) used int
Perhatikan bahwa nilai yang diteruskan ke foobyval adalah NAME-main.i, yaitu, kami secara eksplisit menunjuk ke variabel dari fungsi yang membungkus penutupan.
Pada tahap ini, tahap kompiler, yang disebut captvars, yaitu, "menangkap variabel", mulai beroperasi. Tujuannya adalah untuk memutuskan bagaimana menangkap "variabel tertutup" (yaitu, variabel bebas yang digunakan dalam penutupan). Berikut adalah komentar dari fungsi kompiler yang sesuai, yang juga menjelaskan heuristik:
// capturevars dipanggil dalam fase terpisah setelah semua jenis dicek.
// Ini memutuskan apakah akan menangkap variabel dengan nilai atau dengan referensi.
// Kami menggunakan tangkapan oleh nilai untuk nilai <= 128 byte yang tidak lagi mengubah nilai setelah penangkapan (pada dasarnya konstanta).
Ketika capturevars disebut dalam Contoh 5, ia memutuskan bahwa variabel loop i harus ditangkap dengan referensi, dan menambahkan flag addrtaken yang sesuai dengannya. Ini dapat dilihat pada output AST:
FOR l(13) tc(1) . LT l(13) tc(1) bool . . NAME-main.ia(true) g(1) l(13) x(0) class(PAUTOHEAP) esc(h) tc(1) addrtaken assigned used int
Untuk variabel loop, heuristik pemilihan "oleh nilai" tidak berfungsi, karena variabel mengubah nilainya setelah panggilan (ingat kutipan dari spesifikasi bahwa variabel loop digunakan kembali pada setiap iterasi). Oleh karena itu, variabel i ditangkap dengan referensi.
Dalam variasi contoh kita, di mana kita memiliki ii: = i, ii tidak digunakan lagi dan oleh karena itu ditangkap oleh nilai [5].
Jadi, kita melihat contoh yang menakjubkan dari tumpang tindih dua fitur bahasa yang berbeda secara tak terduga. Alih-alih menggunakan variabel baru di setiap iterasi loop, Go menggunakan yang sama. Ini, pada gilirannya, mengarah pada pemicu heuristik dan pilihan penangkapan dengan referensi, dan ini mengarah pada hasil yang tidak terduga. Go FAQ mengatakan bahwa perilaku ini mungkin merupakan kesalahan desain.
Perilaku ini (jangan gunakan variabel baru) mungkin merupakan kesalahan saat merancang bahasa. Mungkin kami akan memperbaikinya di versi mendatang, tetapi karena kompatibilitas ke belakang kami tidak dapat melakukan apa pun di Go versi 1.
Jika Anda mengetahui masalahnya, kemungkinan besar Anda tidak akan menginjak rake ini. Namun perlu diingat bahwa variabel bebas selalu dapat ditangkap dengan referensi. Untuk menghindari kesalahan, pastikan bahwa hanya variabel read-only yang ditangkap saat menggunakan goroutin. Ini juga penting karena potensi masalah dengan penerbangan data.
[1] Beberapa pembaca telah memperhatikan bahwa, sesungguhnya, tidak ada konsep "lewat referensi" di Go, karena semuanya dilewatkan oleh nilai, termasuk petunjuk. Dalam artikel ini, ketika Anda melihat "pass by reference", maksud saya "pass by address" dan itu eksplisit dalam beberapa kasus (seperti meneruskan & n ke fungsi yang mengharapkan * int), dan dalam beberapa kasus implisit, seperti pada yang nanti bagian dari artikel.
[2] Selanjutnya, saya menggunakan waktu. Tidur sebagai cara cepat dan kotor untuk menunggu semua goroutine selesai. Tanpa ini, main akan berakhir sebelum goroutine mulai bekerja. Cara yang tepat untuk melakukan ini adalah dengan menggunakan sesuatu seperti WaitGroup atau saluran yang dilakukan.
[3] Representasi assembler untuk semua contoh dalam artikel ini diperoleh dengan menggunakan perintah go tool compile -l -S. Flag -l menonaktifkan fungsi inlining dan membuat kode assembler lebih mudah dibaca.
[4] Foobyval tidak dipanggil secara langsung, karena panggilannya melewati panggilan. Sebaliknya, alamat dilewatkan sebagai argumen kedua (16 (SP)) ke fungsi runtime.newproc, dan argumen untuk foobyval (i dalam kasus ini) naik stack.
[5] Sebagai latihan, tambahkan ii = 10 sebagai baris terakhir dari for loop (setelah memanggil go). Apa kesimpulan Anda? Mengapa