Go dilinde bir dilime ekleme yaptığınızda, orijinal dilimin kapasitesi yeni öğeleri karşılayacak kadar yeterliyse, sonuç orijinal dilimle aynı temel diziyi paylaşabilir. Bu durum, append'in geri döndürdüğü dilim başlığının (işaretçi, uzunluk, kapasite) aynı destekleyici diziye işaret edebilmesinden kaynaklanır. Orijinal dilimin uzunluğu kapasitesinden küçükse ve o kapasite içinde yeniden dilimleme veya ekleme yaparsanız, yeni dilimdeki öğelerin değişiklikleri, aynı bellek adreslerini referans aldıkları için orijinal dilimde görünür hale gelir.
buffer := make([]int, 3, 5) // [0 0 0], len=3, cap=5 buffer[0] = 10 newSlice := append(buffer, 42) // Hala destekleyici diziyi paylaşıyor newSlice[0] = 99 // buffer[0] artık 99, 10 değil
Bu benzeşim durumu, Go'nun dilim uygulamasının, bellek verimliliğini optimize etmek amacıyla bir işaretçi başlığı ile kesintisiz bir dizi kullanmasından kaynaklanır ve geliştiricilerin değer anlamını varsayarken potansiyel yan etkiler doğurur.
Bir yüksek frekanslı ticaret platformunun son beş işlenmemiş emri bir döngüsel tampon dilimden çıkardığını hayal edin ve ardından nihai bir gönderim grubu hazırlamak için yeni bir sentetik emri ekler. Geliştirici, yeni grubun bağımsız olduğunu varsayıyor, ancak gönderim grubundaki sentetik emrin fiyat alanını değiştirirken, döngüsel tampondaki ilgili emir gizemli bir şekilde güncelleniyor ve bu, yanlış alarm veren kopya emir tespiti mantığını tetikleyerek geçerli ticaretleri reddediyor.
Verileri izole etmek için birkaç çözüm düşünüldü. İlk yaklaşım, eklemeden önce verilerin savunmacı bir kopyasını oluşturmak için copy kullanmayı gerektiriyordu; bu, destekleyici diziden bağımsızlık garanti eder ancak işlem başına binlerce batch işlenirken O(n) bellek tahsisi ve kopya maliyeti doğuruyor. İkinci yaklaşım, her zaman tam uzunlukta sıfır ve gerekli boyuta eşit kapasiteyle yeni bir dilim tahsis etmeyi öneriyordu; bu, benzeşimi önlüyor ama dikkatli bir kapasite yönetimi gerektiriyor ve batch boyutları öngörülemez bir şekilde değişirse bellek israfına neden oluyordu. Üçüncü yaklaşım, Go'nun dilim semantiğinden bağımsız kesintisiz yerleştirme sağlamak için özel bir arena tahsis edici kullanıyordu; ancak bu, güvenli işaretçi işlemleri içeriyordu ve projenin güvenlik gereksinimlerini ihlal ettiğinden üretim finans kodları için uygun değildi.
Ekip, tahsisat yükünü hafifletmek için destekleyici diziler için bir sync.Pool uygularken, kritik gönderim grupları için copy kullanan ilk çözümü seçti. Bu yaklaşım, tür güvenliğinden ödün vermeden veri izolasyonu sağladı.
Yayımlandıktan sonra, yanlış alarm oranı sıfıra düştü ve CPU profilleme, tahsisat verimliliğinde yalnızca %3'lük bir artış gösterdi; bu, elde edilen doğruluk garantileri göz önüne alındığında kabul edilebilir bir durumdu.
append'in bağımsız bir kopya döndürebilmesi için len(slice) == cap(slice) kontrolü neden garanti etmez?
Uzunluk kapasiteye eşit olduğunda bile, append mevcut destekleyici dizi doluysa yeniden tahsis yapabilir, ancak kritik yanlış anlama, bağımsızlığın yalnızca bu durumun kontrol edilmesiyle sağlandığını varsayıyor olmalarıdır. Adaylar, dilimlerin başka dilimlerden yeniden dilimleme yoluyla (örn. s[:0]) türetilmesinin, açık bir şekilde sınırlandırılmadıkça orijinal kapasiteyi koruduğunu gözden kaçırır. Çalışma zamanı, ekleme mevcut kapasiteyi aştığında yalnızca yeni bellek tahsis eder, ancak "mevcut kapasite", dilim başlığının hala referans aldığı orijinal destekleyici dizideki herhangi bir kullanılmayan yeri de içerir. Bağımsızlık sağlamak için, ya tam kapasiteye sahip yeni bir dilime copy yapılmalı ya da eklemeden önce kapasiteyi sınırlamak için üç indeksli dilimleme s[low:high:max] kullanılmalıdır.
Üç indeksli dilimleme, ekleme benzeşimini nasıl önler ve performans etkileri nelerdir?
Üç indeksli dilimleme s[i:j:k], sonuç olarak elde edilen dilimin hem uzunluğunu (j-i) hem de kapasitesini (k-i) ayarlar, temel dizinin görünür kısmını etkili bir şekilde sınırlar. Bu sınırlı dilime daha sonra ekleme yaptığınızda, herhangi bir büyüme hemen yeniden tahsis gerektirir çünkü kapasite k-1 dizininden ötesinde veri yazmayı önler. Bu teknik, dilimleme işlemi sırasında bellek tahsisi yapılmasını önler—copy'dan farklı olarak—ancak adaylar, bir ekleme gerçekleşene kadar hâlâ aynı destekleyici diziyi referans aldığını fark etmezler. Orijinal dilim büyük ve alt küme küçükse, bu yaklaşım çoğaltmayı önleyerek bellek tasarrufu sağlar, ancak tüm destekleyici diziye olan referansları koruma riski taşır ve kullanılmayan öğelerin GC'sini geciktirir.
Bir dilimi bir fonksiyona geçirmenin ve o fonksiyonda ekleme yapmanın, altındaki diziyi değiştirmesine rağmen çağıranın orijinal dilim değişkeninde değişiklik yapmaması için hangi özel koşul geçerlidir?
Bu durum, Go'nun dilimleri değer olarak geçirmesi nedeniyle ortaya çıkar; bu, dilim başlığının (işaretçi, uzunluk, kapasite) kopyalanmasını ancak destekleyici diziyi kopyalamaz. Fonksiyon ekleme yaptığında ve dilim başlığı güncellenirse (yeniden tahsis nedeniyle yeni işaretçi veya artan uzunluk), çağıranın başlığı değişmeden kalır. Adaylar, mevcut öğelere yapılan değişikliklerin paylaşılan belleği değiştirdiğini, ancak uzunluk ve işaretçi güncellemelerinin yalnızca fonksiyonun başlığının kopyasına ait olduğunu gözden kaçırır. Aşırı ekleme sonuçlarını geri iletmek için, yeni dilimi geri döndürmeli veya dilime işaretçi geçirmelisiniz (*[]T), bu, çağıranın sonucu yeniden atamasını zorlar: slice = append(slice, val) çalışır çünkü çağıran dönüş değerini yeniden atar, ancak func mutate(s []int) { s = append(s, 1) } yeniden tahsisi sessizce terk eder, ancak s döndürülmezse.