Go’da Map Yapısı Hakkında Her şey

Mehmet Can Tas
9 min readAug 17, 2023

Map yapısı Go programlama diline yeni başlayan ya da uzun süredir bu dilde proje geliştiren kişilerin en çok kullandığı yapılardan birisidir. Bu makalede Go programlama dilini öğrenmek isteyen arkadaşlar için öncelikle bu veri yapısı nedir ve projelerde hangi amaçla kullanılır sorularını cevaplandırıp sonrasında bu yapının arka planda nasıl çalıştığını elimden geldiğince aktarmaya çalışacağım.

Map Nedir?

Map içerisinde key-value ikilisini tutan varsayılan olarak herhangi bir sıralamanın olmadığı bir listeleme elemanıdır. Eğer C#, Java ya da benzeri bir dile aşinalığınız varsa Dictionary yapısının karşılığı olarak da düşünebilirsiniz.

Map yapısı özünde bir hashmap olarak karşımıza çıkıyor. Hashmap illaki bir şekilde duyduğunuz ya da günlük hayatınızda kullandığınız bir veri yapısı ancak bunun hakkında da ufak bir hatırlatma yapalım. Hashmap içerisinde yapılan bir aramanın kompleksitesi O(1)’dır. En kötü senaryoda ise O(n)’dir. Burada veriye erişim süresini değiştiren unsur ise hash fonksiyonudur. Makalenin ilerleyen kısımlarında Go’da map yapısı için hash fonksiyonunun nasıl çalıştığı konusundan da detaylı bahsedeceğiz ancak şimdilik bu ön bilgiyi aklımızda tutalım.

Nasıl Kullanılır ?

Go’da map kullanımı oldukça basit ancak bazı dikkat edilmesi gereken noktalar mevcut. Sırasıyla gidecek olursak bir map tanımlamak için aşağıdaki yöntemlerden herhangi birisini kullanabilirsiniz:

var myMap map[string]int
myMap2 := make(map[string]int, 15)
myMap3 := map[string]int{"Stock":120}
myMap4 := map[string]int{}be
var myMap5 = make(map[string]int, 10)

Burada görebildiğiniz gibi sadece bir değil birden fazla yöntemle map tanimlayabiliyoruz.

Elbette bu farklar sadece syntax bazında değil. İlk satırda yer alan yöntemi kullandığımızda elimizde nil (null) bir map olmuş oluyor. Nil map tanımlarıyla bilmeniz gereken en önemli şey ise; nil bir mapten okuma yapabilirsiniz ancak veri yazamazsınız. Okuma işleminde value kısmında belirlediğiniz veri tipinin varsayılan değeri döner. Örneğin siz tıpkı bizim örneğimizdeki gibi key’lerin veri tipini string olarak belirleyip value’ların veri tipini int belirlediyseniz nil bir map’ten okuma yaptığınızda size sonuç 0 dönecektir.

Eğer nil map’e yazma işlemi gerçekleştirmeye çalışırsanız “panic: assignment to entry in nil map“ hatası alırsınız ve eğer error handling konusunu atladıysanız uygulamanız sonlanır. Bu hata uygulamanın derleme zamanında değil çalışma zamanında geleceğinden dolayı uygulamanız production ortamında hata alabilir ve emin olun bu basit gibi görünse de gözden kaçan bir konu.

İkinci tanımlamamıza bakacak olursak make fonksiyonunu kullanarak yeni bir map oluşturuyoruz. Buradaki 15 değeri tekrar bir allocation işlemi olmadan map içerisine 15 adet veri eklememizi sağlıyor yani bir diğer deyişle map oluşturma işlemi sırasında 15 adet int verisini tutabileceğimiz bellek alanını ayırıyoruz. Burada oluşturduğumuz map nil olmadığından ötürü rahatça yeni eleman ekleyebilir ya da map içerisindeki bir elemanı sorgulayabiliriz. Burada herhangi bir uzunluk ya da kapasite parametresini kullanmadan da tanımlama yapabilirsiniz yani örnekteki 15 değerini sildiğinizde hata almazsınız.

make fonksiyonu Go programlama dili içerisinde gelen built-in bir fonksiyondur. Kullanım alanı slice, map ve channel yapılarını oluşturmak ve bu oluşturma işlemi esnasında bizim belirlediğimiz integer değer kadarlık veri alanını heap’de ayırmaktır. Make içerisine 3 adet parametre alır. İlki oluşturulacak olan veri yapısı, ikinci parametre uzunluk, üçüncü parametre ise kapasitesidir (alabileceği maksimum veri sayısı). Burada dikkat edilmesi gereken nokta uzunluk parametresi, kapasite parametresindeki değerden büyük olmamalıdır. Daha detaylı bilgi için make ile ilgili dokümanı inceleyebilirsiniz make function

Üçüncü tanımlamamızda ise map oluştururken direkt olarak içerisine eklemek istediğimiz elemanları belirtiyoruz. Son tanımlamamız da aslında benzer olsa da sonuncu tanımlamada herhangi bir veri eklemeden sadece empty bir map oluşturmuş oluyoruz. Örneklere genel olarak bakarsak ilk tanımlama nil bir map oluştururken diğer tüm tanımlamalar non-nil ya da empty bir map oluşturmamızı sağlıyor.

Bir map’ten veri okumak için aşağıdaki kodu kullanabilirsiniz eğer ilgili anahtar değeri map içerisinde yoksa bizim map içerisindeki değerlerimizin veri tipi int olduğu için sonuç 0 dönecektir.

myValue = myMap["testkey"]

Map’lerden veri okurken aslında bize 2 adet sonuc gelir bunlardan birisi değerin kendisi diğeri ise kontrol ettiğimiz anahtarın gerçekten map içerisinde olup olmadığını belirten boolean tipinde bir değerdir. Aşağıdaki ilk satırda verimizi ve verimizin gerçekten belirttiğimiz anahtar ile map içerisinde olup olmadığını kontrol edebiliyoruz. İkinci satırda ise biraz daha pratik bir yöntemle bu kontrolü if koşulu ile kullanip eğer map içerisinde gerçekten bu anahtar varsa kendi algoritmamızı çalıştırıyoruz.

myValue, isExist := myMap["testkey"]
if myValue, isExist := myMap["testkey"]; isExist {
// Çalıştırmak istediğiniz kodlar
}

Map ile for döngüsü kullanarak verileri okumak isterseniz aşağıdaki kodu inceleyebilirsiniz

 for key, value := range myMap {
fmt.Println(key, value)
}

Map içerisindeki bir elemanı silmek için built-in olarak gelen delete() fonksiyonunu kullanabilirsiniz

delete(myMap, "testkey")

Go’da Map İmplementasyonu Nasıl Çalışıyor?

Makalenin ilk bölümlerinde Map yapısının ne olduğundan, ne için kullanıldığından ve nelere dikkat etmemiz gerektiğinden bahsettik. Bundan sonra artık görünmeyen kısımlarda Map gerçekten nasıl çalışıyor bizim bu yaptığımız tanımlamalar arka planda nelere yol açıyor ya da yeni bir eleman eklediğimizde ne oluyor gibi konuları inceleyeceğiz.

En başında yaptığımız tanımlamada da söylediğimiz gibi Go’da map implementasyonu aslında hashmap veri yapısıdır. Hashmap içerisinde bucket dizisi barındıran ve her bir bucket içerisinde de 8 eleman barındıran bir implementasyon olarak karşımıza çıkıyor. Burada bucket sayısı daima 2 ve katları şeklinde artarak gidiyor yani siz 2 bucket bulunan bir map’e yeni bir eleman eklediğinizde Go 3. bucket yaratma ihtiyacı duyuyorsa o zaman 2 adet daha bucket yaratarak toplam 4 bucket içeren bir map haline geliyor.

Go’da Map yapısı kaynak kodda hmap struct’ı ile temsil edilirler. hmap struct’ı içerisinde aşağıdaki alanları barındırmaktadır :

// A header for a Go map.
type hmap struct {
count int // # live cells == size of map. Must be first (used by len() builtin)
flags uint8
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed
buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)
extra *mapextra // optional fields
}

Buradaki tüm alanları elbette ezberlemenize gerek yok. Go’da bir map oluşturduğunuzda aslında hmap struct’ını oluşturmuş oluyorsunuz. Yaptığınız tüm işlemler hmap struct’ında değil struct içerisindeki buckets alanı üzerinde gerçekleşmektedir. Bundan dolayı her ne kadar Go dokümantasyonunda Map için referans tip tanımı yapılmış olsa da Map de diğer tüm tipler gibi pass-by-value şeklinde çalışmaktadır. Go’da mapler her bir bucket içerisinde maksimum 8 adet key-value ikilisi tutabilir bunun haricinde ekstradan içerisinde overflow pointer alanı barındırır. Siz map içerisine yeni bir değer eklediğinizde eğer bu eklenen değer 8 elemanlı bir bucket’a gittiyse bu noktada overflow pointer’a denk geliyor ve yeni bir bucket oluşturularak overflow pointer alanındaki değer oluşturulan yeni bucket’a ekleniyor.

Aslında overflow pointer’ın ana amacı 8. elemandan sonra gelen eleman varsa yeni bir bucket’a atmak ve bucket’ların birbirine bağlı olmasını sağlamak. Peki bizim verimiz hangi bucket’a gideceğini nerden biliyor? Go’da map içerisine eklenen her bir key ile yeni bir hash key oluşturuluyor ve bu oluşturulan hash key değerinin LOB (Low Order Bit/Least Significant Bit/Right-most bit) değeri bucket seçiminde kullanılıyor.

Şimdi yukarıda bahsettiğimiz Map’ler de diğer tüm yapılar gibi (pointer da dahil) pass-by-value şeklinde çalışır ifadesini biraz daha açalım. Örneğin siz bir fonksiyona map’i parametre olarak gönderirseniz burada map’in pointer değerini göndermediğiniz sürece tıpkı struct yapısında olduğu gibi bir kopya üzerinde çalışmış oluyorsunuz.

Şu an bir süredir Go ile proje geliştiriyorsanız aklınıza şu soru gelebilir “pass-by-value geçtiğimiz her değeri mutlaka düzenledikten sonra geri dönmemiz gerekiyor ama map kullanırken hiç böyle yapmadık neden böyle oluyor?”. Aslında bunun cevabını da yukarıda öğrendik. Siz map oluşturma işleminde hmap struct’ını yaratıyorsunuz ve parametre olarak bir fonksiyona map gönderdiğinizde de yine bu hmap struct’ının bir kopyasını göndermiş oluyorsunuz. Gönderdiğiniz şey her ne kadar bir kopya olsa da içerisinde bucket dizisinin pointer’ı bulunduğundan dolayı map üzerinde yaptığınız herhangi bir işlem direkt olarak bu pointer aracılığıyla bucket’larda gerçekleşiyor dolayısıyla sizin map’i tekrar geri dönmenize gerek kalmıyor.

Map yapısı ile ilgili küçük ipuçları ve optimizasyonlar

Map yapısı uygulamanızı çalıştırdığınız makinenin ram kapasitesi ne kadarsa o kadar büyüyebilir. Bu sadece çalışma anında bilinebilecek bir durum olduğundan dolayı map yapısı için built-in gelen cap() fonksiyonu kullanılamaz.

Map yapısı sadece büyüyebilir küçülemez. Bu cümle her ne kadar net olsa da biraz detaylandıralım. Yukarda bahsettiğimiz gibi map yapısı arkada bir array kullanıyor ve bu arraylerin belli bir limiti var siz map’e yeni bir değer ekledikçe map’in bellekte kapladığı alan da doğal olarak artacaktır. Burada dikkat edilmesi gereken konu ise siz map içerisindeki tüm key-value pairleri silseniz bile map’in kapladığı alan küçülmez. Kullanılan bu alanı tekrar serbest bırakmak için map’ı nil’e eşitlemeniz ya da make ile tekrar oluşturmanız gerekiyor.

Map yapısını daha performanslı kullanmak için bazı yöntemler mevcut bunlardan belki de en yaygın olanı örneklerimizde kullandığımız gibi bir integer tipinde değer tutmak için kullanıyorsanız ve bu değerler ile toplama/çıkarma/çarpma/bölme vb. işlemler yapacaksanız bunu asagidaki gibi yapmanız size bir performans kaybı yaşatır.

myMap["myProductId"] = myMap["myProductId"] + taxAmount

Bunun yerine direkt olarak += vb. operatorlerle deger atamasını yapmak çok daha performanslı olacaktır. Bunun sebebi yukarıdaki örnek için hashkey 2 kez hesaplanırken aşağıdaki örnek için yalnızca 1 kez hesaplanmasıdır.

myMap["myProductId"] += taxAmount

Go her ne kadar başlangıç için kolay ve anlaşılabilir bir dilmiş gibi görünse de uzmanlaşması oldukça zaman alan bir dil. Arka planda yaptığı özel optimizasyonlar özellikle kaynak konusunda dikkat gerektiren uygulamalarda kritik öneme sahip oluyor. Bunlardan bazılarına makale içerisinde değindik ancak her şeyi detaylı bir şekilde bilmek imkansız bu yüzden go101.org vb. siteleri günlük rutininize dahil ederseniz yazdığınız uygulamalarda elde etmek istediğiniz sonuçlara çok daha çabuk ulaşabilirsiniz.

Bu optimizasyonlara örnek olarak Go arka planda map key’leri hashlerken 4 ve 8 byte’lık değerler için özel bir optimizasyon uyguluyor. Bundan dolayı map key’lerinizi belirlerken map[int16] yerine map[int32] kullanmak ya da map[[5]byte] yerine map[[8]byte] gibi key’ler kullanmak size kaynak optimizasyonu olarak cok daha iyi sonuclar verecektir.

Son optimizasyon ise özellikle map’lerde key olarak belirli bir uzunlukta string tipinde değerler kullanıyorsanız bunları direkt olarak string değil byte array olarak kullandığınızda ortaya çıkıyor. Örnek olarak aşağıdaki kod satırlarına baktığımızda birinde string tipinde key kullanılıyorken diğer tanımlamada byte dizisi kullanılmış. Buradaki fark ise string ile tanımlanan map için GC tarama (scan) evresinde map içerisindeki tüm verileri tarıyorken byte dizisi ile yapılan tanımlamada bu işlemi pas geçiyor ve burada harcanabilecek süreyi kazanmış oluyoruz.

var myMap = make(map[string]int, 10)
var myMap2 = make(map[[32]byte]int, 10)

Elbette bu tarz optimizasyonlar cok fazla veri iceren map yapılarında fark yaratıyor. Az sayıda veri içeren map’lerde bu tarz değişikliklerin farkları anlaşılamasa da veri sayısı arttıkça bu tarz detay bilgiler önemli hale geliyor.

Sync Map

Makaleyi bitirmeden son olarak sync paketinin altında yer alan map yapısından bahsedelim. Direkt olarak kaynak koddan aldığım yorum satırını aşağıda inceleyebilirsiniz.

// The Map type is specialized. Most code should use a plain Go map instead,
// with separate locking or coordination, for better type safety and to make it
// easier to maintain other invariants along with the map content.
//
// The Map type is optimized for two common use cases: (1) when the entry for a given
// key is only ever written once but read many times, as in caches that only grow,
// or (2) when multiple goroutines read, write, and overwrite entries for disjoint
// sets of keys. In these two cases, use of a Map may significantly reduce lock
// contention compared to a Go map paired with a separate Mutex or RWMutex.

Buradaki yorum satırlarını özetleyecek olursak en kısa tabirle sync paketinin altındaki map’i kullanmamanızı bunun yerine direkt olarak içerisinde mutex ve map barındıran bir struct oluşturup kullanmanızı bu sayede yazdığınız kodun bakımının daha kolay hale gelecegini ve type safety için daha iyi olacağını belirtiyor. Örnek olarak aşağıdaki struct yapısını düşünebilirsiniz.

type MyCustomMap struct {
sync.Mutex
m map[string]string
}

Sync paketinin altındaki map yapısı yorum satırlarından da okunacağı üzere 2 senaryo için optimize edilmiş durumda. İlk senaryo uygulamanız ayağa kalkarken sadece bir kez yazma işlemi yapıp çok fazla okuma işlemini gerçekleştirdiğiniz senaryo. Uygulamanız ayağa kalkarken bi json/yaml dosyasından configleri okuyup uygulama genelinde kullanmanız bu senaryo için iyi bir örnek olacaktır.

Diğer önemli senaryo, birden fazla goroutine’in eşzamanlı olarak çalıştığı durumlarla ilgili. Bu senaryoda, farklı goroutinler, farklı anahtar grupları üzerinde okuma ve yazma işlemleri gerçekleştirebilir. Standartmap yapısını kullanıldığınızda, her erişim için kilitleme (lock/unlock) işlemi yapmak gerekeceği için performans sorunları ortaya çıkabilir.

Sync paketi altinda yer alan map implementasyonu, lock maliyetini minimuma indirerek işlemlerin daha performanslı bir şekilde gerçekleşmesine olanak tanıyor. Özellikle birden fazla goroutin’in verilere eşzamanlı olarak eriştiği ve yüksek performansın kritik olduğu uygulamalarda, bu yapı hayat kurtarıcı olabilir.

Ancak, günlük kullanıma baktığımızda, bu özelliğin işinize yarayacağı durumlar her zaman karşımıza çıkmayabilir. Pratikte, bu özelliği gerektiren senaryolar toplam kullanımın belki de yalnızca %5 ya da %10'unu oluşturacaktır. Yani, sync paketi icerisinde yer alan map yapısının avantajlarını görmek için, gerçekten lock contention’ın bir sorun olduğu senaryolarla karşılaşmanız gerekecektir.

Kendi adıma bazı unuttuğum şeyleri hatırladığım ve araştırma sürecinde yeni bilgiler edindiğim bir makale oldu. Umarım bu makale sizler için de faydalı olmuştur. Takıldığınız bir nokta olursa ya da eklenmesi/çıkarılması gereken bir yer olduğunu düşünüyorsanız bana Twitter ya da LinkedIn üzerinden ulaşabilirsiniz müsait olduğum anda dönüş yaparak sorularınızı cevaplandırabilirim.

Makale için kullandığım kaynaklar :

Maps -Go 101

Internals of Map in Golang. Maps are associative containers mapping… | by Phati Sawant | Medium

Macro View of Map Internals In Go (ardanlabs.com)

Mastering Go — Third Edition [Book] (oreilly.com)

map Types in Golang. What is actually a map in Golang?. | by Dinesh Silwal | wesionaryTEAM

go/src/sync/map.go at master · golang/go (github.com)

--

--