1 poin oleh GN⁺ 2023-09-20 | 1 komentar | Bagikan ke WhatsApp
  • Go 1.22 mengubah variabel loop for dari cakupan seluruh loop menjadi cakupan per iterasi, untuk mengurangi salah satu kesalahan paling khas di Go, yaitu closure yang keliru menangkap variabel yang sama
  • Dalam semantik lama, bahkan tanpa goroutine pun, fungsi yang dijalankan setelah iterasi selesai dapat merujuk ke v atau i yang sama, sehingga hanya melihat nilai terakhir atau membuat pengujian lolos secara keliru
  • Analyzer loopclosure di go vet dan gopls hanya menangkap kasus yang benar-benar pasti sehingga masih ada false negative, sementara pemeriksa yang lebih agresif bisa menambah kode x:= x yang tidak perlu karena false positive
  • Semantik baru ini hanya berlaku untuk modul yang mendeklarasikan go 1.22 atau lebih tinggi di go.mod, dan di Go 1.21 pratinjau-nya bisa dijalankan dengan GOEXPERIMENT=loopvar
  • Google telah memaksa mode ini untuk semua build di toolchain Go internal mereka sejak awal Mei 2023, dan selama 4 bulan tidak ada laporan masalah produksi, meski pengujian yang ditulis keliru akhirnya terungkap

Jebakan penangkapan variabel pada loop for lama

  • Variabel loop for pada Go lama memiliki cakupan seluruh loop, sehingga kode yang merujuk variabel itu setelah iterasi selesai bisa melihat nilai yang berbeda dari yang dimaksud
  • Jika menelusuri values := []string{"a", "b", "c"} sambil membuat tiga goroutine, setiap goroutine akan mencetak variabel v yang sama, bukan v per iterasi
  • Masalah yang sama juga bisa terjadi tanpa konkurensi
    • Jika func() { fmt.Println(i) } disimpan ke dalam slice di dalam iterasi lalu dijalankan nanti, setiap fungsi akan merujuk ke i yang sama, bukan nilai per iterasi

Gangguan produksi dan keterbatasan analyzer

  • Kesalahan seperti ini telah menyebabkan masalah produksi di sejumlah perusahaan, dan isu publik Let’s Encrypt adalah salah satu contohnya
  • Dalam kasus Let’s Encrypt, saat melakukan iterasi map, k memang disalin dengan kCopy := k, tetapi karena modelToAuthzPB(&v) memakai pointer ke field milik v saat membentuk hasil, v juga perlu disalin secara terpisah
    • Karena penangkapan variabel melibatkan beberapa fungsi, masalahnya sulit dikenali
  • Alat analisis statis sulit menentukan apakah variabel akan tetap hidup setelah iterasi, sehingga harus berkompromi antara false positive dan false negative
    • Analyzer loopclosure di go vet dan gopls hanya melaporkan masalah yang pasti, dengan konsekuensi menerima false negative
    • Pemeriksa yang lebih agresif bisa menandai kode yang benar sebagai kode yang salah
  • Jika melihat commit di kode Go open source yang menambahkan baris x := x, terlihat bahwa selain perbaikan bug yang nyata, banyak juga perubahan yang sebenarnya tidak perlu
    • Ada situasi ketika developer menambahkan kode yang tidak perlu hanya untuk memuaskan pemeriksa
    • Dari dua diff seperti informer := informer dan a := a, hanya satu yang benar-benar memperbaiki bug dan yang lain tidak perlu, tetapi tanpa informasi tipe dan fungsi, sulit membedakannya

Semantik loop baru di Go 1.22

  • Di Go 1.22, variabel loop for akan diubah agar memiliki cakupan terpisah pada setiap iterasi
  • Contoh-contoh sebelumnya tidak lagi menjadi program Go yang mengandung bug, dan masalah produksi serta kebutuhan akan alat pemeriksa yang kurang akurat juga akan berkurang
  • Demi kompatibilitas mundur, semantik baru ini hanya berlaku pada package dalam modul yang mendeklarasikan go 1.22 atau lebih tinggi di go.mod
    • Ini memungkinkan migrasi bertahap tanpa harus mengubah seluruh codebase sekaligus
    • Kontrol per file juga dimungkinkan melalui baris //go:build
  • Kode lama tetap mempertahankan makna seperti sekarang
    • Perubahan hanya berlaku pada kode baru atau kode yang telah diperbarui
    • Developer bisa mengontrol kapan semantik berubah pada package tertentu

Pengaman di versi Go sebelumnya

  • Sesuai pekerjaan forward compatibility di Go, Go 1.21 tidak akan mengompilasi kode yang mendeklarasikan go 1.22 atau lebih tinggi
  • Rilis point Go 1.20.8 dan Go 1.19.13 juga menyertakan penanganan khusus yang menghasilkan efek yang sama
  • Setelah Go 1.22 dirilis, kode yang ditulis dengan ketergantungan pada semantik baru tidak akan dikompilasi dengan semantik lama, kecuali jika menggunakan versi Go yang sudah sangat lama dan tidak didukung

Menjalankan pratinjau di Go 1.21

  • Go 1.21 menyertakan pratinjau perubahan cakupan loop
  • Jika dikompilasi dengan mengatur GOEXPERIMENT=loopvar, semua loop akan memakai semantik baru dengan mengabaikan baris go di go.mod
  • Untuk memeriksa apakah package dan seluruh dependensinya tetap lolos pengujian di bawah semantik loop baru, jalankan seperti berikut
GOEXPERIMENT=loopvar go test
  • Di Go Playground, Anda bisa mencoba semantik baru dengan menambahkan komentar // GOEXPERIMENT=loopvar di bagian paling atas program
  • Toolchain Go internal Google telah ditambal agar memaksa mode ini di semua build sejak awal Mei 2023, dan setelah itu selama 4 bulan tidak ada laporan masalah pada kode produksi

Bug pengujian yang terungkap oleh semantik baru

  • Semantik loop baru tidak menimbulkan masalah pada kode produksi, tetapi justru mengungkap pengujian yang selama ini lolos secara keliru
  • Dalam contoh subtest yang memakai t.Parallel, Go 1.21 menahan tiap subtest hingga seluruh loop selesai, lalu menjalankannya secara paralel
    • Setelah loop selesai, v selalu bernilai 6, sehingga semua subtest memeriksa apakah 6 adalah bilangan genap dan akhirnya lolos
    • Padahal test case sebenarnya berisi 1, jadi pengujian seharusnya gagal
  • Di Go 1.21, presisi analyzer loopclosure telah ditingkatkan sehingga bisa mengidentifikasi dan melaporkan masalah ini
    • Contoh laporan di Go Playground: contoh program
    • Jika go vet melaporkan masalah seperti ini dalam pengujian, memperbaikinya akan membantu persiapan menuju Go 1.22
  • Alat dan contoh untuk menemukan loop yang menyebabkan kegagalan pengujian tertentu setelah semantik baru diterapkan dirangkum di FAQ

Bacaan lanjutan

1 komentar

 
GN⁺ 2023-09-20
Pendapat Hacker News
  • Mungkin ada contoh yang jauh lebih awal, tetapi peringatan tertua tentang perilaku ini yang saya temukan lewat pencarian 60 detik adalah comp.lang.lisp FAQ yang diposting pada 1992, lebih dari 30 tahun lalu
    Di sana dijelaskan bahwa DOTIMES, DOLIST, dan DO menggunakan assignment, bukan binding, saat memperbarui variabel iterasi, sehingga jika lambda menangkap n seperti pada contoh, kesepuluh closure semuanya dibuat di atas nilai variabel N yang sama

    • D juga punya isu yang sama: https://issues.dlang.org/show_bug.cgi?id=2043
      Jika menangkap melalui referensi, sebenarnya itu perilaku yang memang diharapkan
    • Standar tidak menyebutkan apakah loop seperti ini mengubah nilai atau melakukan rebinding, jadi jika menangkap variabel, kita harus berasumsi bahwa ia tidak melakukan rebinding
      Meski begitu, setelah mempelajari cara kerjanya sekali, ini tidak lagi menjadi masalah, dan jika perlu kita bisa memilih form lalu melakukan macro expansion untuk memeriksa cara implementasinya
  • Tim bahasa C# juga mengalami masalah yang sama setelah memperkenalkan closure ringan di C# 4.0, dan segera terlihat bahwa ini adalah jebakan
    Pengguna hampir selalu menggunakan variabel loop dengan keliru, dan di C# 5.0 mereka memasukkan perubahan yang memutus kompatibilitas
    Eric Lippert menulis artikel yang menjelaskan dengan baik “mengapa” dari sudut pandang itu: https://ericlippert.com/2009/11/12/closing-over-the-loop-var...
    Tulisan pengumuman asli C# 5 sulit ditemukan; semoga tidak hilang selama berbagai migrasi blog di domain Microsoft setelah 2012

    • Python juga selama bertahun-tahun beberapa kali menerima permintaan fitur yang sama, tetapi jawabannya selalu “manfaat besarnya sedikit dan akan merusak kode yang sudah ada”: https://discuss.python.org/t/make-lambdas-proper-closures/10...
      Mengingat perubahan tipe string saja saat berpindah dari Python 2 ke 3 sudah menimbulkan kekacauan, rasanya perubahan ini tidak akan masuk sebelum Python 4.0
      Lalu mungkin akan ada orang yang mengatakan Python buruk karena tidak memperbaiki hal seperti ini, lalu kembali mencaci Python karena skrip yang dibuat pada 2003 tidak berjalan
    • jaredpar dari tim C# meninggalkan komentar pertama di diskusi GitHub untuk proposal Go ini: https://github.com/golang/go/discussions/56010
      Menurut saya ia berperan besar dalam melewati hambatan “tolak dulu” yang pada dasarnya harus dimiliki proposal perubahan bahasa
      Hal lain yang sangat meyakinkan adalah hasil pemindaian codebase open source untuk melihat keseimbangan antara bug yang diperbaiki dan bug baru yang muncul
    • Java juga punya masalah ini pada anonymous class, dan biasanya diselesaikan dengan memperkenalkan function object
      Karena pass-by-value, ini menangkap keadaan variabel pada saat pemanggilan dan mengurangi ambiguitas kode
      Jika mencoba menangkap variabel dengan cara yang aneh, misalnya koleksi yang diakumulasi untuk mengubah array menjadi map dan variabel yang dideklarasikan akan berperilaku berbeda satu sama lain
      Go tampaknya mencoba menyeimbangkan ini dengan menerapkan perilaku semacam itu hanya pada counter loop, tetapi tetap saja sebagian variabel akan berperilaku aneh
      Saya terutama penasaran apa yang akan terjadi jika mendefinisikan beberapa variabel loop untuk memindai input secara langsung
    • JavaScript juga punya masalah yang sama dan memperkenalkan loop for(let)
    • Dengan gaya khas Go, alurnya adalah tidak belajar dari bahasa-bahasa sebelumnya dan mengabaikan perilaku ini, lalu belakangan mencoba memperbaikinya lagi
  • https://eli.thegreenplace.net/2019/go-internals-capturing-lo... tampaknya menjelaskan masalah ini dengan lebih detail

    • Menarik bahwa alasan trik lama i := i bekerja ternyata sama sekali berbeda dari yang saya kira
      Awalnya saya mengira karena i baru diteruskan ke goroutine, escape analysis menandainya sebagai keluar dari lexical scope, sehingga dialokasikan di heap, dan setiap iterasi menghasilkan satu alokasi heap sehingga tiap goroutine merujuk ke lokasi memori yang unik
      Kenyataannya, compiler Go punya heuristik untuk memilih capture by reference atau capture by value, dan ada kondisi bahwa nilai yang tidak diperbarui setelah inisialisasi akan ditangkap sebagai nilai
      i yang baru berada dalam scope body for dan tidak diperbarui oleh loop itu sendiri, sehingga dinilai sebagai nilai yang tidak diperbarui setelah inisialisasi, lalu kode yang dihasilkan menangkapnya sebagai nilai tanpa alokasi heap
      Saya paham bahwa yang terakhir lebih baik, tetapi saya ingin mendengar dari orang yang sangat memahami Go mengapa cara yang pertama tidak ikut terjadi
  • Apakah perubahan ini tidak akan merusak program yang bergantung pada perilaku saat ini?

    • Untuk menjamin kompatibilitas mundur dengan kode yang sudah ada, semantik baru hanya diterapkan pada paket di dalam modul yang mendeklarasikan go 1.22 atau lebih baru di go.mod
      Pada tingkat berkas, keputusan juga bisa dibuat menggunakan baris //go:build
    • Saya tidak tahu kenapa ini di-downvote, tetapi sebenarnya ini memang perubahan yang melanggar janji kompatibilitas Go 1
      Janji itu mengatakan bahwa program yang ditulis berdasarkan spesifikasi Go 1 harus terus dikompilasi dan berjalan dengan benar tanpa perubahan selama masa hidup spesifikasi tersebut; suatu saat mungkin ada spesifikasi Go 2, tetapi sampai saat itu program Go yang berjalan hari ini harus tetap berjalan di rilis poin seperti Go 1.1 dan Go 1.2
    • Dalam persiapan Go 1.21, mereka menganalisis korpus kode Go yang sangat besar untuk melihat apa saja yang akan terdampak, dan katanya jumlahnya sangat, sangat kecil
      Mereka berpikir jumlah orang yang tanpa sengaja membuat bug karena desain ini jauh lebih banyak daripada jumlah orang yang terdampak oleh perbaikannya
    • Proposal aslinya membahas cukup rinci hasil penyelidikan terhadap kasus penggunaan lama dari sintaks ini
      Seingat saya, dikatakan bahwa di basis kode Google maupun kode GitHub, hampir tidak ada kasus di mana perubahan ini merusak perilaku yang diharapkan
      Keputusan untuk melanggar kompatibilitas mundur baru diambil setelah memastikan betapa sedikitnya basis kode yang terdampak, lalu membuat mekanisme yang mengharuskan kode diperbarui secara aktif lewat penentuan versi di go.mod agar memakai perilaku baru
    • Cukup banyak
      https://twitter.com/go100and1/status/1690412229135601664
      https://twitter.com/go100and1/status/1690587305806057472
      https://twitter.com/go100and1/status/1690589791686119424
      https://twitter.com/go100and1/status/1690591234715492352
      https://twitter.com/go100and1/status/1690593184857145344
      https://twitter.com/go100and1/status/1691456732151889920
      Sebagian besar sama sekali tidak disebutkan dalam dokumen proposal
  • Python juga pernah mengalami masalah ini, meski bukan baru-baru ini
    Saya tidak yakin apakah Python yang berubah, atau saya yang mulai menyadari masalahnya
    Bahwa ini masih bisa menjadi masalah di Python sudah cukup terlihat dari kode ini saja: funcs = [(lambda: x) for x in range(3)]; funcs[0]() mencetak 2

    • Itu perilaku yang benar
      Python dulu lebih buruk lagi, karena cakupannya bahkan dibagikan sampai ke luar list comprehension
    • Perilaku ini disebabkan oleh late binding pada closure Python
      Saat memakai lambda di dalam list comprehension atau loop, yang ditangkap bukan nilai x saat ini, melainkan referensi ke variabel x
      Saat funcs[0]() dipanggil, x sudah diatur ke nilai terakhir dari range, yaitu 2
      Untuk mendapatkan perilaku yang diinginkan, teruskan x sebagai argumen default lambda: funcs = [(lambda x=x: x) for x in range(3)]
  • Saya hanya sedikit memakai Go dan paham masalah umum yang diselesaikan perubahan ini, tetapi saya kurang memahami contoh yang lebih halus seperti kasus letsencrypt atau "range c.informerMap" versus "range alarms"
    Dalam for k, v := range someMap, apakah v adalah tipe nilai map, dan ada satu binding untuk seluruh loop yang disalin pada setiap iterasi? Kalau begitu masalahnya bisa dijelaskan, tetapi saya mengira v adalah referensi yang menunjuk ke bagian dalam map
    Saya sempat membaca sekilas “For statements with range clause” di spesifikasi dan tidak menemukan jawabannya; mungkin karena saya hampir tidak pernah menyentuh Go, saya melihat bagian yang salah: https://go.dev/ref/spec#For_statements
    Sunting: ternyata jawabannya ada di tabel berformat blok kode. Sepertinya saya melewatkannya seperti banner. Mengejutkan bahwa v adalah nilai yang disalin, bukan referensi

  • Saya penasaran bagaimana bagian “sebagai hasil dari pekerjaan kompatibilitas ke depan, Go 1.21 tidak akan mencoba mengompilasi kode yang mendeklarasikan go 1.22 atau lebih tinggi. Kami juga memasukkan penanganan khusus dengan efek yang sama ke rilis poin Go 1.20.8 dan Go 1.19.13, sehingga setelah Go 1.22 dirilis, kode yang ditulis dengan bergantung pada semantik baru tidak akan pernah dikompilasi dengan semantik lama, kecuali menggunakan versi Go yang sangat tua dan tidak lagi didukung” ini bekerja
    Kalau suatu paket dipatok ke 1.22 dan saya mengompilasinya dengan 1.18, apakah akan berhasil dikompilasi atau muncul error bahwa compiler 1.22 diperlukan?

    • Mereka memakai cara yang agak licik
      Karena di Go 1.21 mereka mengubah format nomor versi di berkas go.mod, jika mencoba build dengan Go 1.18 akan muncul error seperti go.mod:3: invalid go version '1.21.0': must match format 1.23
      Namun ini hanya terjadi kalau modul dibuat dengan go mod init; kalau menulis go 1.21 secara manual di go.mod, build berjalan tanpa protes
    • Menariknya, di Go 1.21, jika modul mendeklarasikan versi Go yang lebih tinggi, perilaku defaultnya adalah mengambil toolchain yang lebih baru dan memakainya sebagai pengganti: https://go.dev/blog/toolchain
      Ini fitur yang cukup keren, tetapi perilakunya mengejutkan, dan saya agak ragu karena ia terhubung ke server yang dikendalikan Google untuk mengambil binary
      Bersama proxy modul, ini salah satu fitur Go yang paling membuat saya ambivalen; rasanya saya akan jauh lebih tenang kalau Go dikelola oleh yayasan yang hanya dimiliki sebagian oleh Google
      Edit: setelah dipikir-pikir, ini soal ketika modul saat ini yang mendeklarasikannya, bukan ketika dependensi mendeklarasikan versi lain, jadi berbeda dari pertanyaan aslinya
    • Sejauh pemahaman saya, di Go 1.18 modul 1.22 tetap akan dikompilasi meski masuk sebagai dependensi, dan jika bergantung pada fitur ini bisa menghasilkan logika yang salah
      Jadi penggunaan Go 1.18 menjadi berbahaya secara aktif
      Di Go 1.19 kemungkinan akan muncul error compiler
      Bagaimanapun, Go tidak memberi perbaikan bug keamanan untuk rilis lama dan pustaka standarnya, jadi menurut saya memakai versi seperti itu sendiri sudah berbahaya
    • Seharusnya muncul error kompilasi
      Namun meskipun dikompilasi dengan Go 1.22, kode Anda tetap memiliki semantik Go 1.18
  • Go dalam beberapa hal adalah bahasa yang sangat aneh
    Ia bahasa yang sangat berpendirian kuat, tetapi pada saat yang sama tampak seperti bahasa yang terlalu tidak punya pendirian

  • Saya tidak sepenuhnya yakin apa perbedaan antara kode yang mengiterasi c.informerMap dan kode yang mengiterasi alarms, tetapi kalau menebak, variabel loop di satu sisi mungkin pointer dan di sisi lain nilai
    Karena pemanggilan metodenya memakai pointer receiver, mungkinkah ketika berupa nilai compiler otomatis memasukkan referensi ke receiver?

    • Saya menemukan sumber asli yang berisi potongan kode ini lewat pencarian kode GitHub
      https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
      https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
      Perbedaannya adalah pada yang satu, informer adalah interface, sehingga pemanggilan metode langsung ditafsirkan sebagai informer.Run dan tidak bermasalah
      Pada yang lain, a adalah struct Alarm dan disalin sebagai nilai, sementara metode Monitor menerima pointer receiver
      Jadi compiler pada dasarnya mengubah go a.Monitor(b) menjadi go (&a).Monitor(b), dan ini membuat referensi ke variabel loop sehingga menimbulkan masalah
    • Di Go, saat mengiterasi map, nilainya selalu disalin, jadi kode pertama tampaknya bekerja sesuai harapan
      Saya menduga pada yang kedua, karena a pada akhirnya hanya memegang nilai elemen terakhir dari alarms, masalah asli yang dijelaskan di tulisan itu terjadi
    • Dari namanya saja, yang atas adalah map dan yang bawah adalah slice
      Pengetahuan internal saya hanya sampai di situ, tetapi slice memiliki backing array di heap, sehingga pointer atau referensi sampai tingkat tertentu saling terkait
    • Sepertinya jelas ada sesuatu seperti compiler tahu cara menangkap nilainya
  • Membaca ini membuat saya sangat lega
    Salah satu cacat terbesar di Go sedang diperbaiki

    • Bukan, cacat terbesar adalah penanganan error
      Jika menulis seperti foo, err := getFoo(); if err != nil ... lalu bar, err := getBar(); fmt.Println(bar), pemeriksaan error dari getBar bisa terlewat
      Karena aturan scope, pola if foo, err := getFoo(); err != nil menjadi sulit ditangani begitu nesting sedikit saja bertambah dalam
      Ini juga memperkenalkan keadaan yang tidak valid. Saat getFoo mengembalikan error, apa yang harus dikembalikan? Kita jadi harus mempertimbangkan apakah mengubah API agar mengembalikan pointer dan mengembalikan nil, atau menaruh objek yang sebagian dibuat dalam keadaan tidak valid
    • Berikutnya tinggal memperbaiki pemeriksaan nil pada interface