1. Senkronizasyon mekanizmalarının rolü

Eşzamanlı programlamada, birden fazla gorutinin kaynakları paylaştığı durumlarda, yarış koşullarını önlemek için kaynaklara yalnızca bir gorutinin aynı anda erişmesini sağlamak gerekir. Bu durum senkronizasyon mekanizmalarının kullanılmasını gerektirir. Senkronizasyon mekanizmaları, farklı gorutinlerin paylaşılan kaynaklara erişim sırasını koordine edebilir, eşzamanlı ortamda veri tutarlılığını ve durum senkronizasyonunu sağlayabilir.

Go dilinin sunduğu senkronizasyon mekanizmaları arasında şunlar bulunur (ancak bunlarla sınırlı değildir):

  • Mutex'ler (sync.Mutex) ve okuma-yazma mutex'leri (sync.RWMutex)
  • Kanallar (Channels)
  • WaitGroup'lar
  • Atomik fonksiyonlar (atomic paketi)
  • Koşul değişkenleri (sync.Cond)

2. Senkronizasyon primitifleri

2.1 Mutex (sync.Mutex)

2.1.1 Mutex'un kavramı ve rolü

Mutex, paylaşılan kaynaklara yalnızca bir gorutinin aynı anda kilit alarak güvenli erişimini sağlayan bir senkronizasyon mekanizmasıdır. Mutex, Lock ve Unlock yöntemleri aracılığıyla senkronizasyon sağlar. Lock yöntemini çağırmak, kilidin serbest bırakılana kadar bloke olacak ve bu noktada kilidi almaya çalışan diğer gorutinler bekleyecektir. Unlock çağrısı ise kilidi serbest bırakarak, diğer bekleyen gorutinlerin onu almasını sağlar.

var mu sync.Mutex

func criticalSection() {
    // Kaynağa özel erişim için kilidi al
    mu.Lock()
    // Burada paylaşılan kaynağa eriş
    // ...
    // Diğer gorutinlerin kilidi almasına izin vermek için kilidi serbest bırak
    mu.Unlock()
}

2.1.2 Mutex'un pratik kullanımı

Global bir sayacı sürdürmemiz gerektiğini varsayalım ve birden fazla gorutinin değerini artırması gerekiyor. Bir mutex kullanarak sayaçın doğruluğunu sağlayabiliriz.

var (
    mu      sync.Mutex
    counter int
)

func increment() {
    mu.Lock()         // Sayağı değiştirmeden önce kilitlen
    counter++         // Sayağı güvenli bir şekilde artır
    mu.Unlock()       // İşlemden sonra kilidi serbest bırakarak, diğer gorutinlerin sayaca erişmesine izin ver
}

func main() {
    for i := 0; i < 10; i++ {
        go increment()  // Sayaç değerini artırmak için birden fazla gorutin başlat
    }
    // Bir süre bekle (uygulamada, tüm gorutinlerin tamamlanmasını sağlamak için WaitGroup veya diğer yöntemleri kullanmalısın)
    time.Sleep(1 * time.Second)
    fmt.Println(counter)  // Sayaç değerini yazdır
}

2.2 Okuma-Yazma Mutex (sync.RWMutex)

2.2.1 Okuma-yazma mutex kavramı

RWMutex, birden çok gorutinin aynı anda paylaşılan kaynakları okumasına izin verirken, yazma işlemleri özeldir. Mutex'lere göre, okuma-yazma kilitleri çoklu okuyucu senaryolarında performansı artırabilir. RLock, RUnlock (okuma işlemleri için kilitleme ve kilidi serbest bırakma), Lock, Unlock (yazma işlemleri için kilitleme ve kilidi serbest bırakma) olmak üzere dört yönteme sahiptir.

2.2.2 Okuma-Yazma Mutex'un Pratik Kullanım Senaryoları

Bir veritabanı uygulamasında, okuma işlemleri yazma işlemlerinden çok daha sık olabilir. Okuma-yazma kilidi kullanmak, çoklu gorutinlerin aynı anda okumasına izin vererek sistem performansını artırabilir.

var (
    rwMu  sync.RWMutex
    data  int
)

func readData() int {
    rwMu.RLock()         // Okuma kilidini al, diğer okuma işlemlerinin aynı anda devam etmesini sağla
    defer rwMu.RUnlock() // Kilidin defer kullanılarak serbest bırakılmasını sağla
    return data          // Veriyi güvenli bir şekilde oku
}

func writeData(newValue int) {
    rwMu.Lock()          // Yazma kilidini al, bu sırada diğer okuma ya da yazma işlemlerini engelle
    data = newValue      // Yeni değeri güvenli bir şekilde yaz
    rwMu.Unlock()        // Yazma işlemi tamamlandıktan sonra kilidi serbest bırak
}

func main() {
    go writeData(42)     // Yazma işlemi gerçekleştirmek için bir gorutin başlat
    fmt.Println(readData()) // Ana gorutin bir okuma işlemi gerçekleştirir
    // Tüm gorutinlerin tamamlandığından emin olmak için WaitGroup veya diğer senkronizasyon yöntemlerini kullan
}

Yukarıdaki örnekte, birden çok okuyucu işlevi (readData) aynı anda yürütülebilir, ancak writeData işlevini yürüten yazıcı yeni okuyucuları ve diğer yazıcıları engeller. Bu mekanizma, yazmaktan çok okumaların olduğu senaryolarda performans avantajı sağlar.

2.3 Koşullu Değişkenler (sync.Cond)

2.3.1 Koşullu Değişkenlerin Kavramı

Go dilinin senkronizasyon mekanizmasında, koşullu değişkenler bir senkronizasyon ögesi olarak belirli bir koşul değişikliğini beklemek veya bildirmek için kullanılır. Koşullu değişkenler her zaman koşulu kendisi için tutarlılığı korumak için kullanılan bir mutex (sync.Mutex) ile birlikte kullanılır.

Koşullu değişkenlerin kavramı işletim sistemi alanından gelir ve belirli bir koşulun karşılanmasını bekleyen bir grup gorutininin olmasına izin verir. Daha spesifik olarak, bir gorutini belirli bir koşulun karşılanmasını beklerken yürütmesini duraklatabilir ve başka bir gorutini koşullu değişkeni kullanarak koşulu değiştirdikten sonra diğer gorutinleri uyarmak için yürütmesi yeniden devam ettirebilir.

Go standart kütüphanesinde, koşullu değişkenler sync.Cond türü aracılığıyla sağlanır ve temel metodları şunları içerir:

  • Wait: Bu metodu çağırma, tutulan kilidi serbest bırakacak ve diğer bir gorutinin aynı koşullu değişkeni üzerinde Signal veya Broadcast çağırarak uyandırmasını bekleyecektir, ardından tekrar kilidi almaya çalışacaktır.
  • Signal: Bu koşullu değişken için bekleyen bir gorutini uyandırır. Eğer bekleyen bir gorutin yoksa, bu metodu çağırmak etkili olmayacaktır.
  • Broadcast: Bu koşullu değişken için bekleyen tüm gorutinleri uyandırır.

Koşullu değişkenler kopyalanmamalıdır, bu nedenle genellikle belirli bir yapıının işaretçi alanı olarak kullanılırlar.

2.3.2 Koşullu Değişkenlerin Pratik Kullanımı

İşte koşullu değişkenleri kullanan basit bir üretici-tüketici modelini gösteren bir örnek:

package main

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

// GüvenliKuyruk, mutex ile korunan güvenli bir kuyruktur
type SafeQueue struct {
    mu    sync.Mutex
    cond  *sync.Cond
    queue []interface{}
}

// Enqueue, bir öğeyi kuyruğun sonuna ekler ve bekleyen gorutinleri bildirir
func (sq *SafeQueue) Enqueue(item interface{}) {
    sq.mu.Lock()
    defer sq.mu.Unlock()

    sq.queue = append(sq.queue, item)
    sq.cond.Signal() // Kuyruğun boş olmadığını bekleyen gorutinleri bildirir
}

// Dequeue, kuyruk boşsa beklerken kuyruğun başından bir öğeyi kaldırır
func (sq *SafeQueue) Dequeue() interface{} {
    sq.mu.Lock()
    defer sq.mu.Unlock()

    // Kuyruk boş olduğunda bekle
    for len(sq.queue) == 0 {
        sq.cond.Wait() // Koşul değişikliğini bekler
    }

    item := sq.queue[0]
    sq.queue = sq.queue[1:]
    return item
}

func main() {
    queue := make([]interface{}, 0)
    sq := SafeQueue{
        mu:    sync.Mutex{},
        cond:  sync.NewCond(&sync.Mutex{}),
        queue: queue,
    }

    // Üretici Gorutini
    go func() {
        for i := 0; i < 5; i++ {
            time.Sleep(1 * time.Second)         // Üretim süresini simüle et
            sq.Enqueue(fmt.Sprintf("madde%d", i)) // Bir öğe üret
            fmt.Println("Üretim:", i)
        }
    }()

    // Tüketici Gorutini
    go func() {
        for i := 0; i < 5; i++ {
            item := sq.Dequeue() // Bir öğe tüket, kuyruk boşsa bekle
            fmt.Printf("Tüketim: %v\n", item)
        }
    }()

    // Tüm üretim ve tüketimin tamamlanmış olduğundan emin olmak için yeterli süre bekle
    time.Sleep(10 * time.Second)
}

Bu örnekte, içinde bir kuyruk ve bir koşullu değişken bulunan bir SafeQueue yapılandırması tanımladık. Tüketici Dequeue metodunu çağırdığında ve kuyruk boşsa, Wait metodu kullanılarak bekler. Üretici Enqueue metodunu kullanarak yeni bir öğe eklediğinde, bekleyen tüketiciyi uyandırmak için Signal metodunu kullanır.

2.4 WaitGroup

2.4.1 WaitGroup'ın Kavramı ve Kullanımı

sync.WaitGroup, bir grup gorutininin tamamlanmasını beklemek için kullanılan bir senkronizasyon mekanizmasıdır. Bir gorutin başlattığınızda, Add metoduyla sayaç artırılabilir ve her bir gorutin görevini tamamladığında Done metodu çağrılabilir (Add(-1) gerçekte gerçekleşir) ve ana gorutin, sayaç 0'a ulaşana kadar beklemek için Wait metodunu kullanabilir, bu da tüm gorutinlerin görevlerini tamamladığını gösterir.

WaitGroup kullanılırken şu noktalar dikkate alınmalıdır:

  • Add, Done ve Wait metodları thread-safe değildir ve birden fazla gorutinde aynı anda çağrılmamalıdır.
  • Add metodu, yeni oluşturulan gorutin başlamadan önce çağrılmalıdır.

2.4.2 WaitGroup'ın Pratik Kullanım Senaryoları

WaitGroup'ın kullanımına dair bir örnek aşağıda verilmiştir:

package main

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

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done() // Tamamlanınca WaitGroup'a haber ver

	fmt.Printf("%d numaralı işçi çalışmaya başladı\n", id)
	time.Sleep(time.Second) // Zaman alıcı bir işlemi simüle et
	fmt.Printf("%d numaralı işçi işini bitirdi\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 5; i++ {
		wg.Add(1) // Gorutin başlamadan önce sayacı artır
		go worker(i, &wg)
	}

	wg.Wait() // Tüm işçi gorutinlerinin tamamlanmasını bekle
	fmt.Println("Tüm işçiler işlerini bitirdi")
}

Bu örnekte, worker fonksiyonu bir görevin yürütülmesini taklit eder. Main fonksiyonunda, beş worker gorutinini başlatıyoruz. Her gorutini başlamadan önce, yeni bir görevin yürütüldüğünü WaitGroup'a bildirmek için wg.Add(1) çağrısını yapıyoruz. Her işçi fonksiyonu tamamladığında, defer wg.Done() çağrısını yaparak WaitGroup'a görevin tamamlandığını bildirir. Tüm gorutinleri başlattıktan sonra, main fonksiyonu wg.Wait()'te tüm işçiler tamamlanana kadar bloke olur.

2.5 Atomik İşlemler (sync/atomic)

2.5.1 Atomik İşlemlerin Kavramı

Atomik işlemler, eşzamanlı programlamada yürütme sırasında diğer işlemler tarafından kesintiye uğramayan, yani bölünemez olan işlemleri ifade eder. Birden fazla gorutin için atomik işlemler kullanılarak, atomik işlemler verinin tutarlılığını ve durum senkronizasyonunu kilitleme ihtiyacı olmadan sağlayabilir. Go dilinde, sync/atomic paketi düşük seviyeli atomik bellek işlemleri sağlar. int32, int64, uint32, uint64, uintptr ve pointer gibi temel veri tipleri için, sync/atomic paketinden gelen yöntemler güvenli eşzamanlı işlemler için kullanılabilir. Atomik işlemlerin önemi, diğer eşzamanlı ilkeleri (kilitleme ve durum değişkenleri gibi) oluşturmak için temel olmaları ve genellikle kilitleme mekanizmalarından daha verimli olmalarındadır.

2.5.2 Atomik İşlemlerin Pratik Kullanım Senaryoları

Web sitesine aynı anda giriş yapan ziyaretçi sayısını takip etmemiz gereken bir senaryoyu düşünelim. Basit bir sayaç değişkenini kullanarak, bir ziyaretçi geldiğinde sayaçı artırabilir ve bir ziyaretçi gittiğinde azaltabiliriz. Ancak, eşzamanlı bir ortamda, bu yaklaşım veri yarışlarına yol açacaktır. Bu nedenle, güvenli bir şekilde sayaç değişkenini değiştirmek için sync/atomic paketini kullanabiliriz.

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

var ziyaretciSayisi int32 // Visitor sayısını tutan değişken

func ziyaretciSayisiniArtir() {
	atomic.AddInt32(&ziyaretciSayisi, 1)
}

func ziyaretciSayisiniAzalt() {
	atomic.AddInt32(&ziyaretciSayisi, -1)
}

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			ziyaretciSayisiniArtir()
			time.Sleep(time.Second) // Ziyaretçinin ziyaret süresi
			ziyaretciSayisiniAzalt()
			wg.Done()
		}()
	}
	wg.Wait()
	fmt.Printf("Mevcut ziyaretçi sayısı: %d\n", ziyaretciSayisi)
}

Bu örnekte, 100 gorutin oluşturarak ziyaretçilerin gelişini ve ayrılışını taklit ediyoruz. atomic.AddInt32() fonksiyonunu kullanarak, sayaçın artırılması ve azaltılmasını atomik hale getiriyoruz, böylece yoğun eş zamanlı durumlarda bile ziyaretciSayisi'nın doğruluğunu sağlıyoruz.

2.6 Kanal Senkronizasyon Mekanizması

2.6.1 Kanalların Senkronizasyon Özellikleri

Kanallar, Go dilinde gorutinlerin dil düzeyinde iletişim kurmasını sağlar. Bir kanal, veri gönderme ve almayı sağlar. Bir gorutin bir kanaldan veri okumaya çalıştığında ve kanalda veri yoksa, veri gelene kadar bloklanır. Benzer şekilde, kanal doluysa (kapasitesi olmayan bir kanal için bu, zaten veri bulundurduğu anlamına gelir), veri göndermeye çalışan gorutin de veri yazılacak boşluğa kadar bloklanır. Bu özellik, kanalları gorutinler arasındaki senkronizasyon için çok kullanışlı hale getirir.

2.6.2 Kanallarla Senkronizasyon Kullanım Senaryoları

Birden fazla gorutinin işlemesini gerektiren ve tüm alt görevlerin sonuçlarını toplamamız gereken bir görevimiz olduğunu varsayalım. Tüm gorutinlerin tamamlanmasını beklemek için bir kanal kullanabiliriz.

package main

import (
    "fmt"
    "sync"
)

func worker(id int, wg *sync.WaitGroup, resultChan chan<- int) {
    defer wg.Done()
    // Bazı operasyonlar gerçekleştir...
    fmt.Printf("Çalışan %d başlıyor\n", id)
    // Alt görevin sonucunun çalışanın kimliği olduğunu varsayalım
    resultChan <- id
    fmt.Printf("Çalışan %d tamamlandı\n", id)
}

func main() {
    var wg sync.WaitGroup
    numWorkers := 5
    resultChan := make(chan int, numWorkers)

    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go worker(i, &wg, resultChan)
    }

    go func() {
        wg.Wait()
        close(resultChan)
    }()

    // Tüm sonuçları topla
    for result := range resultChan {
        fmt.Printf("Sonuç alındı: %d\n", result)
    }
}

Bu örnekte, 5 adet görevi gerçekleştirmek için 5 farklı gorutin başlatıyoruz ve sonuçları resultChan kanalı aracılığıyla topluyoruz. Ana gorutin, tüm işlerin ayrı bir gorutin içinde tamamlanmasını bekler ve ardından sonuç kanalını kapatır. Sonrasında ana gorutin, resultChan kanalını dolaşarak tüm gorutinlerin sonuçlarını toplar ve yazdırır.

2.7 Tek Seferlik Yürütme (sync.Once)

sync.Once, bir işlemin programın yürütme süresince yalnızca bir kez yürütülmesini sağlayan bir senkronizasyon aracıdır. sync.Once'ın tipik bir kullanımı, bir singleton nesnesinin başlatılmasında veya gecikmeli başlatma gerektiren senaryolarda gerçekleşir. Bu işlemi kaç gorutin çağırırsa çağırsın, yalnızca bir kez çalışacaktır, bu nedenle Do fonksiyonunun adı budur.

sync.Once, aynı işlemi tekrarlayan başlatmaların yol açabileceği performans sorunlarına yönelik kaygıları ortadan kaldırarak, çoklu iş parçacığı sorunlarını ve yürütme verimliliğini mükemmel bir şekilde dengelemektedir.

sync.Once'ın kullanımını göstermek için basit bir örnek:

package main

import (
    "fmt"
    "sync"
)

var once sync.Once
var instance *Singleton

type Singleton struct{}

func Instance() *Singleton {
    once.Do(func() {
        fmt.Println("Tek bir örnek oluşturuluyor.")
        instance = &Singleton{}
    })
    return instance
}

func main() {
    for i := 0; i < 10; i++ {
        go Instance()
    }
    fmt.Scanln() // Çıktıyı görmek için bekleyin
}

Bu örnekte, Instance fonksiyonu birden fazla kez eş zamanlı olarak çağrılsa bile, Singleton örneğinin oluşturulması yalnızca bir kez gerçekleşir. Sonraki çağrılar, ilk kez oluşturulan singleton örneğini doğrudan döndürerek, örneğin benzersizliğini garanti eder.

2.8 ErrGroup

ErrGroup, Go dilinde çoklu gorutinleri senkronize etmek ve hatalarını toplamak için kullanılan bir kütüphanedir. "golang.org/x/sync/errgroup" paketinin bir parçası olan ErrGroup, eş zamanlı işlemlerde hata senaryolarını ele almanın özgün bir yolunu sunar.

2.8.1 ErrGroup Kavramı

ErrGroup'un temel fikri, ilişkili görevlerin bir grubunu (genellikle eş zamanlı olarak gerçekleştirilen) bir araya getirmek ve bu görevlerden biri başarısız olursa tüm grubun yürütmesini iptal etmektir. Aynı zamanda, bu eş zamanlı operasyonlardan herhangi biri bir hata döndürürse, ErrGroup bu hatayı yakalayacak ve bu hatayı döndürecektir.

ErrGroup'u kullanmak için öncelikle paketi içe aktarın:

import "golang.org/x/sync/errgroup"

Sonra, ErrGroup örneği oluşturun:

var g errgroup.Group

Bunun ardından, görevleri kapanış şeklinde ErrGroup'a geçirebilir ve Go metodunu çağırarak yeni bir Gorutine başlatabilirsiniz:

g.Go(func() error {
    // Belirli bir görevi gerçekleştir
    // Her şey yolunda giderse
    return nil
    // Bir hata oluşursa
    // return fmt.Errorf("hata oluştu")
})

Son olarak, Wait metodunu çağırarak, tüm görevlerin tamamlanmasını bekleyen ve herhangi bir görev hata döndürürse, bu hatayı döndüren Wait:

if err := g.Wait(); err != nil {
    // Hata ile başa çık
    log.Fatalf("Görev yürütme hatası: %v", err)
}

2.8.2 ErrGroup'ın Pratik Kullanımı

Üç farklı veri kaynağından veri aynı anda getirmemiz gereken bir senaryoyu düşünelim ve herhangi bir veri kaynağı başarısız olursa diğer veri getirme işlemlerini hemen iptal etmek istiyoruz. Bu görev, ErrGroup kullanılarak kolayca başarılabillir:

package main

import (
    "fmt"
    "golang.org/x/sync/errgroup"
)

func fetchDataFromSource1() error {
    // Kaynaktan veri getirme işlemini simüle et
    return nil // veya bir hata döndürerek başarısızlığı simüle et
}

func fetchDataFromSource2() error {
    // Kaynaktan veri getirme işlemini simüle et
    return nil // veya bir hata döndürerek başarısızlığı simüle et
}

func fetchDataFromSource3() error {
    // Kaynaktan veri getirme işlemini simüle et
    return nil // veya bir hata döndürerek başarısızlığı simüle et
}

func main() {
    var g errgroup.Group

    g.Go(fetchDataFromSource1)
    g.Go(fetchDataFromSource2)
    g.Go(fetchDataFromSource3)

    // Tüm gorutinlerin tamamlanmasını bekleyerek hatalarını topla
    if err := g.Wait(); err != nil {
        fmt.Printf("Veri getirme sırasında hata oluştu: %v\n", err)
        return
    }

    fmt.Println("Tüm veriler başarıyla getirildi!")
}

Bu örnekte, fetchDataFromSource1, fetchDataFromSource2 ve fetchDataFromSource3 fonksiyonları farklı veri kaynaklarından veri getirmeyi simüle eder. Bu fonksiyonlar g.Go yöntemine geçirilir ve ayrı Gorutin'lerde çalıştırılır. Eğer bu fonksiyonlardan herhangi biri bir hata döndürürse, g.Wait, bu hatayı hemen döndürerek hatanın oluştuğu noktada uygun hata işlemini sağlar. Eğer tüm fonksiyonlar başarıyla çalışırsa, g.Wait, tüm görevlerin başarıyla tamamlandığını gösteren nil değerini döndürür.

ErrGroup'un diğer önemli bir özelliği de eğer Gorutin'lerden herhangi biri panik yaparsa, bu panik üzerinden toparlanma deneyecek ve onu bir hata olarak döndürecektir. Bu, diğer eş zamanlı olarak çalışan Gorutin'lerin sorunsuz bir şekilde kapanmasını engellemeye yardımcı olur. Elbette, görevlerin harici iptal sinyallerine yanıt vermesini isterse, errgroup'un WithContext fonksiyonunu ve context paketini kullanarak iptal edilebilir bir bağlam sağlayabilirsiniz.

Bu şekilde ErrGroup, Go'nun eş zamanlı programlama uygulamalarında çok pratik bir senkronizasyon ve hata yönetim mekanizması haline gelir.