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 üzerindeSignal
veyaBroadcast
ç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
veWait
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.