hkucuk

Go'da Concurrency: Channels, Goroutines ve Senkronizasyon Mekanizmaları

23 Kasım 2024 • ☕️☕️ 10 dk okuma • 🏷 bilgisayar, yazılım, algoritma

Yazar tarafından şu dillere çevrildi: English


Go (Golang), Google tarafından 2007 yılında tasarlanmaya başlayan ve 2009’da açık kaynak olarak yayınlanan, özellikle yüksek performanslı ve ölçeklenebilir yazılımlar geliştirmeye odaklanmış modern bir programlama dilidir. Basit ve anlaşılır sözdizimi sayesinde hem deneyimli hem de yeni başlayan geliştiriciler tarafından kolayca öğrenilebilir. Aynı zamanda, hafif ve hızlı bir şekilde derlenebilen bir dil olduğu için C benzeri bir performans elde edilebilir.

Go’nun en güçlü özelliklerinden biri de dil seviyesinde gömülü olarak gelen concurrency (eşzamanlılık) desteğidir. Concurrency kavramı, modern uygulamalarda yüksek verimlilik ve ölçeklenebilirlik sağlamak açısından kritik öneme sahiptir. Go’da concurrency kullanımını mümkün kılan temel yapı taşı olan goroutine’ler, geleneksel iş parçacıklarına (thread) göre çok daha hafif ve kolay yönetilebilir bir yaklaşım sunar. Ayrıca, eşzamanlı çalışan parçaların kendi aralarındaki iletişim ve senkronizasyonunu basitleştirmek için channel gibi mekanizmalar sağlanır. Bu sayede, paylaşımlı veri yönetimi yerine “iletişim kurarak paylaşım” yaklaşımına dayanan güvenilir ve temiz bir tasarım uygulanabilir.

Go'da Concurrency


1. Concurrency Nedir? Parallelism ile Farkları

Öncelikle sıkça karıştırılan iki terimi ayırt etmekte fayda var: concurrency ve parallelism.

  • Concurrency (Eşzamanlılık): Bir programın birden fazla işi aynı zaman aralığında ilerletebilme (interleaving) yeteneğidir. İşler birbiri ardına kısa zaman dilimlerinde parçalar halinde çalıştırılır ve bu şekilde kullanıcıya aynı anda birden fazla işin yürütüldüğü hissi verilir. İşletim sistemi çekirdeği veya dilin çalışma zamanı (runtime), hangi işin ne zaman çalıştırılacağına (schedule) karar verir.
  • Parallelism (Paralellik): Fiziksel olarak aynı anda birden fazla işin yürütülmesidir. Örneğin çok çekirdekli bir CPU’da, birden fazla çekirdek aynı anda farklı işlemler üzerinde çalışabilir.

Go concurrency’i çok kolay bir şekilde kullanılabilir hale getirir ancak bu concurrency özelliği aynı makinede her zaman “paralel” çalışacağı anlamına gelmez. Go runtime, hangi goroutine’in hangi çekirdekte veya ne zaman çalıştırılacağını belirler. Doğru şekilde konfigüre edildiğinde ve çok çekirdekli bir işlemci üzerinde çalıştığında, Go kodunuz hem concurrency hem de parallelism sağlayabilir.

2. Goroutine Nedir?

Go’da concurrency’nin temel yapı taşı goroutine’lerdir. Goroutine, Go runtime içinde çalışan hafif (lightweight) bir iş parçacığı olarak düşünülebilir. Go’da bir goroutine başlatmak oldukça basittir. Herhangi bir fonksiyonun başına go anahtar kelimesi getirerek o fonksiyonu ayrı bir goroutine’de çalıştırabilirsiniz.

package main

import (
    "fmt"
    "time"
)

func helloWorld() {
    fmt.Println("Hello World!")
}

func main() {
    // Run the helloWorld function in a new goroutine
    go helloWorld()

    // Let's wait a bit in the main goroutine so that the other goroutine can run as well.
    time.Sleep(time.Second)
    fmt.Println("The main goroutine has ended.")
}

Yukarıdaki örnekte helloWorld() fonksiyonunu go helloWorld() ifadesiyle çağırdığımızda, bu fonksiyon ana goroutine’den bağımsız bir şekilde çalışmaya başlar. Ana goroutine sona erer ermez, program sonlanacağı için diğer goroutine’in de çalışmasına imkân kalmaz. Bu nedenle, küçük de olsa bir time.Sleep() ekleyerek diğer goroutine’in sonuç üretmesini bekliyoruz.

Önemli Not: Gerçek projelerde bekleme için time.Sleep() kullanmak yerine, genellikle senkronizasyon mekanizmalarından biri olan sync.WaitGroup veya benzer yapıları kullanmak daha doğru bir yaklaşımdır.


3. Channel’lar (Kanallar)

Channel, Go’da goroutine’ler arasında veri aktarımını güvenli ve senkronize bir şekilde yapmaya yarayan veri yapılarıdır. Channel’ların en büyük avantajı, paylaşılan değişkenler kullanarak kilit (lock) yönetimi yapmak yerine, verinin kendisini goroutine’ler arasında transfer etmeye olanak vermesidir. Bu yaklaşım “paylaşma yerine iletişim” (communicating by sharing vs. sharing by communicating) felsefesinin güzel bir örneğidir.

3.1 Kanal Oluşturma ve Veri Gönderme/Alma

Bir kanal aşağıdaki gibi oluşturulur:

ch := make(chan int) // Creates a channel of type int (unbuffered channel)

Veri gönderimi ve alımı <- operatörü ile gerçekleştirilir:

  • Gönderim: ch <- x
  • Alım: y := <-ch

Örnek bir kullanım:

package main

import (
    "fmt"
)

func main() {
    ch := make(chan string)

    go func() {
        // Sending data
        ch <- "Data from Goroutine"
    }()

    // Receiving data
    data := <-ch
    fmt.Println(data)
}

Yukarıdaki örnekte, isimlendirilmemiş (anonymous) bir fonksiyonu goroutine olarak çalıştırıyoruz ve bu fonksiyon, ch kanalına bir string gönderiyor. Ana goroutine ise bu string’i data := <-ch ifadesiyle okuyor.

3.2 Buffered Channels

Standart olarak oluşturulan channel’lar “unbuffered” (tamponlanmamış) olarak adlandırılır ve gönderici ile alıcının aynı anda işlemi gerçekleştirmesini bekler. Yani kanalın her gönderimi bir alıcı tarafından okunana kadar bloklanır.

Eğer belirli bir kapasiteye sahip bir kanal oluşturursanız, bu bir “buffered channel” olur. Bu kanal belirli sayıda mesajı okuyucu olmadan da tutabilir.

ch := make(chan int, 5) // A buffered channel with a capacity of 5

Bu durumda ch kanalına en fazla 5 adet int değerini alıcı beklemese bile gönderebilirsiniz. Ancak 6. gönderim, kapasite dolduğu için, bir değer alana kadar bloklanacaktır.

3.3 Kanalın Kapatılması (close)

Kanalı close(ch) fonksiyonu ile kapatabilirsiniz. Kapatılan bir kanala tekrar veri gönderilmeye çalışılırsa “panic” oluşur. Kapalı kanaldan veri okumaya devam edebilirsiniz, ancak okunabilecek veri kalmadığında “zero value” (int için 0, string için "" vb.) dönecektir.

package main

import (
    "fmt"
)

func main() {
    ch := make(chan int, 3)
    ch <- 10
    ch <- 20
    close(ch)

    // Since the channel is closed, we can continue to read data, 
	// but as soon as we try to write, a panic occurs.
    for val := range ch {
        fmt.Println(val)
    }
}

Yukarıdaki örnekte range ifadesi kapatılmış kanaldaki mevcut tüm değerleri okur, yeni veri olmadığında döngüyü sonlandırır.


4. Senkronizasyon Mekanizmaları

Concurrency ile ilgilenirken birden fazla goroutine’in aynı kaynağa erişmesi, paylaşımlı bir veriyi güncellemesi veya okuması gerekebilir. Bu durumlarda veri bütünlüğünü (data consistency) korumak için senkronizasyon yöntemlerine ihtiyaç duyarız.

4.1 sync.WaitGroup

Birden fazla goroutine’in tamamlanmasını beklemek için kullanılan en temel yapı sync.WaitGroup‘dır. Aşağıdaki örnekte, üç farklı fonksiyonu üç ayrı goroutine’de çalıştırıyoruz ve hepsinin bitmesini WaitGroup kullanarak bekliyoruz:

package main

import (
    "fmt"
    "sync"
    "time"
)

func doWork(num int, wg *sync.WaitGroup) {
    defer wg.Done() // When the function finishes, the counter is decremented by calling Done().
    fmt.Printf("Goroutine %d started\n", num)
    time.Sleep(time.Millisecond * 500) // For example, let's wait 500 ms
    fmt.Printf("Goroutine %d finished\n", num)
}

func main() {
    var wg sync.WaitGroup
    toplamGoroutine := 3
    wg.Add(toplamGoroutine) // We are waiting for 3 goroutines

    for i := 1; i <= toplamGoroutine; i++ {
        go doWork(i, &wg)
    }

    // Waits until all goroutines are finished
    wg.Wait()

    fmt.Println("All goroutines completed")
}

Yukarıda:

  • wg.Add(n) ile bekleyeceğimiz goroutine sayısını belirtiyoruz.
  • Her goroutine’in içinde defer wg.Done() diyerek, goroutine’in işi bittiğinde WaitGroup sayacını 1 azaltmasını sağlıyoruz.
  • wg.Wait() ifadesi, tüm goroutine’lerin (add ile eklenen) tamamlanmasını bekler.

4.2 sync.Mutex ve sync.RWMutex

Paylaşımlı veriye aynı anda erişildiği durumlarda veri tutarlılığı sağlamak için sync.Mutex (mutual exclusion lock) kullanılır. Bir “mutex” aynı anda yalnızca bir goroutine’in paylaşımlı kaynağa erişmesine izin verir.

package main

import (
    "fmt"
    "sync"
)

type SafeCounter struct {
    mu    sync.Mutex
    count int
}

func (sc *SafeCounter) Increment() {
    sc.mu.Lock()
    defer sc.mu.Unlock()
    sc.count++
}

func (sc *SafeCounter) Value() int {
    sc.mu.Lock()
    defer sc.mu.Unlock()
    return sc.count
}

func main() {
    var wg sync.WaitGroup
    sc := SafeCounter{}

    goroutineCount := 5
    wg.Add(goroutineCount)

    for i := 0; i < goroutineCount; i++ {
        go func() {
            defer wg.Done()
            for j := 0; j < 1000; j++ {
                sc.Increment()
            }
        }()
    }

    wg.Wait()
    fmt.Println("Total:", sc.Value())
}
  • Increment() ve Value() metodlarında Lock() ve Unlock() ile verilere tek seferde sadece bir goroutine’in erişmesini sağlıyoruz. Böylece veri tutarlılığı korunuyor.
  • RWMutex, okuma-yazma kilidi (read-write lock) olup, birden çok okuyucu goroutine’e aynı anda izin verir ancak bir yazıcı girince sadece yazıcıya izin verir. Böylece genellikle ağırlıklı olarak okuma işlemlerinin yapıldığı durumlarda performans kazandırır.

4.3 sync.Once

Bazen belirli bir fonksiyonun veya işlemin program boyunca yalnızca bir kez çalışmasını isteyebilirsiniz. Bu durum için sync.Once yapısı kullanılır. Özellikle konfigürasyon, cache inicializasyonu gibi işlemler sadece bir kez yapılmak isteniyorsa oldukça faydalıdır.

package main

import (
    "fmt"
    "sync"
)

var once sync.Once

func initConfig() {
    fmt.Println("The configuration is started.")
}

func main() {
    var wg sync.WaitGroup
    wg.Add(3)

    for i := 0; i < 3; i++ {
        go func(id int) {
            defer wg.Done()
            once.Do(initConfig)
            fmt.Printf("Goroutine %d is running.\n", id)
        }(i)
    }

    wg.Wait()
}

Bu örnekte initConfig() fonksiyonu kaç tane goroutine çağırırsa çağırsın yalnızca bir kez çalıştırılacaktır.


5. Concurrency’de Dikkat Edilmesi Gerekenler

  1. Data Race (Veri Yarışı): Birden fazla goroutine aynı değişkeni aynı anda okuyup yazmaya çalışırsa veri yarışı oluşur. Bu durumda beklenmedik sonuçlar alabilirsiniz. go run -race main.go komutu ile Go programınızda veri yarışı test edebilirsiniz.
  2. Kilitlerin Unutulması: Mutex kullanırken kilidi açmayı (unlock) unutursanız, diğer goroutine’ler sonsuza kadar bekleyebilir. Bu nedenle defer mu.Unlock() gibi yaklaşımlar önerilir.
  3. Ölçeklenebilirlik: Goroutine’ler çok hafif olsa da, aşırı sayıda goroutine kullanımı sistem kaynaklarının tükenmesine neden olabilir. Milyonlarca goroutine başlatmak teoride mümkün olsa da pratikte göz önünde bulundurulması gereken bellek ve işlemci limitleri vardır.
  4. Kapanmamış Kanallar: Özellikle uzun ömürlü servislerde, kanal kullanımını dikkatli yönetmek gerekir. İhtiyaç kalmayan kanallar doğru zamanda kapatılmazsa bekleyen goroutine’ler bloklanabilir veya “goroutine leak” (sızıntı) yaşanabilir.
  5. Zamanlama ve Yarış Koşulları: Concurrency, karmaşıklığı artırır. Kodun hangi sırayla çalışacağını tam olarak kestirmek güçtür. Bu nedenle test stratejileri, logging ve bellek izleme (profiling) önemlidir.

6. Örnek: İşleyiciler (Worker) ve Kanal Tabanlı Sıra

Aşağıda, 10 adet işi işlemesi için 3 adet “worker” (işleyici) goroutine başlatan, işleri bir kanaldan okuyan ve işleyen örnek bir kod bulunuyor:

package main

import (
    "fmt"
    "sync"
    "time"
)

// Task represents an item or job to be processed
type Task struct {
    ID int
}

// worker function processes tasks from the channel
func worker(id int, taskChan <-chan Task, wg *sync.WaitGroup) {
    defer wg.Done()
    for task := range taskChan {
        fmt.Printf("Worker %d is processing task %d...\n", id, task.ID)
        // Simulate some work
        time.Sleep(time.Millisecond * 100)
        fmt.Printf("Worker %d has completed task %d.\n", id, task.ID)
    }
}

func main() {
    taskCount := 10
    workerCount := 3

    // Create a channel to send tasks
    taskChan := make(chan Task, taskCount)

    // A WaitGroup will ensure that all tasks and workers finish
    var wg sync.WaitGroup

    // Start 3 workers
    for i := 1; i <= workerCount; i++ {
        wg.Add(1)
        go worker(i, taskChan, &wg)
    }

    // Send 10 tasks to the channel
    for i := 1; i <= taskCount; i++ {
        taskChan <- Task{ID: i}
    }

    // Close the channel once all tasks have been sent
    close(taskChan)

    // Wait for all workers to finish
    wg.Wait()
    fmt.Println("All tasks have been processed, the program is now terminating.")
}
  • worker fonksiyonu kendisine gelen taskChan‘dan görevleri alarak (range ile) işliyor. Kanal kapatıldığında range döngüsü sonlanıyor ve fonksiyon çıkıyor. Ana fonksiyonda wg.Add(1) yaparak 3 worker başlatılıyor. Her worker tamamlandığında wg.Done() çağırıyor. Görevler tamamlandığında, program sonlanıyor.

Program çalıştırıldığında çıktısı aşağıdaki gibi olacaktır.

Worker 3 is processing task 1...
Worker 1 is processing task 2...
Worker 2 is processing task 3...
Worker 2 has completed task 3.
Worker 2 is processing task 4...
Worker 3 has completed task 1.
Worker 3 is processing task 5...
Worker 1 has completed task 2.
Worker 1 is processing task 6...
Worker 1 has completed task 6.
Worker 1 is processing task 7...
Worker 2 has completed task 4.
Worker 2 is processing task 8...
Worker 3 has completed task 5.
Worker 3 is processing task 9...
Worker 2 has completed task 8.
Worker 2 is processing task 10...
Worker 3 has completed task 9.
Worker 1 has completed task 7.
Worker 2 has completed task 10.
All tasks have been processed, the program is now terminating.

Programın çalışır haline şuradan erişilebilir.

Bu yaklaşım, “worker pool” (işleyici havuzu) olarak bilinir ve genellikle büyük miktarda işi, belirli sayıda eşzamanlı çalışan işleyici ile paylaşmak için idealdir. Böylece kaynak kullanımını düzenler ve performans ile ölçeklenebilirlik sağlar.


Go basitlik ve verimlilik odaklı bir dil olarak concurrency konusunu dil seviyesinde mükemmel şekilde desteklemektedir.

  • Goroutine‘ler, sistem iş parçacıklarına (thread) göre çok daha hafif ve kolay yönetilebilir yapılardır.
  • Channel‘lar, veriyi paylaşmak yerine mesajlaşma paradigmasını benimser ve goroutine’ler arası iletişimi güvenli hale getirir.
  • sync paketi (WaitGroup, Mutex, RWMutex, Once gibi yapılar) concurrency senaryolarında yaygın kullanılan senkronizasyon mekanizmalarını içerir.

Bu araçları doğru ve bilinçli kullanarak, yüksek performanslı ve ölçeklenebilir Go uygulamaları geliştirebilirsiniz. Ancak concurrency, beraberinde karmaşıklığı da getirir. Veri yarışı (race condition), kilitlenme (deadlock) gibi sorunlar çıkmaması için kodunuzu dikkatle tasarlamalı, test etmeli ve gerekli profil (profiling) araçlarını kullanmalısınız.

Unutmayın: Concurrency doğru kullanıldığında çok güçlü bir araçtır; yanlış kullanıldığında ise hataları takip etmesi ve gidermesi zor bir kabusa dönüşebilir. Go’nun basit concurrency modeli ve zengin standard kütüphanesi, bu yolda büyük kolaylıklar sunuyor.


Kaynaklar