1 puan yazan GN⁺ 2023-09-20 | 1 yorum | WhatsApp'ta paylaş
  • Go 1.22, for döngüsü değişkenlerini tüm döngü için değil her yineleme için ayrı kapsam olacak şekilde değiştirerek, closure’ların aynı değişkeni yanlış yakalamasından doğan Go’nun tipik hatalarını azaltmayı hedefliyor
  • Mevcut anlambilimde, goroutine olmasa bile yinelemeden sonra çalışan bir fonksiyon aynı v ya da i değişkenine başvurabildiği için yalnızca son değeri görebilir veya testler yanlış şekilde başarılı olabilir
  • go vet ve gopls içindeki loopclosure çözümleyicileri yalnızca kesin durumları yakaladığından bazı hataları kaçırır; daha agresif denetleyiciler ise yanlış pozitifler nedeniyle gereksiz x := x kodlarını artırabilir
  • Yeni anlambilim yalnızca go.mod içinde go 1.22 veya üzerini bildiren modüllere uygulanacak; Go 1.21’de ise GOEXPERIMENT=loopvar ile önizleme olarak denenebilecek
  • Google, 2023 Mayıs’ının başından beri dahili Go araç zincirinde bu modu tüm derlemelerde zorunlu kıldı; 4 ay boyunca üretimde sorun raporlanmadı, ancak hatalı yazılmış testler ortaya çıktı

Mevcut for döngüsünde değişken yakalama tuzağı

  • Go’daki mevcut for döngüsü değişkenleri tüm döngü boyunca tek kapsam kullandığı için, yineleme bittikten sonra o değişkene başvuran kod beklenenden farklı bir değer görebilir
  • values := []string{"a", "b", "c"} üzerinde dolaşıp üç goroutine oluşturursanız, her goroutine yinelemeye özgü v yerine aynı v değişkenini yazdırır
  • Aynı sorun eşzamanlılık olmadan da ortaya çıkabilir
    • Döngü içinde func() { fmt.Println(i) } fonksiyonunu bir slice içine kaydedip daha sonra çalıştırırsanız, her fonksiyon yinelemeye özgü değer yerine aynı i değişkenine başvurur

Üretim kesintileri ve çözümleyicilerin sınırları

  • Bu tür hatalar birçok şirkette üretim sorunlarına yol açtı; Let’s Encrypt’in herkese açık sorunu da bunlardan biri
  • Let’s Encrypt örneğinde, map üzerinde dolaşırken k, kCopy := k ile kopyalanmıştı; ancak modelToAuthzPB(&v) sonuç oluştururken v alanlarının pointer’larını kullandığı için v’nin de ayrıca kopyalanması gerekiyordu
    • Değişken yakalama birkaç fonksiyona yayıldığından sorunu fark etmek zordu
  • Statik analiz araçlarının, bir değişkenin yineleme sonrasına taşıp taşınmadığını belirlemesi zor olduğundan yanlış pozitifler ile kaçırılan hatalar arasında ödün vermesi gerekir
    • go vet ve gopls içindeki loopclosure çözümleyicileri yalnızca kesin sorunları raporladığı için bazı hataları kaçırmayı göze alır
    • Daha agresif denetleyiciler doğru kodu bile hatalı diye işaretleyebilir
  • Açık kaynak Go kodlarında x := x satırı ekleyen commit’lere bakıldığında, gerçek hata düzeltmelerinin yanında çok sayıda gereksiz değişiklik de görülüyordu
    • Geliştiriciler, denetleyiciyi memnun etmek için gereksiz kod eklemek zorunda kalabiliyordu
    • informer := informer ve a := a gibi iki diff’ten biri gerçek bir hata düzeltmesi, diğeri ise gereksiz değişiklikti; ama tür ve fonksiyon bilgisi olmadan bunları ayırt etmek zordu

Go 1.22’nin yeni döngü anlambilimi

  • Go 1.22’de for döngüsü değişkenlerinin her yineleme için ayrı kapsamı olacak şekilde değişmesi planlanıyor
  • Yukarıdaki örnekler artık hatalı Go programları sayılmayacak; böylece bu tür hatalardan doğan üretim sorunları ve isabetsiz denetim araçlarına duyulan ihtiyaç da azalacak
  • Geriye dönük uyumluluk için yeni anlambilim yalnızca go.mod içinde go 1.22 veya üzerini bildiren modüllerdeki paketlere uygulanacak
    • Tüm kod tabanını tek seferde değiştirmeden kademeli geçiş yapılabilecek
    • Dosya bazında denetim için //go:build satırı da kullanılabilecek
  • Mevcut kod, bugünkü anlambilimini aynen koruyacak
    • Değişiklik yalnızca yeni ya da güncellenmiş kodlara uygulanacak
    • Geliştirici, anlambilimin belirli bir pakette ne zaman değişeceğini kontrol edebilecek

Önceki Go sürümlerindeki güvenlik önlemleri

  • Go’nun forward compatibility çalışması doğrultusunda Go 1.21, go 1.22 veya üzerini bildiren kodu derlemeyecek
  • Aynı etkiyi sağlayan özel işlem, Go 1.20.8 ve Go 1.19.13 nokta sürümlerine de eklendi
  • Go 1.22 yayımlandıktan sonra, yeni anlambilime dayanarak yazılan kod; çok eski destek süresi bitmiş Go sürümleri kullanılmadıkça eski anlambilimle derlenemeyecek

Go 1.21’de önizleme olarak denemek

  • Go 1.21, döngü kapsamı değişikliğinin önizleme sürümünü içeriyor
  • GOEXPERIMENT=loopvar ayarıyla derleme yapıldığında go.mod içindeki go satırı yok sayılıyor ve tüm döngülere yeni anlambilim uygulanıyor
  • Bir paketin ve tüm bağımlılıklarının yeni döngü anlambilimi altında testleri geçip geçmediğini kontrol etmek için şu şekilde çalıştırılabilir:
GOEXPERIMENT=loopvar go test
  • Go Playground’da programın en üstüne // GOEXPERIMENT=loopvar yorumu eklenerek yeni anlambilim denenebilir
  • Google’ın dahili Go araç zinciri, 2023 Mayıs başından itibaren tüm derlemelerde bu modu zorunlu kılacak şekilde yamanmıştı ve sonraki 4 ay boyunca üretim kodunda bir sorun raporlanmadı

Yeni anlambilimin ortaya çıkardığı test hataları

  • Yeni döngü anlambilimi üretim kodunda sorun yaratmadı, ancak yanlış şekilde geçmekte olan testleri ortaya çıkardı
  • t.Parallel kullanan alt test örneğinde Go 1.21, tüm döngü bitene kadar her alt testi bekletip ardından paralel çalıştırıyor
    • Döngü bittiğinde v her zaman 6 olduğundan tüm alt testler 6’nın çift olduğunu kontrol edip başarılı oluyor
    • Oysa gerçek test vakaları arasında 1 de bulunduğu için testin başarısız olması gerekir
  • Go 1.21’de loopclosure çözümleyicisinin hassasiyeti artırıldı; bu sayede bu sorun belirlenip raporlanabiliyor
    • Go Playground rapor örneği: program örneği
    • go vet testlerde bu tür sorunları raporluyorsa, bunları düzeltmek Go 1.22’ye hazırlık açısından faydalı olur
  • Yeni anlambilim uygulandığında belirli test başarısızlıklarına neden olan döngüleri bulmak için araçlar ve örnekler SSS içinde özetlenmiştir

Daha fazla okuma

1 yorum

 
GN⁺ 2023-09-20
Hacker News yorumları
  • Çok daha eski örnekler de olabilir ama 60 saniyelik bir aramayla bu davranış hakkında bulduğum en eski uyarı, 30 yılı aşkın süre önce, 1992’de yayımlanan comp.lang.lisp FAQ idi.
    DOTIMES, DOLIST, DO yineleme değişkenini güncellerken bağlama değil atama kullandığından, örnekte olduğu gibi lambda n’yi yakalarsa 10 closure’ın tamamının aynı N değişkeninin değeri üzerine oluşturulduğu açıklanıyor.

    • D’de de aynı sorun var: https://issues.dlang.org/show_bug.cgi?id=2043
      Referansla yakalıyorsanız aslında beklenen davranış bu.
    • Standartta bu tür döngülerin değeri değiştirip değiştirmediği ya da yeniden bağlayıp bağlamadığı belirtilmediği için, bir değişkeni yakalıyorsanız yeniden bağlama yapmadığını varsaymak gerekir.
      Yine de çalışma biçimini bir kez öğrenince sorun olmaktan çıkıyor; gerekirse formu seçip makro açılımına bakarak nasıl uygulandığını kontrol edebilirsiniz.
  • C# dil ekibi de C# 4.0’da hafif closure’ları getirdikten sonra aynı sorunla karşılaştı ve bunun kısa sürede bir tuzak olduğu ortaya çıktı.
    Kullanıcılar neredeyse her zaman döngü değişkenini yanlış kullanıyordu ve C# 5.0’da geriye dönük uyumluluğu bozan bir değişiklik yapıldı.
    Eric Lippert bu açıdan “neden”i iyi açıklayan bir yazı yazmıştı: https://ericlippert.com/2009/11/12/closing-over-the-loop-var...
    Asıl C# 5 duyuru yazısını bulmak zordu; umarım 2012’den sonra Microsoft alan adındaki çeşitli blog taşımaları sırasında kaybolmamıştır.

    • Python da yıllar boyunca aynı özellik isteğini defalarca aldı, ancak yanıt hep “büyük kazanım az, mevcut kodu bozar” oldu: https://discuss.python.org/t/make-lambdas-proper-closures/10...
      Python 2’den 3’e geçerken yalnızca string türü değişikliğinin bile nasıl bir fırtına kopardığını düşünürsek, bu değişikliğin Python 4.0’dan önce geleceğini sanmıyorum.
      Üstelik birileri Python’ın böyle şeyleri düzeltmediği için kötü olduğunu söyleyip, sonra da 2003’te yazdığı script çalışmıyor diye yine Python’a sövecektir.
    • C# ekibinden jaredpar, bu Go önerisinin GitHub tartışmasına ilk yorumu yaptı: https://github.com/golang/go/discussions/56010
      Dil değişikliği önerilerinin varsayılan olarak sahip olması gereken “önce reddet” eşiğini aşmasında büyük rol oynadığını düşünüyorum.
      Beni ayrıca ciddi biçimde ikna eden bir başka nokta da açık kaynak kod tabanlarını tarayıp düzeltilen hatalarla yeni oluşan hatalar arasındaki dengeye bakmalarıydı.
    • Java da anonim sınıflarda bu sorunu yaşadı ve genelde fonksiyon nesneleri getirerek çözdü.
      Değerle aktarım olduğu için çağrı anındaki değişken durumunu yakalar ve koddaki belirsizliği azaltır.
      Değişkenleri garip biçimde yakalamaya çalışırsanız, örneğin bir diziyi map’e dönüştürmek için biriktirdiğiniz koleksiyon ile tanımlanmış değişkenler birbirinden farklı davranmaya başlar.
      Go, bu davranışı yalnızca döngü sayacına uygulayarak denge kurmaya çalışıyor gibi görünüyor ama yine de bazı değişkenler garip davranıyor.
      Özellikle girdiyi doğrudan taramak için birden fazla döngü değişkeni tanımlanan durumlarda ne olacağını merak ediyorum.
    • JavaScript de aynı sorunu yaşadı ve for(let) döngüsünü getirdi.
    • Go’ya yakışır biçimde, önceki dillerden ders almayıp bu davranışı görmezden gelmiş, sonra da dönüp düzeltmeye çalışmış gibi bir akış.
  • https://eli.thegreenplace.net/2019/go-internals-capturing-lo... bu sorunu daha ayrıntılı açıklıyor gibi görünüyor.

    • Eski i := i numarasının düşündüğümden tamamen farklı bir nedenle çalışması ilginç.
      Başta yeni i goroutine’e geçirildiği için kaçış analizinin bunu leksik kapsamın dışına kaçıyor diye işaretlediğini, bu yüzden heap’te ayrıldığını ve her yinelemede bir heap ayırması oluşarak her goroutine’in kendine özgü bir bellek konumuna başvurduğunu sanmıştım.
      Gerçekte ise Go derleyicisinin referansla yakalama ile değerle yakalama arasında seçim yapan bir sezgisel yöntemi var ve başlatmadan sonra güncellenmeyen değerleri değer olarak yakalama koşulu bulunuyor.
      Yeni i, for gövdesinin kapsamında ve döngünün kendisi tarafından güncellenmediği için başlatmadan sonra güncellenmeyen bir değer olarak değerlendiriliyor; böylece heap ayırması olmadan değerle yakalayan kod üretiliyor.
      İkincisinin daha iyi olduğunu anlıyorum ama ilk yöntemin neden bununla birlikte gerçekleşmediğini Go’yu derinlemesine bilen birinden duymak isterim.
  • Bu değişiklik, mevcut davranışa dayanan programları bozmaz mı?

    • Mevcut kodla geriye dönük uyumluluğu garanti etmek için yeni semantik yalnızca go.mod içinde go 1.22 veya daha üstünü bildiren modüllerdeki paketlere uygulanır
      Dosya bazında ise //go:build satırı kullanılarak da belirlenebilir
    • Neden eksi oy aldığını bilmiyorum ama aslında bu, Go 1 uyumluluk vaadini bozan bir değişiklik
      Bu vaat, Go 1 spesifikasyonuyla yazılmış programların spesifikasyonun ömrü boyunca değişiklik gerektirmeden derlenmeye ve doğru çalışmaya devam etmesi gerektiğini; bir gün Go 2 spesifikasyonu çıkabilecek olsa da o zamana kadar Go 1.1, Go 1.2 gibi nokta sürümlerde de bugün çalışan Go programlarının çalışmaya devam etmesi gerektiğini söyler
    • Go 1.21 hazırlık sürecinde çok büyük bir Go kod külliyatı analiz edilip nelerin etkileneceğine bakıldı ve sayının çok çok küçük olduğu söylendi
      Bu tasarım yüzünden istemeden hata üreten kişi sayısının, düzeltmeden etkilenen kişi sayısından çok daha fazla olacağını düşündüler
    • Özgün öneri, bu söz diziminin mevcut kullanım örneklerini araştıran kısmı oldukça ayrıntılı ele alıyordu
      Hatırladığım kadarıyla Google kod tabanında veya GitHub kodlarında bu değişikliğin beklenen davranışı bozduğu durumlar neredeyse hiç yoktu
      Etkilenen kod tabanlarının ne kadar az olduğunu doğrulayıp, yeni davranışı kullanmak için go.mod içindeki sürüm belirtimiyle kodun aktif olarak değiştirilmesini gerektiren bir mekanizma oluşturduktan sonra geriye dönük uyumluluğu bozmaya karar verdiler
    • Epey fazla var
      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
      Çoğundan öneri belgesinde hiç bahsedilmemişti
  • Python’da da bu sorunu yaşamıştım ama yakın zamanda değil
    Python mı değişti, yoksa ben mi sorunu fark etmeye başladım, emin değilim
    Python’da hâlâ sorun olabileceğini yalnızca şu kod bile yeterince gösteriyor: funcs = [(lambda: x) for x in range(3)]; funcs[0]() çıktısı 2 olur

    • Doğru davranış bu
      Python eskiden daha kötüydü; liste üreteçlerinin dışındaki kapsamı bile paylaşıyordu
    • Bu davranış Python closure’larının geç bağlama özelliğinden kaynaklanır
      Liste üretecinde veya döngü içinde lambda kullandığınızda x’in o anki değerini değil, x değişkenine olan referansı yakalar
      funcs[0]() çağrıldığında x zaten range’in son değeri olan 2 olarak ayarlanmıştır
      İstediğiniz davranışı elde etmek için x’i lambda’nın varsayılan argümanı olarak geçirebilirsiniz: funcs = [(lambda x=x: x) for x in range(3)]
  • Go’yu sadece biraz kullandım ve bu değişikliğin çözdüğü genel problemi biliyorum, ama letsencrypt örneği ya da "range c.informerMap" ile "range alarms" arasındaki daha incelikli örneği pek anlayamıyorum
    for k, v := range someMap içinde v map değer türü mü ve tüm döngü boyunca tek bir bağlama olup her yinelemede kopyalanıyor mu? Öyleyse sorun açıklanıyor, ama v’nin map’in içini gösteren bir referans olmasını beklerdim
    Spesifikasyondaki “For statements with range clause” bölümüne hızlıca göz attığımda yanıtı bulamadım; Go’ya pek dokunmadığım için muhtemelen yanlış yere baktım: https://go.dev/ref/spec#For_statements
    Düzenleme: Yanıt kod bloğu biçimindeki tablodaymış. Banner gibi görüp geçmişim sanırım. v’nin referans değil, kopyalanmış değer olması şaşırtıcı

  • “İleriye dönük uyumluluk çalışmalarının sonucu olarak Go 1.21, go 1.22 veya üzerini bildiren kodu derlemeye çalışmaz. Go 1.20.8 ve Go 1.19.13 nokta sürümlerine de aynı etkiye sahip özel bir işlem koyduk; dolayısıyla Go 1.22 yayımlandığında, yeni semantiğe dayanarak yazılmış kod, çok eski ve desteklenmeyen bir Go sürümü kullanılmadığı sürece asla eski semantiklerle derlenmeyecek” kısmının nasıl çalıştığını merak ediyorum
    Bir paket 1.22’ye sabitlenmişse ve ben 1.18 ile derlersem, derlenir mi yoksa 1.22 derleyicisi gerektiğine dair hata mı verir?

    • Biraz kurnazca bir yöntem kullanmışlar
      Go 1.21’de go.mod dosyasının sürüm numarası biçimini değiştirdikleri için Go 1.18 ile build etmeye çalışırsan go.mod:3: invalid go version '1.21.0': must match format 1.23 gibi bir hata çıkıyor
      Ancak bu yalnızca go mod init ile modül oluşturulduğunda böyle; go.mod içine elle go 1.21 yazarsan itiraz etmeden build ediyor
    • İlginç şekilde Go 1.21’de bir modül daha yüksek bir Go sürümü bildirirse, varsayılan davranış daha yeni toolchain’i indirip onun yerine kullanmak: https://go.dev/blog/toolchain
      Oldukça hoş bir özellik ama şaşırtıcı bir davranış ve ikili dosya almak için Google’ın kontrol ettiği bir sunucuya bağlanması nedeniyle biraz tereddüt ettiriyor
      Modül proxy’siyle birlikte Go’daki en ikircikli hissettiren özelliklerden biri; Go, Google’ın yalnızca pay sahibi olduğu bir vakıf tarafından yönetilseydi çok daha rahat hissederdim
      Düzenleme: Düşününce bu, bir bağımlılık başka bir sürüm bildirdiğinde değil, mevcut modül bildirdiğinde geçerli; bu yüzden asıl sorudan farklı
    • Benim anladığım kadarıyla Go 1.18’de 1.22 modülü bağımlılık olarak gelse de derlenir ve bu özelliğe dayanıyorsa hatalı mantık üretebilir
      Bu yüzden Go 1.18 kullanmak aktif olarak tehlikeli hale geliyor
      Go 1.19’da derleyici hatası vermesi gerekir
      Zaten Go eski sürümlere ve standart kütüphaneye güvenlik hatası düzeltmeleri yapmadığı için, böyle sürümleri kullanmanın başlı başına riskli olduğunu düşünüyorum
    • Derleme hatası vermesi gerekir
      Ama Go 1.22 ile derlesen bile kodun hâlâ Go 1.18 semantiklerine sahip olur
  • Go bazı açılardan gerçekten tuhaf bir dil
    Çok keskin görüşleri olan bir dil olmasına rağmen aynı zamanda fazla görüşsüz bir dil gibi görünüyor

  • c.informerMap üzerinde dönen kodla alarms üzerinde dönen kod arasındaki farkın ne olduğundan emin değilim ama tahminime göre bir taraftaki döngü değişkeni pointer, diğer taraftaki değer olabilir
    Metot çağrısı pointer receiver kullandığı için, değer olduğunda derleyici receiver’a otomatik olarak referans ekliyor olabilir mi?

    • GitHub kod aramasıyla bu kod parçasını içeren kaynakları buldum
      https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
      https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
      Fark şu: birinde informer bir interface olduğu için metot çağrısı hemen informer.Run olarak çözümleniyor ve sorun olmuyor
      Diğerinde a, Alarm struct’ı ve değer olarak kopyalanıyor; Monitor metodu ise pointer receiver alıyor
      Bu yüzden derleyici fiilen go a.Monitor(b) ifadesini go (&a).Monitor(b) biçimine dönüştürüyor; bu da döngü değişkenine referans oluşturup soruna yol açıyor
    • Go’da map üzerinde dönerken değerler her zaman kopyalandığı için ilk kod beklendiği gibi çalışıyor gibi
      İkincisinde ise a sonunda alarms’ın son elemanının değerini taşıdığı için yazıda açıklanan asıl sorunun ortaya çıktığını tahmin ediyorum
    • İsimlere bakınca üstteki map, alttaki slice gibi görünüyor
      İç bilgim bu kadar, ama slice’ların heap’te backing array’i olduğu için pointer’lar veya referanslar bir ölçüde işin içine giriyor
    • Derleyicinin değeri yakaladığını bilmesiyle ilgili bir şey kesinlikle var gibi
  • Bunu okuyunca ciddi şekilde rahatladım
    Go’daki en büyük kusurlardan biri düzeltiliyor

    • Hayır, en büyük kusur hata işleme
      foo, err := getFoo(); if err != nil ... sonrasında bar, err := getBar(); fmt.Println(bar) gibi yazarsan getBar’ın hata kontrolünü kaçırırsın
      Kapsam kuralları yüzünden if foo, err := getFoo(); err != nil kalıbı, iç içe geçme biraz derinleştiğinde başa çıkılmaz hale geliyor
      Ayrıca geçersiz durumlar yaratıyor. getFoo hata döndürdüğünde ne döndürmeli? API’yi pointer döndürecek şekilde değiştirip nil mi döndürmeli, yoksa geçersiz durumda kısmen oluşturulmuş bir nesne mi bırakmalı diye düşünmek zorunda kalıyorsun
    • Sırada interface’lerde nil kontrolünü düzeltmek var