İçindekiler
Geliştirilmekte olan hemen her yazılımda eş zamanlı olarak çalışması gereken işlemler vardır. Bunların aynı anda çalışması ve bazen de birbiriyle senkronize olarak üzerlerine düşen görevleri yapmaları beklenir. Go dili Concurrency (Eşzamanlılık) sayesinde eş zamanlı fonksiyon çalıştırmayı doğal olarak destekleyen bir programlama dilidir.
Concurrency yani eş zamanlılık denildiğinde herhangi bir program içerisinde (n) sayıda görevin aynı anda çalışması akla gelmelidir. Daha teknik bir ifadeyle fonksiyonların sırayla değil, aynı anda çalıştırıldığı anlaşılmalıdır.
Eşzamanlılık konusunda en yaygın örnek web sunucularıdır. Sunuculara gelen ziyaretçiler eş zamanlı olarak web sitelerine ulaşabilmektedir. Hiçbir ziyaretçi diğer ziyaretçiyi beklemez. Teknik anlamda sunucuya (server) istemcilerden (client) gelen istekler (request) eş zamanlı olarak ele alınıp işlenir ve gerekli cevap (response) gönderilir. Web sunucusu bu görevleri eş zamanlı olarak yürütür. Concurrency’deki temel amaç da zaten budur, görevleri aynı anda işletebilmek.
Go dilinde eş zamanlı görevleri yerine getirebilmek için Go Routine ve Channels (Kanallar) kullanılmaktadır.
Go Routines
Go routine denilince birbirinden “bağımsız” ve “eş zamanlı” olarak çalışan ufak iş parçacıkları aklınıza getirebilirsiniz. Bunu gerçekleştirmek Go dilinde gerçekten çok kolay. Tek yapmanız gereken bağımsız çalışmasını istediğiniz fonksiyonların başına go kelimesini eklemek.
Aşağıdaki örnekte bir dizi içinde Flash kelimesini arayan ve bulduğu anda ekrana yazan iki fonksiyon gösterilmiştir. Bu fonksiyonların başlarına “go” getirilerek go routine’e dönüştürülmüş olduklarına dikkat ediniz.
Örnek A
Programın çıktısından da görebileceğiniz gibi iki fonksiyon birbirini beklemeden ve eş zamanlı olarak çalışmıştır.
Anonymous (İsimsiz) Go Routine
Yaznın devamında isimsiz go routine’ler göreceğimiz için burada ne olduğuna kısaca değinmek istedim.
Go routine çalıştırırken her zaman dışarıdan bir fonksiyon çağırmanıza gerek yoktur. İsimsiz fonksiyonlar ile pratik bir şekilde go routine kullanabilirsiniz ve bu oldukça yaygın bir kullanım şeklidir.
Buraya kadar her şey normal ve basit. Peki ya bulucuA() ve bulucuB() fonksiyonlarının birbiriyle iletişim halinde olması, senkronize çalışması ve hatta birbirlerine veri göndermeleri gerekeseydi? İşte bu durumda Channels’tan (bundan sonra Kanal olarak ifade edeceğim) bahsetmemiz gerekiyor.
Channels (Kanallar)
Go routine’ler pratiktir ancak tek başlarına birbirleriyle iletişim kurmadan, bağımsız olarak ve tamamlandıkları zaman herhangi bir sinyal göndermeden çalışırlar. Sessizce çalışıp işleri bittiğinde kullandıkları kaynakları geri iade ederler. Kanallar sayesinde ise go routine’ler birbirileri ile iletişim kurabilir ve birbirlerine sinyaller göndererek senkronize çalışabilirler. Bir go routine herhangi bir kanala veri gönderebilir veya kanaldan veri alabilir. Bunu yaparken de verinin akış yönünü gösteren ok (<-) kullanılır. Bununla birlikte bir kanal kullanılmaya başlanmadan önce tanımlanmalıdır.
Şimdi yukarıda (Örnek A‘da) go routine’i anlatırken kullandığımız örneği isimsiz iki fonksiyonla kanaldan veri alıp verecek şekilde yeniden düzenleyelim.
Uygulama çıktısı:
Alıcı: Bulucudan Marvel alındı Alıcı: Bulucudan Flash alındı Alıcı: Bulucudan Thanos alındı Alıcı: Bulucudan Flash alındı
Buradaki kodun nasıl çalıştığını anlamak yazımızın en önemli noktası diyebilirim. Bu nedenle yeni bir başlıkla kanalların nasıl çalıştığını, bloke olma durumunu ve bu sayede sağlanan senkronizasyona değinelim.
Senkronizasyon
Kanallarda gönderme ve alma işlemi varsayılan olarak birbirini bloke eder. Peki bu ne demektir? Kanala bir veri gönderildiği zaman kontrol mekanizması (for, if, switch) kanala veri girişinin sağlandığı satırda durur yani bloklanır, ta ki başka bir go routine o kanaldaki veriyi okuyana kadar! Buna benzer olarak kanaldaki veri okunduktan sonra okuma işleminin yapıldığı satırdaki kontrol bloklanır, ta ki başka bir go routine tarafından o kanala veri girişi yapılana kadar. İşte bu bloklama işlemi sayesinde go routine’lerin birbiri ile senkronize çalışması sağlanmış olur.
Yukarıdaki örneğimizde 15 ve 23‘üncü satırlar birbiriyle senkronize olarak çalışır. 15’inci satırda kanala veri girdiği anda kontrol durur ve 23’üncü satırdaki okuma işlemi devreye girer. 23’üncü satırda kanaldaki veri okunduğu anda buradaki kontrol ve go routine durur ve 15’inci satırda yeniden veri girişi yapılması beklenir.
Main Go Routine
Go routine ve kanal konusunu kavradığımızı düşünerek bazı bilinmesi gereken noktalara dikkat çekmek istiyorum. Her go projesinin esas fonksiyonu olan “Main()” başlı başına bir go routine’dir ve hatta kraldır! Main tamamlandığı (return) anda bütün diğer go routine’ler sonlandırılmış olur. Doğal olarak uygulamamızı sağlıklı çalıştırabilmek için main’in diğer bütün go routine’leri beklemesi için ilave tedbir almamız gerekmektedir.
Yukarıdaki örneklerde main fonksiyonunu (yani ana go routine’imizi) bekletmek için aşağıdaki komutla bir channel oluşturup 5 saniye sonra bu kanala veri gönderilmesini sağladık.
<-time.After(time.Second * 5)
Kanalların yazma ve okuma esnasında doğal olarak bloklandıklarını bildiğimiz için main fonksiyonu, oluşturulan kanaldan veriyi alana (read) kadar bekleyecektir. Böylece uygulamamızı çalıştırmak için 5 saniyelik bir süreye sahip oluruz.
Done Channel Kullanımı
Main fonksiyonunun bir go routine olduğunu öğrendik. Bu go routine’i bloklamanın yani programımızın işlevi bitene kadar main’i bekletmenin çok daha uygun yöntemleri vardır. Bunlardan en yaygını ise bir done channel (tamamlandı kanalı) oluşturmaktır. Kimi kaynaklarda flag olarak da isimlendirildiğini görebilirsiniz. Kısacası projenin başında bir done kanal tanımlarsınız. işleminiz tamamlandığında bu kanala veri gönderirsiniz. Bu kanalı da main fonksiyonu okuyacak şekilde yerleştirirseniz siz tamamlandı diyene kadar main fonksiyonu çalışmaya devam edecektir.
func main() { doneChan := make(chan string) go func() { //yapılacak işlemler vs... doneChan <- "İşim bitti" }() <-doneChan // İşim bitti diyene kadar çalış! }
Sender, Receiver ve Direction
Bir kanala veri gönderip alma işlemini kolaylaştırmak için sender ve receiver fonksiyonu yazabiliriz. Böylece bu fonksiyonları tek bir işi yapmaya zorlamış oluruz. Bununla birlikte aşağıda göreceğiniz direction fonksiyonu gibi tek işi bir kanaldaki veriyi alıp başka bir kanala aktaran fonksiyonlar da yazabiliriz.
ilkKanalim := make(chan string, 1) ikinciKanalim := make(chan string, 1) ... sender(ilkKanalim, kahraman) ... bulunan = receiver(bulunan, ilkKanalim) ... direction(ilkKanalim, ikinciKanalim) func sender(channel chan<- string, message string) { channel <- message } func receiver(message string, channel <-chan string) string { message = <-channel return message } func direction(receiver <-chan string, sender chan<- string) { message := <-receiver sender <- message }
Unbuffered ve Buffered Channel
Image from: Trevor Forrey – Learning Go’s Concurrency Through Illustrations
Unbuffered: Bu zamana kadar göstermiş olduğum kanal kullanımı unbuffered kanala örnektir. Bu tür kanalların özelliği sadece bir adet veri muhafaza edebilirler.
Buffered: Buffered kanallarda birden fazla veri tutulabilmektedir. Bir kanalın unbuffered mı yoksa buffered mı olduğu henüz kanalı tanımlarken belirtilir.
bufferedKanal := make(chan string, 100)
Buffered kanallarla ilgili üç önemli notu iletmekte fayda var.
- Birden çok veri almaktadır ancak standart bloklama konusunda herhangi bir değişiklik yoktur. Kanala veri girişi ve çıkışı esnasında bloklama işlemi gerçekleşir.
- Kanala veri girişi esnasında kanalın tamamı dolunca yalnızca yeni veri girişi bloklanır. Kanal boşsa yalnızca okuma yani veri çıkışı işlemi bloklanır.
- FIFO (First-In-First-Out) yaklaşımı burada geçerlidir yani kanala ilk giren veri, okuma tarafında ilk çıkan veridir.
Örnek C
Yukarıdaki buffered kanal örneğini çıktısı aşağıdaki gibidir.
1. Gönderildi 2. Gönderildi 3. Gönderildi Alınıyor... birinci ikinci üçüncü
Kanallarda For / Range Kullanımı
Yukarıdaki örneklerde kanaldaki verileri okurken for döngüsünü 3 defa çağırarak ekrana yazdırdık çünkü kanala 3 defa veri gireceğini biliyorduk. Ancak her zaman kanala gelecek veri miktarını bilemeyiz. İşte böyle durumlarda kanala gelebilecek tüm verileri yazdırmak ve veri kaybetmemek için for / range döngü mekanizmasından faydalanabiliriz. Böylece hiçbir veriyi kaçırmamış oluruz. Bunu daha iyi anlayabilmek için Örnek B’deki alıcı fonksiyonu şu şekilde güncelleyebiliriz:
// Alıcı go func() { for bulunan := range ilkKanalim { fmt.Println("Alıcı: Bulucudan " + bulunan + " alındı") } }()
For / Range yapısında standart bloklama olayı devam etmektedir. Bu bloklamayı kaldırmak için kanalı belirlir bir durumda close(ilkKanalim) şeklinde komut ile kapatabiliriz.
Select Kullanımı
Artık biliyoruz ki bir kanal hem yazma, hem de okuma esnasında bloklama yapar. Peki ya okuma işleminin uygulamayı bloklamasını istemiyorsak? Böyle durumlarda Select / Case mekanizmasını kullanmaktayız. Diğer bir ifadeyle n sayıda goroutine çalıştırdığımızda kanallar ve select ifadesini kullanarak yalnızca işi bitenlerin sonuçlarını almayı başarabiliriz.
Örnek Ç
Bu kodun çıktısı da aşağıdaki gibidir:
mesaj yok mesaj
Kanaldan veri okurken (read) esnasında select kullanabildiğimiz gibi yazma esnasında da aynı işlemi yapabiliriz.
select { case kanalim <- "mesaj": fmt.Println("mesaj gönderildi") default: fmt.Println("mesaj yok") }
Deadlock problemi
Go channels denilince akla gelen en yaygın problem deadlock hatasıdır. Özetlemek gerekirse bir kanala gönderen (sender) kadar okuyucu (reader) atanmaz ise, diğer bir deyişle kapasitesinden fazla veri gönderilirse programımız deadlock problemi ile runtime hatası vererek derlenmez. Tabi bir go rout Örnek bir deadlock hatası şöyle bir programda ortaya çıkabilir.
func main() { kanalim := make(chan string, 2) kanalim <- "Beşiktaş" kanalim <- "Galatasaray" kanalim <- "Fenerbahçe" fmt.Println(<-kanalim) fmt.Println(<-kanalim) }
Bu program aşağıdaki gibi bir hata mesajı vererek çalışmaz!
➜ con go run main.go fatal error: all goroutines are asleep - deadlock! goroutine 1 [chan send]: main.main() /Users/xxx/go/src/con/main.go:11 +0x90 exit status 2
Capacity ve Length
Deadlock sorunu kanalın kapasitesi ile ilgili bir mesele. İşte böyle durumlarda kontrol mekanizması olarak len() ve cap() fonksiyonlarından faydalanabiliriz. len() kanal içindeki mevcut veri miktarını gösterirken, cap() kanalın ilk tanımlandığı esnada belirtilen maksimum alabileceği veri miktarını gösterir.
func main() { kanalim := make(chan string, 3) kanalim <- "Beşiktaş" kanalim <- "Galatasaray" fmt.Println("Kanal kapasitesi: ", cap(kanalim)) fmt.Println("Veri miktarı: ", len(kanalim)) fmt.Println("Okunan veri: ", <-kanalim) fmt.Println("Yeni veri miktarı: ", len(kanalim)) }
Kapasite ve veri miktarını gösterir programın çıktısı aşağıdaki gibidir.
Kanal kapasitesi: 3 Veri miktarı: 2 Okunan veri: Beşiktaş Yeni veri miktarı: 1
Worker Pool (İşçi Havuzu) Kullanımı
Worker Pool bütün dillerde yaygın bir şekilde kullanılan yazılım dizayn paternidir. Genel olarak işçi havuzu, görevlendirilmek üzere bekleyen iş parçacıklarından (işçi de diyebiliriz) oluşur. Uygulama esnasında üzerlerine görev alırlar, bu görevi tamamlar ve yeni görev alabilecek şekilde hazır bekleyişe geçerler. Worker pool dizaynı Go diline go routine ve kanallar ile uyarlanır. Aşağıda Go By Example’daki örneği paylaşarak konuyu özetleyelim. Yorum satırlarında Türkçe olarak workerların çalışma şeklini anlatmaya çalıştım.
Son Sözler
Go’nun en gözde konularından biri olan Concurrency hakkında ihtiyaç duyabileceğiniz her konuya değinmeye çalıştım. Ancak buna rağmen atladıklarım ya da merak ettikleriniz varsa ve yorumlarınızda belirtirseniz yazıya mutlaka eklemek isterim. Diğer Go yazılarıma da Golang kategorisinden ulaşabilirsiniz ;)