Go'da Concurrency ve Uygulamaları
Last updated
Last updated
Bu yazımızda sizlere goroutine, channel, buffered channel, mutex ve select konularını anlatacağız. İyi okumalar dileriz :)
Bu kısımda sizlere gorutine ve channel'lar hakkında temel bilgileri vereceğim. Gorutine konusuna girmeden önce "eş zamanlılık" ve "paralellik" konusuna değineceğim.
Eş Zamanlılık (Concurrency): Birden çok görevin aynı anda çalıştırıldığı bir kavramdır. İşlemler arasında geçiş yaparak, bir işlem bir diğerine devredildiğinde eş zamanlılık gerçekleşir.
Paralellik (Parallelism): Birden çok görevin aynı anda ve bağımsız olarak çalıştırıldığı bir kavramdır. Paralel bir sistemde, işlemler aynı anda başlar. Ancak burada işlemler arasında geçiş yoktur.
Resimde de görüldüğü üzere "eş zamanlılık" kısmında iki tane çekirdeğimiz var ve bu çekirdekler iki tane görevi arasında geçiş yaparak gerçekleştiriyorlar. Fakat paralellikte yine aynı şekilde iki tane çekirdek var, ancak bir çekirdek bir görevi alıyor ve bitirene kadar bırakmıyor.
Gorutine Nedir?
Goroutines, Go dilinin çalışma zamanı tarafından hafif bir şekilde yönetilen eş zamanlı işlemlerdir. Bu, aynı çalışma zamanı içinde birden fazla iş parçacığını oluşturabilmemizi sağlar.
Neden Kullanılır?
Bu kısım için sadece bir örnek vereceğim.
Bu örneği bir web sitesi olarak düşünelim ve kullanıcının bir dosya yüklediğini hayal edelim. Biz kullanıcının bize verdiği dosyayı yüklememiz gerekiyor ancak biz bu dosyanın ne kadar büyük olabileceğini bilemiyoruz. 100 MB olabilir, 1 GB olabilir. Eğer öyle olursa uploadFile(file)
kodu çalıştıktan sonra web sitesi için önemli olan important()
fonksiyonunun çalışması için x dakika beklemek zorunda kalacağız. Bu da sitenin kullanılabilirliğini önemli ölçüde azaltacaktır. Bu gibi sebepler yüzünden gorutine kullanılabilir.
Nasıl Kullanılır?
Gorutine’ler go anahtar kelimesiyle kolayca başlatılabilirler.
Şimdi bir örnek verelim.
Burada, ana iş parçacığı (main goroutine
) içindeki Process fonksiyonu bir gorutine içinde çalışıyor. time.Sleep(2 * time.Second)
kodu ise bu işlemin tamamlanması için bir zaman tanımlıyor gibi görünüyor.
İlk olarak, ana kod (main
) çalıştıktan sonra eğer go
anahtar kelimesi orada olmasaydı, ana iş parçacığı satır satır Process
fonksiyonunun içine girer ve çalıştırırdı. Ancak burada go
anahtar kelimesi olduğu için go Process()
kısmına geldiği zaman orada bir iş parçacığı oluşturuyor ve ana iş parçacığı kendi işini yapmaya, yani bir sonraki satırları okumaya devam ediyor. Bu sırada "eş zamanlı" bir şekilde diğer oluşan iş parçacığı ise Process
fonksiyonunun içine girip orayı satır satır okuyor.
Burada time.Sleep(2 * time.Second)
kodu dediğiniz gibi bekleme işlemi gerçekleştiriyor çünkü iş parçacıkları diğer iş parçacıklarından bağımsız çalışır. Yani eğer ana iş parçacığı diğer iş parçacığından erken biterse, diğer iş parçacığı işini tamamlayamaz. Bu gibi durumlara goroutine leaks denmektedir.
Peki, nereden biliyoruz ki 2 saniye beklemenin yeterli olacağını? Burada üç tane olasılık bizi karşılıyor. Ya şanslıyızdır ve gerçekten iş parçacığı 2 saniyede işini tamamlar ve biter, ya da şanssız oluruz ve iş parçacığı işini bitirmesi için 4 saniye sürer, dolayısıyla goroutine leaks oluşur. Son olarak da iş parçacığı işini 1 saniyede bitirir ve biz boşu boşuna 1 saniye fazla beklemiş ve sistemi yormuş oluruz. Bu gibi durumlarım çözümü için WaitGroups kullanacağız.
WaitGroups
Nedir?: Go dilinde goroutine’lerin eş zamanlı çalışmasını kontrol etmek ve ana programın bu goroutine’lerin tamamlanmasını beklemesini sağlamak için kullanılan bir senkronizasyon mekanizmasıdır.
Yine bir örnek ile anlatmayı tercih ediyorum. Ilk olarak komutlarını kullanımını yazalım.
wg.Add
: Bu fonksiyon sync.WaitGroup
içindeki bekleme sayacını arttırır.
wg.Done
: Bu fonksiyon ise wg.Add’ın tam tersini yapar. sync.WaitGroup
içineki bekleme sayacını azaltır.
wg.Wait
: Bu fonksiyon sync.WaitGroup
içineki bekleme sayacı sıfır olana kadar bekler.
Yani basitçe, kaç tane iş parçacığı oluşturursanız, bu sayıyı wg.Add(x)
olarak belirtmelisiniz ve her iş parçacığının çalıştığı fonksiyonun içine wg.Done()
ekleyerek WaitGroup içindeki sayaçları azaltmalısınız. Son olarak, ana iş parçacığından wg.Wait()
çağrarak beklemelisiniz.
Bu şekilde, tüm iş parçacıklarının tamamlanmasını bekleyebilir ve programın uygun bir şekilde sonlanmasını sağlayabilirsiniz.
Channels Nedir?
Go programlama dilinde "channel" (kanal), gorutineler arasında veri iletişimini ve senkronizasyonu sağlamak için kullanılan bir veri yapısıdır.
Channels Ne İçin Kullanılır?
Eş Zamanlı İşlemler
Bir ticaret uygulamasında, ürün stoklarını güncellemek, müşteri siparişlerini işlemek ve ödeme işlemlerini aynı anda yönetmek önemlidir.
Gorutineler Arası İletişim ve Senkronizasyon
Müşteri bir ürünü sepete eklediğinde, stoktan düşmek, sipariş oluşturmak ve müşteriye sipariş onayı göndermek gibi farklı görevler arasında güvenli bir iletişim sağlanmalıdır.
Kodun Daha Okunabilir ve Bakımı Kolay Hale Gelmesi
Uygulama içindeki farklı modüller arasındaki iletişimi belirginleştirmek ve kodu daha anlaşılır hale getirmek önemlidir.
Channels Nasıl Kullanılır?
Alttaki şekilde bir channel oluşturabilirsiniz. Burada [TYPE] yerine int,string hatta kendi oluşturduğunuz struct bile gelebilir.
Burada ise int değer alan bir channel'a <- operatörü ile 42 değerini yolluyoruz.
Channel kapatma işlemi.
Alttaki şekilde bir channel üzerinden değer alabiliriz.
Temel olarak, kanallar yukarıdaki komutlarla kullanılıyor. Tabii ki, daha fazla kullanım şekli de bulunmaktadır. Burada dikkat etmemiz gereken önemli bir nokta var: Eğer bir kanala veri gönderiliyorsa, o kanalı dinleyip veriyi işleyecek bir iş parçacığı (gorutine) olmak zorundadır. Aksi takdirde, ileride göreceğimiz deadlock hatasını alırız.
Deadlock
Bilindiği üzere her go programı main routine ile başlar. Main routine içinde yeni goroutineler oluşturarak programa eşzamanlılık kazandırmış oluruz. Ancak bu iş parçacıkları doğru yönetilmezse deadlock denilen hata ortaya çıkar. Deadlock: Oluşturulan iş parçacıklarının çalışmaya devam etmek için, çeşitli nedenlerle, sonsuza kadar birbirini beklemesi durumunu ifade eder. Go sistemi bu durumu algılar ve routinleri sonlandırarak deadlock hatası verir.
Oluşturulan iş parçacıklarının birbirleri ile haberleşmesi için kullanılan channelların dikkat edilmesi gereken iki özelliği vardır:
Channelda veri yoksa channeldan veri çeken routine, channela veri gelene kadar çalışmayı durduracaktır.
Channelın bufferı doluysa channela veri ekleyen routine, channel'ın bufferı boşalana kadar çalışmayı durduracaktır.
Main routine ek bir goroutine bulunduran bir programda yukarıda saydığımız iki madde aynı anda gerçekleşirse program sonsuz bir bekleme döngüsüne girmemek için deadlock hatası vererek sonlanacaktır. Zira bir goroutine veri eklemek için bufferın boşalmasını beklemekte, diğeri ise veri çekmek için channela veri gelmesini beklemektedir.
Channelların bufferı ve programın channellara veri ekleme, çekme sırası iyi yönetilerek bu hatanın ortaya çıkması engellenmelidir.
Bu örnekte bir kare yapısı tanımlanmış ve ana iş parçacığı içinde go
kullanılarak bu karenin alanı hesaplanıyor. Bu hesaplama 3 saniye sürüyor ve channel'a gerekli değer döndürülüyor. Bu değer ise ana iş parçacığı tarafından alınıp ekrana yazılıyor.
Görüldüğü üzere ana iş parçacığımız fmt.Println("Square Area: ", <-square.ch)
koduna geldiğinde o channel'a veri gelmesini bekliyor. Böylelikle iş parçacıkları arasında senkronizasyon sağlanıyor. Eğer go square.Area()
fonksiyonu çalışmasaydı bu square.ch channel'ına veri gelmeyecekti bu da deadlock dediğimiz sorunu ortaya çıkarıcaktı.
Buffered Channels
Deadlock olayı channels ile doğrudan ilişkilidir. Channel kullanımı doğru yapılmazsa deadlock yaşanma ihtimali oldukça artar. Go dilinde channel oluşturulurken make fonksiyonu aslında bir argüman daha alır. İkinci parametre olarak eklenen bu değer channelın buffer boyutudur. Bu parametre verilmeden bir channel oluşturulursa buffer boyutu varsayılan olarak 0 olacaktır. Bu da bufferın bulunmadığı, dolayısıyla channela veri eklendiği anda hali hazırda veriyi almak için bekleyen bir goroutine bulunmuyorsa programın deadlock hatası vereceği anlamına gelir.
Bu örnek kodda oluşturulan channelın buffer boyutu 0'dır. Main routinde channela veri eklenmekte, func1 adlı fonksiyonun çalıştırıldığı goroutinede ise channeldan veri alınmaktadır. Channela eklenen ilk veride buffer taştığı için main routine çalışmaya devam edebilmek için bufferın boşalmasını bekleyecektir. Fakat bufferdan veri çekecek bir goroutine bulunmadığı için program deadlock hatası vererek sonlanacaktır. Eğer func1 için oluşturulan goroutine başlatıldıktan sonra main routinede channela veri eklenseydi program sorunsuz çalışacaktı.
Bu kodda ise buffer boyutu 1 olarak oluşturulan channela main routinede bir int değer ekleniyor. Daha sonra bir goroutine oluşturuluyor ve iki defa channeldan veri çekiliyor. İlk veri alındıktan sonra bufferda veri kalmadığı için ikinci defa veri alınmaya çalışılırken routine bloklanıyor. Main routinden channela başka bir veri girişi yapılmadığı için de program sonsuz beklemeye girmemek için deadlock hatası vererek sonlanıyor.
Özetle, channellar kullanılırken bulunduğu routinei iki koşulda bloklarlar:
Buffer boşken channeldan veri çekilmeye çalışılırsa routine channela veri eklenene kadar bloklanacaktır.
Buffer dolu iken channela veri eklemeye çalışılırsa routine buffer tamamen boşalana kadar bloklanacaktır.
Dolayısıyla channela veri ekleyen ve channeldan veri tüketen routinler bu maddelere dikkat edilerek tasarlanmalıdır.
Bu bölüm Yasir Altın tarafından yazılmıştır.
Birden fazla goroutine’in aynı kaynak üzerinde aynı anda değişiklik yapabilmesini önleyen yapıya mutex diyoruz. Eğer mutex kullanmazsak ve goroutinelerin bir kaynağı eşzamanlı olarak değiştirmesine izin verirsek, burada Race Condition zaafiyeti ortaya çıkmasına izin vermiş oluruz.
Race Condition Zaafiyeti'ni daha iyi anlamak için önceden yayınladığımız yazımıza buradan ulaşabilirsiniz.
Kaynağımızı değiştireceğimiz bölgede m.Lock() kullanarak goroutine’imizi buraya kilitleriz ve m.Unlock() çalışana kadar başka hiçbir goroutine bu bölgeye erişemez ve kaynağı değiştiremez. Bu şekilde race condition’ın önüne geçmiş oluruz.
Bu örneğimizde bir saldırganın saniyeler içinde birden fazla satın alma isteği göndermesi simüle edilmiştir. Eğer kullanıcı burada mutex kullanarak Purchase fonksiyonunu kilitlemeseydi o zaman eş zamanlı olarak çalışan goroutineler if bölgesini atlatabilecekti. Bunun sebebi de goroutinelerin birisi banka hesabından miktarı düşüremeden diğeri if şartını kontrol ediyor ve bu durumda bakiyenin eksilere düşmesine olanak tanınmış oluyor. Bunu daha iyi anlamak için aşağıdaki görsele beraber bakalım.
Birinci goroutine banka hesabındaki bakiyeyi okuyor. Henüz hesabına bu miktarı ekleyemeden diğer goroutine çalışmaya başlıyor ve banka hesabındaki miktarı okuyor. Bu durumda iki goroutine’in bildiği bakiye değeri £50 olacaktır. İlk goroutine ekleme işlemini tamamlıyor. Ancak diğer goroutienin okuduğu değer de £50 olduğundan bir önceki rutinin yapmış olduğu değişikliği göremiyor ve kendi değişikliğini yapıp bakiyeye yansıtıyor. Sonuç olarak yalnızca 2. Goroutine’in yapmış olduğu işlem bakiyeye yansımış oluyor. Bunun önüne geçebilmek için bizim her zaman bir kaynağa eşzamanlı olarak erişmemiz söz konusuysa mutex kullanarak o bölgeyi kilitlememiz gerekmektedir. Ama o bölgeyi unlock etmeyi unutmamalıyız yoksa bu bölge hep kilitli kalacağından hiçbir goroutine bu bölgeye giremeyecektir ve deadlock oluşacaktır.
Mutex kullanırken en çok karşılaşılan sorunlardan birisi deadlock oluşmasıdır. Bunun sebebi de mutexin unlock edilmemesi veya birden fazla mutex kullanımı durumunda yazım sırasına dikkat edilmemesidir. Bunun için aşağıdaki örneğe beraber bakalım.
Burada GoroutineA ve GouroutineB fonksiyonlarında 2 tane mutex kullanılmış ve bunlar farklı sıra ile kilitlenmiştir. Burada iki mutex kullanılmasının nedeni, resourceA ve resourceB'nin ayrı ayrı kilitlenmesi gerekliliğidir. Bu, resourceA'nın artırılması sırasında resourceB ile herhangi bir ilişkisinin olmaması ve dolayısıyla ikisinin bağımsız olarak artırılabilmesini sağlar. Mutexlerin farklı sıra ile kilitlenmesi ise GoroutineA ve GoroutineB ‘nin eşzamanlı olarak çalışması durumunda Deadlock oluşmasına sebep olacaktır.
Bunun sebebi ise GoroutineA çalışırken ilk başta m1 mutexini kilitleyecek ve kaynağının değerini değiştirecektir. Aynı zamanda GoroutineB de çalışacak ve m2 mutexini kilitleyerek kaynağını değiştirecektir.
GoroutineA m2 mutexini de kilitleyip diğer kaynağının değerini değiştirmek isteyecektir. Ancak diğer fonksiyon(GoroutineB) onu kilitlediği için GoroutineA bu mutexi kilitleyemeyecek ve GoroutineB’nin m2 mutexini serbest bırakmasını bekleyecektir.GoroutineB ise m1 mutexini kilitlemek isteyecektir fakat GoroutineA onu kilitlediği için o da bu mutexe erişemeyecektir ve burada sonsuz bir bekleyiş başlayacaktır.
Bu sorunun önüne geçmek içinse mutexler her fonksiyonda aynı sırayı koruyarak kullanılmalıdır. Böyle olursa GoroutineA çalışıp m1 mutexini kilitlediğinde GoroutineB bu fonksiyonun m1 mutexini serbest bırakmasını bekleyecektir ve deadlock önlenmiş olacaktır.
Birden fazla Channel’ın eşzamanlı olarak dinlenebilmesine olanak tanıyan yapıya Select denmektedir. Yapısal olarak Switch case’e benzetebiliriz ancak mantıksal olarak biraz farklıdır. Select case yapısında bir değişkenden gelen değere göre değil, hangi Channel veri göndermişse ona göre case içerisine girilmektedir.
Yukarıda Select case yapısının kullanımı gösterilmiştir. Ancak burada kullanılan Default case’in kullanımı zorunlu değildir. Kullandığı zaman programın, Select yapısı channellar üzerinden veri gelmesini beklerken başka işler yapabilmesine olanak tanımaktadır. Default case olmadığı durumlarda Select yapısı veri gelmesini beklerken programı bloklamaktadır.
Bu şekilde bir kullanım olduğu zaman Select yapısı yalnızca bir Channel üzerinden gelen veriyi ya da Default case’i çalıştırıp programa devam etmektedir. Ancak eğer tüm channellar üzerinden gelecek veriyi dinlemek istiyorsak Select’i for döngüsü içerisine almamız gerekir.
Burada tüm Channel'lar üzerinden veri gelmesi beklenecektir. Bekleme sırasında Default case kullanılmışsa buradaki gibi program başka işlemler yapacaktır. Veri gelir gelmez de caselerin içine girecektir. Done Channel’ı için belirlenmiş süre sonunda ise program channelları dinlemeyi bırakacak ve programı sonlandıracaktır.
For döngüsünü kırmak için timeout ifadesini de kullanabilmekteyiz ancak bu ifade Default case ile beraber çalışmamaktadır. Timeout ile Select yapısının belirtilen süre zarfında bloklu kalması durumunda yapacağı işlemi ifade etmekteyiz.
Bu şekilde time.After kullanarak timeout tanımlayabilmekteyiz.
For içinde kullanılan Select yapısını kırmak için labellardan da yararlanabiliriz. Bu şekilde programımız sonlanmaz, devam edebilir.
Select yapısında karşılaşılabilecek üç durumdan bahsedebilmekteyiz; channelların sırayla veri gönderdiği durum, aynı anda birden fazla Channel’ın veri gönderdiği durum ve hiçbir channel’ın veri göndermediği durum.
Channelların sırayla veri gönderdiği durumda Select yapısı ilk veri gönderen Channel’ı dinleyen case içerisine girmektedir ve sırayla bu işleme devam eder. Ancak bu her zaman bu şekilde olmayabilir. İki ya da daha fazla channel aynı anda veri göndermiş olabilir. Bu durumda Select yapısı hangi case’in çalışacağına rastgele karar vermektedir. Eğer Select yapısı for içerisindeyse diğer caseler de çalışabilmiş olacaktır ancak for içerisinde değilse yalnızca rastgele seçilmiş olan Channel üzerinde işlemler yapılabilmektedir.
Hiçbir Channel'ın veri göndermediği durum da söz konusu olabilir. Bu durumda ise Default yapısını veya timeout yapısını kullanmadıysak Deadlock oluşacaktır. Çünkü Select yapısı sonsuza kadar gelmeyen bir veriyi bekleyecektir. Bu sebeple programın veri gelmemesi durumunda ne yapacağını ele almak önemli bir husustur.