Skip to content

Eşzamanlılık

Go dili eşzamanlılık için doğal destek sunar. Bu dilin çekirdeğidir ve kullanım kolaylığı nispeten düşüktür. Geliştiriciler altta yatan uygulamayı çok fazla düşünmeden oldukça iyi bir eşzamanlı uygulama oluşturabilir ve geliştiricilerin alt sınırını yükseltir.

Goroutine

Goroutine, hafif bir iş parçacığıdır veya kullanıcı modu iş parçacığı olarak adlandırılır. İşletim sistemi tarafından doğrudan zamanlanmaz, Go dilinin kendi zamanlayıcısı tarafından çalışma zamanında zamanlanır. Bu nedenle bağlam değiştirme maliyeti çok düşüktür ve bu da Go'nun eşzamanlılık performansının iyi olmasının nedenlerinden biridir. Goroutine kavramı ilk olarak Go tarafından ortaya atılmamıştır ve Go goroutine'i destekleyen ilk dil de değildir. Ancak Go, goroutine ve eşzamanlılığı oldukça basit ve zarif bir şekilde destekleyebilen ilk dildir.

Go'da bir goroutine oluşturmak çok basittir. Sadece go anahtar kelimesi ile hızlı bir şekilde bir goroutine başlatabilirsiniz. go anahtar kelimesinden sonra bir fonksiyon çağrısı gelmelidir. Örnek aşağıdaki gibidir

::: ipucu

Dönüş değeri olan yerleşik fonksiyonlar go anahtar kelimesinden sonra kullanılamaz. Aşağıdaki hatalı örneğe bakın

go
go make([]int,10) //  go discards result of make([]int, 10) (value of type []int)

:::

go
func main() {
  go fmt.Println("hello world!")
  go hello()
  go func() {
    fmt.Println("hello world!")
  }()
}

func hello() {
  fmt.Println("hello world!")
}

Yukarıdaki üç goroutine başlatma yöntemi çalışır. Ancak bu örnek çalıştırıldığında çoğu durumda hiçbir şey çıktılanmaz. Goroutine'ler eşzamanlı olarak çalışır ve sistem goroutine oluşturmak için zamana ihtiyaç duyar. Bundan önce, ana goroutine çoktan çalışmayı bitirmiştir. Ana iş parçacığı çıktıktan sonra, diğer alt goroutine'ler de doğal olarak çıkar. Ayrıca goroutine'lerin çalışma sırası da belirsizdir ve tahmin edilemez. Aşağıdaki örneğe bakın

go
func main() {
  fmt.Println("start")
  for i := 0; i < 10; i++ {
    go fmt.Println(i)
  }
  fmt.Println("end")
}

Bu bir döngü içinde goroutine başlatan bir örnektir. Ne çıktılanacağını asla kesin olarak tahmin edemezsiniz. Alt goroutine'ler çalışmaya başlamadan önce ana goroutine bitmiş olabilir. Durum şu şekildedir

start
end

Veya sadece bazı alt goroutine'ler ana goroutine çıkmadan önce başarıyla çalışmış olabilir. Durum şu şekildedir

start
0
1
5
3
4
6
7
end

En basit çözüm, ana goroutine'in biraz beklemesini sağlamaktır. time paketi altındaki Sleep fonksiyonunu kullanmanız gerekir. Bu, mevcut goroutine'in bir süre duraklatılmasını sağlar. Örnek aşağıdaki gibidir

go
func main() {
  fmt.Println("start")
  for i := 0; i < 10; i++ {
    go fmt.Println(i)
  }
    // 1ms duraklat
  time.Sleep(time.Millisecond)
  fmt.Println("end")
}

Tekrar çalıştırıldığında çıktı şu şekildedir. Tüm sayıların eksiksiz olarak çıktılanmış olduğunu görebilirsiniz

start
0
1
5
2
3
4
6
8
9
7
end

Ancak sıra hala karışıktır. Bu nedenle her döngünün biraz beklemesini sağlayın. Örnek aşağıdaki gibidir

go
func main() {
   fmt.Println("start")
   for i := 0; i < 10; i++ {
      go fmt.Println(i)
      time.Sleep(time.Millisecond)
   }
   time.Sleep(time.Millisecond)
   fmt.Println("end")
}

Şimdi çıktı normal sıradadır

start
0
1
2
3
4
5
6
7
8
9
end

Yukarıdaki örnekte sonuç çıktısı mükemmeldir. Peki eşzamanlılık sorunu çözüldü mü? Hayır, hiç de değil. Eşzamanlı programlar için kontrol edilemeyen faktörler çok fazladır. Çalışma zamanı, sırası, işlem süresi vb. Döngüdeki alt goroutine'in işi sadece basit bir sayı çıktısı değil de çok büyük ve karmaşık bir görevse, süresi belirsizse, o zaman önceki sorunlar tekrar ortaya çıkacaktır. Aşağıdaki koda bakın

go
func main() {
   fmt.Println("start")
   for i := 0; i < 10; i++ {
      go hello(i)
      time.Sleep(time.Millisecond)
   }
   time.Sleep(time.Millisecond)
   fmt.Println("end")
}

func hello(i int) {
   // Rastgele gecikme simülasyonu
   time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
   fmt.Println(i)
}

Bu kodun çıktısı hala belirsizdir. Aşağıdaki olası durumlardan biridir

start
0
3
4
end

Bu nedenle time.Sleep iyi bir çözüm değildir. Neyse ki Go birçok eşzamanlılık kontrol yöntemi sağlar. Yaygın olarak kullanılan üç eşzamanlılık kontrol yöntemi vardır:

  • channel: Kanal
  • WaitGroup: Sinyal
  • Context: Bağlam

Üç yöntem farklı uygulama senaryolarına sahiptir. WaitGroup dinamik olarak belirli sayıda goroutine'i kontrol edebilir. Context alt goroutine iç içe geçmiş düzeylerin daha derin olduğu durumlar için daha uygundur. Kanal goroutine'ler arası iletişim için daha uygundur. Daha geleneksel kilit kontrolü için Go da destek sağlar:

  • Mutex: Karşılıklı dışlama kilidi
  • RWMutex: Okuma-yazma karşılıklı dışlama kilidi

Kanal

channel, kanal olarak çevrilir. Go kanalın işlevini şu şekilde açıklar:

Do not communicate by sharing memory; instead, share memory by communicating.

Yani mesajlar aracılığıyla bellek paylaşımı yapılır. channel bunun için yaratılmıştır. Goroutine'ler arası iletişim için bir çözümdür ve aynı zamanda eşzamanlılık kontrolü için de kullanılabilir. Önce channel'ın temel sözdizimini tanıyalım. Go'da chan anahtar kelimesi kanalı temsil eder. Aynı zamanda kanalın depolama türünü de belirtmelisiniz. Depolanan verilerin ne tür olduğunu belirtmek için aşağıdaki örnek normal bir kanalın görünümüdür.

go
var ch chan int

Bu bir kanal bildirim ifadesidir. Bu noktada kanal henüz başlatılmamıştır ve değeri nil'dir. Doğrudan kullanılamaz.

Oluşturma

Kanal oluştururken bir ve yalnızca bir yöntem vardır. Bu da yerleşik make fonksiyonunu kullanmaktır. Kanal için make fonksiyonu iki parametre alır. Birincisi kanal türüdür, ikincisi opsiyonel parametredir ve kanalın arabellek boyutudur. Örnek aşağıdaki gibidir

go
intCh := make(chan int)
// Arabellek boyutu 1 olan kanal
strCh := make(chan string, 1)

Bir kanalı kullandıktan sonra mutlaka kapatmalısınız. Kanalı kapatmak için yerleşik close fonksiyonunu kullanın. Fonksiyon imzası şu şekildedir.

go
func close(c chan<- Type)

Kanal kapatma örneği aşağıdaki gibidir

go
func main() {
  intCh := make(chan int)
  // bir şeyler yap
  close(intCh)
}

Bazen kanalı kapatmak için defer kullanmak daha iyi olabilir.

Okuma-Yazma

Kanal için Go, okuma-yazma işlemlerini temsil etmek için iki çok görsel operatör kullanır:

ch <-: Kanala veri yazmayı temsil eder

<- ch: Kanaldan veri okumayı temsil eder

<- veri akış yönünü çok canlı bir şekilde gösterir. int türü bir kanal okuma-yazma örneğine bakalım

go
func main() {
    // Arabellek yoksa deadlock oluşur
  intCh := make(chan int, 1)
  defer close(intCh)
    // Veri yaz
  intCh <- 114514
    // Veri oku
  fmt.Println(<-intCh)
}

Yukarıdaki örnekte arabellek boyutu 1 olan int tipi bir kanal oluşturulur. 114514 verisi yazılır, ardından veri okunur ve çıktılanır. Son olarak kanal kapatılır. Okuma işlemi için ikinci bir dönüş değeri vardır. Bir boolean türü değerdir ve verinin başarıyla okunup okunmadığını gösterir

go
ints, ok := <-intCh

Kanal içindeki veri akış yöntemi kuyruk ile aynıdır. Yani ilk giren ilk çıkar (FIFO). Goroutine'lerin kanal üzerindeki işlemleri senkronizedir. Belirli bir anda yalnızca bir goroutine kanala veri yazabilir ve aynı zamanda yalnızca bir goroutine kanaldan veri okuyabilir.

Arabelleksiz

Arabelleksiz kanal için arabellek kapasitesi 0 olduğundan geçici olarak hiçbir veri depolanamaz. Arabelleksiz kanal veri depolayamadığından, kanala veri yazarken hemen başka bir goroutine tarafından okunmalıdır. Aksi takdirde engellenir ve bekler. Veri okurken de aynı durum geçerlidir. Bu da aşağıdaki görünüşte normal kodun neden deadlock'a neden olduğunu açıklar.

go
func main() {
  // Arabelleksiz kanal oluştur
  ch := make(chan int)
  defer close(ch)
  // Veri yaz
  ch <- 123
  // Veri oku
  n := <-ch
  fmt.Println(n)
}

Arabelleksiz kanal senkronize şekilde kullanılmamalıdır. Doğrusu veri göndermek için yeni bir goroutine başlatmaktır. Aşağıdaki örnek gibi

go
func main() {
  // Arabelleksiz kanal oluştur
  ch := make(chan int)
  defer close(ch)
  go func() {
    // Veri yaz
    ch <- 123
  }()
  // Veri oku
  n := <-ch
  fmt.Println(n)
}

Arabellekli

Kanalın arabelleği olduğunda, tıpkı bir engelleme kuyruğu gibidir. Boş kanalı okumak ve dolu kanala yazmak engellemeye neden olur. Arabelleksiz kanal veri gönderirken hemen biri tarafından alınmalıdır. Aksi takdirde sürekli engellenir. Arabellekli kanal için bu gerekmez. Arabellekli kanala veri yazarken, veri önce arabelleğe konur. Yalnızca arabellek kapasitesi dolduğunda, kanaldan veri okumak için goroutine'i engelleme beklemesi gerekir. Aynı şekilde, arabellekli kanalı okurken, önce arabellekten veri okunur. Arabellekte veri kalmayana kadar kanala veri yazmak için goroutine'i engelleme beklemesi gerekir. Bu nedenle, arabelleksiz kanalda deadlock'a neden olan örnek burada sorunsuz çalışabilir.

go
func main() {
   // Arabellekli kanal oluştur
   ch := make(chan int, 1)
   defer close(ch)
   // Veri yaz
   ch <- 123
   // Veri oku
   n := <-ch
   fmt.Println(n)
}

Sorunsuz çalışabilse de, bu senkron okuma-yazma yöntemi çok tehlikelidir. Kanal arabelleği boşaldığında veya dolduğunda, sonsuza kadar engellenecektir. Çünkü kanala veri yazmak veya okumak için başka goroutine yoktur. Aşağıdaki örneğe bakalım

go
func main() {
  // Arabellekli kanal oluştur
  ch := make(chan int, 5)
  // İki arabelleksiz kanal oluştur
  chW := make(chan struct{})
  chR := make(chan struct{})
  defer func() {
    close(ch)
    close(chW)
    close(chR)
  }()
  // Yazmaktan sorumlu
  go func() {
    for i := 0; i < 10; i++ {
      ch <- i
      fmt.Println("Yazma", i)
    }
    chW <- struct{}{}
  }()
  // Okumaktan sorumlu
  go func() {
    for i := 0; i < 10; i++ {
            // Her okumada 1 milisaniye harcanır
      time.Sleep(time.Millisecond)
      fmt.Println("Okuma", <-ch)
    }
    chR <- struct{}{}
  }()
  fmt.Println("Yazma tamamlandı", <-chW)
  fmt.Println("Okuma tamamlandı", <-chR)
}

Burada toplam 3 kanal oluşturuldu. Goroutine'ler arası iletişim için bir arabellekli kanal ve alt/üst goroutine'lerin çalışma sırasını senkronize etmek için iki arabelleksiz kanal. Okumadan sorumlu goroutine her okumadan önce 1 milisaniye bekler. Yazmaktan sorumlu goroutine en fazla 5 veri yazabilir. Çünkü kanal arabelleği maksimum 5'tir. Veri okuyan goroutine olmadan önce, engelleme beklemesi gerekir. Bu nedenle örnek çıktısı şu şekildedir

Yazma 0
Yazma 1
Yazma 2
Yazma 3
Yazma 4 // 5 tane yazıldı, arabellek doldu, diğer goroutine'in okumasını bekle
Okuma 0
Yazma 5 // Bir oku, bir yaz
Okuma 1
Yazma 6
Okuma 2
Yazma 7
Okuma 3
Yazma 8
Yazma 9
Okuma 4
Yazma tamamlandı {} // Tüm veriler gönderildi, yazma goroutine'i tamamlandı
Okuma 5
Okuma 6
Okuma 7
Okuma 8
Okuma 9
Okuma tamamlandı {} // Tüm veriler okundu, okuma goroutine'i tamamlandı

Yazmaktan sorumlu goroutine'in başlangıçta 5 veri gönderdiğini görebilirsiniz. Arabellek dolduktan sonra okuma goroutine'inin okumasını beklemeye başlar. Sonra her 1 milisaniyede bir veri okunduğunda, arabellekte yer açıldığında yazma goroutine'i bir veri yazar. Tüm veriler gönderilene kadar, yazma goroutine'i çalışmayı bitirir. Ardından okuma goroutine'i arabellekteki tüm verileri okuduktan sonra, okuma goroutine'i de çalışmayı bitirir. Son olarak ana goroutine çıkar.

::: ipucu

Yerleşik len fonksiyonu ile kanal arabelleğindeki veri sayısına erişebilirsiniz. cap ile kanal arabelleğinin boyutuna erişebilirsiniz.

go
func main() {
   ch := make(chan int, 5)
   ch <- 1
   ch <- 2
   ch <- 3
   fmt.Println(len(ch), cap(ch))
}

Çıktı

3 5

:::

Kanal engelleme koşullarını kullanarak, ana goroutine'in alt goroutine'lerin çalışması bitene kadar beklediği bir örnek kolayca yazılabilir

go
func main() {
   // Bir arabelleksiz kanal oluştur
   ch := make(chan struct{})
   defer close(ch)
   go func() {
      fmt.Println(2)
      // Yaz
      ch <- struct{}{}
   }()
   // Engelleme bekleme okuması
   <-ch
   fmt.Println(1)
}

Çıktı

2
1

Arabellekli kanal kullanılarak basit bir karşılıklı dışlama kilidi de uygulanabilir. Aşağıdaki örneğe bakın

go
var count = 0

// Arabellek boyutu 1 olan kanal
var lock = make(chan struct{}, 1)

func Add() {
    // Kilitle
  lock <- struct{}{}
  fmt.Println("Mevcut sayım", count, "toplama yapılıyor")
  count += 1
    // Kilidi aç
  <-lock
}

func Sub() {
    // Kilitle
  lock <- struct{}{}
  fmt.Println("Mevcut sayım", count, "çıkarma yapılıyor")
  count -= 1
    // Kilidi aç
  <-lock
}

Kanal arabelleğinin boyutu 1 olduğundan, arabellekte en fazla bir veri depolanabilir. Add ve Sub fonksiyonları her işlem yapmadan önce kanala veri göndermeye çalışır. Arabellek boyutu 1 olduğundan, eğer başka bir goroutine zaten veri yazdıysa ve arabellek dolduysa, mevcut goroutine arabellekte yer açılana kadar engelleme beklemelidir. Bu şekilde, belirli bir anda en fazla bir goroutine count değişkenini değiştirebilir. Böylece basit bir karşılıklı dışlama kilidi uygulanmış olur.

Dikkat Edilmesi Gerekenler

Aşağıda bir özet bulunmaktadır. Kanalların yanlış kullanımı aşağıdaki durumlarda kanal engellemesine neden olabilir:

Arabelleksiz kanalı okuma-yazma

Arabelleksiz kanala doğrudan senkron okuma-yazma işlemi yapıldığında mevcut goroutine engellenir

go
func main() {
   // Arabelleksiz kanal oluşturuldu
   intCh := make(chan int)
   defer close(intCh)
   // Veri gönder
   intCh <- 1
   // Veri oku
   ints, ok := <-intCh
   fmt.Println(ints, ok)
}

Boş arabellekli kanalı okuma

Arabelleği boş olan bir kanalı okurken, mevcut goroutine engellenir

go
func main() {
   // Arabellekli kanal oluşturuldu
   intCh := make(chan int, 1)
   defer close(intCh)
   // Arabellek boş, diğer goroutine'lerin veri yazmasını engelleme bekliyor
   ints, ok := <-intCh
   fmt.Println(ints, ok)
}

Dolu arabellekli kanala yazma

Kanal arabelleği dolduğunda, veri yazmak mevcut goroutine'i engeller

go
func main() {
  // Arabellekli kanal oluşturuldu
  intCh := make(chan int, 1)
  defer close(intCh)

  intCh <- 1
    // Doldu, diğer goroutine'lerin okumasını engelleme bekliyor
  intCh <- 1
}

Kanal nil olduğunda

Kanal nil olduğunda, okuma veya yazma ne olursa olsun mevcut goroutine engellenir

go
func main() {
  var intCh chan int
    // Yaz
  intCh <- 1
}
go
func main() {
  var intCh chan int
    // Oku
  fmt.Println(<-intCh)
}

Kanal engelleme koşulları hakkında iyi ustalaşmanız ve aşina olmanız gerekir. Çoğu durumda bu sorunlar çok gizlidir ve örneklerdeki gibi doğrudan değildir.

Aşağıdaki durumlar ayrıca panic'e neden olur:

nil kanalı kapatma

Kanal nil olduğunda, close fonksiyonu ile kapatma işlemi panic'e neden olur

go
func main() {
  var intCh chan int
  close(intCh)
}

Kapatılmış kanala yazma

Kapatılmış bir kanala veri yazmak panic'e neden olur

go
func main() {
  intCh := make(chan int, 1)
  close(intCh)
  intCh <- 1
}

Kapatılmış kanalı kapatma

Bazı durumlarda, kanal katman katman iletilebilir. Çağıran kimin kanalı kapatması gerektiğini bilemeyebilir. Bu durumda, zaten kapatılmış bir kanalı kapatmaya çalışmak panic'e neden olur.

go
func main() {
  ch := make(chan int, 1)
  defer close(ch)
  go write(ch)
  fmt.Println(<-ch)
}

func write(ch chan<- int) {
  // Sadece kanala veri gönderebilir
  ch <- 1
  close(ch)
}

Tek Yönlü Kanal

Çift yönlü kanal hem yazılabilir hem de okunabilir demektir. Yani kanalın her iki tarafında da işlem yapılabilir. Tek yönlü kanal sadece okunabilir veya sadece yazılabilir kanal demektir. Yani sadece kanalın bir tarafında işlem yapılabilir. Manuel olarak oluşturulan sadece okunabilir veya sadece yazılabilir kanalın çok fazla anlamı yoktur. Çünkü kanalı okuyup yazamamak, varoluş amacını yitirmesine neden olur. Tek yönlü kanal genellikle kanal davranışını kısıtlamak için kullanılır. Genellikle fonksiyon parametrelerinde ve dönüş değerlerinde görünür. Örneğin kanalı kapatmak için kullanılan yerleşik close fonksiyonunun fonksiyon imzası tek yönlü kanalı kullanır.

go
func close(c chan<- Type)

Veya yaygın olarak kullanılan time paketi altındaki After fonksiyonu

go
func After(d Duration) <-chan Time

close fonksiyonunun parametresi sadece yazılabilir kanaldır. After fonksiyonunun dönüş değeri sadece okunabilir kanaldır. Bu nedenle tek yönlü kanal sözdizimi şu şekildedir:

  • Ok işareti <- önde ise, sadece okunabilir kanaldır. Örneğin <-chan int
  • Ok işareti <- sonda ise, sadece yazılabilir kanaldır. Örneğin chan<- string

Sadece okunabilir kanala veri yazmaya çalışıldığında, derleme başarısız olur

go
func main() {
  timeCh := time.After(time.Second)
  timeCh <- time.Now()
}

Hata şu şekildedir, anlamı çok açıktır

invalid operation: cannot send to receive-only channel timeCh (variable of type <-chan time.Time)

Sadece yazılabilir kanaldan veri okumak da aynıdır.

Çift yönlü kanal tek yönlü kanala dönüştürülebilir. Tersine ise dönüştürülemez. Normalde, çift yönlü kanalı bir goroutine'e veya fonksiyona iletirken ve okumasını/göndermesini istemediğinizde, tek yönlü kanalı kullanarak diğer tarafın davranışını kısıtlayabilirsiniz.

go
func main() {
   ch := make(chan int, 1)
   go write(ch)
   fmt.Println(<-ch)
}

func write(ch chan<- int) {
   // Sadece kanala veri gönderebilir
   ch <- 1
}

Sadece okunabilir kanal da aynı mantıktadır

::: ipucu

chan referans türüdür. Go'nun fonksiyon parametreleri değer kopyalaması olsa da, referans hala aynıdır. Bu, sonraki kanal prensiplerinde açıklanacaktır.

:::

for range

for range ifadesi kullanılarak arabellekli kanaldaki veriler okunabilir. Aşağıdaki örnek gibi

go
func main() {
  ch := make(chan int, 10)
  go func() {
    for i := 0; i < 10; i++ {
      ch <- i
    }
  }()
  for n := range ch {
    fmt.Println(n)
  }
}

Genellikle for range diğer yinelemeli veri yapılarını yineleirken iki dönüş değeri vardır. Birincisi indeks, ikincisi eleman değeridir. Ancak kanal için sadece bir dönüş değeri vardır. for range kanaldaki elemanları sürekli okur. Kanal arabelleği boş olduğunda veya arabellek olmadığında, diğer goroutine'ler kanala veri yazana kadar engelleme bekler. Bu nedenle çıktı şu şekildedir:

0
1
2
3
4
5
6
7
8
9
fatal error: all goroutines are asleep - deadlock!

Yukarıdaki kodun deadlock'a girdiğini görebilirsiniz. Çünkü alt goroutine çalışmayı bitirmiştir ve ana goroutine hala diğer goroutine'lerin kanala veri yazmasını engelleme beklemektedir. Bu nedenle kanal veri gönderimi bittikten sonra kapatılmalıdır. Aşağıdaki kod gibi değiştirin

go
func main() {
   ch := make(chan int, 10)
   go func() {
      for i := 0; i < 10; i++ {
         ch <- i
      }
      // Kanalı kapat
      close(ch)
   }()
   for n := range ch {
      fmt.Println(n)
   }
}

Yazma işlemi bittikten sonra kanalı kapatın. Yukarıdaki kod artık deadlock'a girmez. Daha önce kanalı okumanın iki dönüş değeri olduğu belirtilmişti. for range kanalı yinelediğinde, veri başarıyla okunamadığında döngüden çıkar. İkinci dönüş değeri verinin başarıyla okunup okunamadığını ifade eder, kanalın kapatılıp kapatılmadığını değil. Kanal kapatılmış olsa bile, arabellekli kanal için hala veri okunabilir ve ikinci dönüş değeri hala true'dur. Aşağıdaki örneğe bakın

go
func main() {
  ch := make(chan int, 10)
  for i := 0; i < 5; i++ {
    ch <- i
  }
    // Kanalı kapat
  close(ch)
    // Tekrar veri oku
  for i := 0; i < 6; i++ {
    n, ok := <-ch
    fmt.Println(n, ok)
  }
}

Çıktı sonucu

0 true
1 true
2 true
3 true
4 true
0 false

Kanal kapatıldığı için, arabellek boş olsa bile, veri okumak mevcut goroutine'i engellemez. Altıncı yinelemede okunan değerin sıfır değeri olduğunu ve ok'un false olduğunu görebilirsiniz.

::: ipucu

Kanal kapatma zamanlaması hakkında, kanalı veri gönderen tarafta kapatmak gerekir. Alıcı tarafta kapatmamalısınız. Çünkü çoğu durumda alıcı sadece veri alır ve kanalı ne zaman kapatması gerektiğini bilmez.

:::

WaitGroup

sync.WaitGroup, sync paketi tarafından sağlanan bir yapıdır. WaitGroup, beklemeyi yürütme anlamına gelir. Bir grup goroutine'i beklemek için kolayca kullanılabilir. Bu yapı sadece üç yöntemi dışarıya açar.

Add yöntemi beklenmesi gereken goroutine sayısını belirtmek için kullanılır

go
func (wg *WaitGroup) Add(delta int)

Done yöntemi mevcut goroutine'in çalışmayı tamamladığını belirtir

go
func (wg *WaitGroup) Done()

Wait yöntemi alt goroutine'lerin bitmesini bekler, aksi takdirde engeller

go
func (wg *WaitGroup) Wait()

WaitGroup kullanımı çok basittir, kutudan çıkar çıkmaz kullanılabilir. İç uygulaması sayaç + sinyalizasyondur. Program başladığında Add çağrılarak sayaç başlatılır. Her goroutine bittiğinde Done çağrılır ve sayaç 1 azalır. 0'a düşene kadar, bu sırada ana goroutine Wait çağrısı yapar ve tüm sayaçlar 0'a düşene kadar engellenir. Sonra uyanır. Basit bir kullanım örneğine bakalım

go
func main() {
  var wait sync.WaitGroup
  // Alt goroutine sayısını belirt
  wait.Add(1)
  go func() {
    fmt.Println(1)
    // Çalışma tamamlandı
    wait.Done()
  }()
  // Alt goroutine'i bekle
  wait.Wait()
  fmt.Println(2)
}

Bu kod her zaman önce 1'i sonra 2'yi çıktılar. Ana goroutine alt goroutine çalışmayı bitirene kadar bekler ve sonra çıkar.

1
2

Goroutine tanıtımındaki ilk örneğe göre aşağıdaki gibi değişiklik yapılabilir

go
func main() {
   var mainWait sync.WaitGroup
   var wait sync.WaitGroup
   // 10 say
   mainWait.Add(10)
   fmt.Println("start")
   for i := 0; i < 10; i++ {
      // Döngü içinde 1 say
      wait.Add(1)
      go func() {
         fmt.Println(i)
         // İki sayımı 1 azalt
         wait.Done()
         mainWait.Done()
      }()
      // Mevcut döngünün goroutine'inin bitmesini bekle
      wait.Wait()
   }
   // Tüm goroutine'lerin bitmesini bekle
   mainWait.Wait()
   fmt.Println("end")
}

Burada time.Sleep yerine sync.WaitGroup kullanıldı. Goroutine'lerin eşzamanlı çalışma sırası daha kontrol edilebilir. Kaç kez çalıştırılırsa çalıştırılsın, çıktı şu şekildedir

start
0
1
2
3
4
5
6
7
8
9
end

WaitGroup genellikle goroutine sayısının dinamik olarak ayarlanabildiği durumlarda uygundur. Örneğin goroutine sayısı önceden biliniyorsa veya çalışma sırasında dinamik olarak ayarlanması gerekiyorsa. WaitGroup değeri kopyalanmamalıdır. Kopyalanan değer kullanılmamalıdır. Özellikle fonksiyon parametresi olarak iletirken, değer yerine işaretçi iletilmelidir. Kopyalanan değer kullanılırsa, sayaç gerçek WaitGroup üzerinde çalışmaz. Bu, ana goroutine'in sürekli engellenmesine ve programın normal çalışmamasına neden olabilir. Aşağıdaki kod örneği

go
func main() {
  var mainWait sync.WaitGroup
  mainWait.Add(1)
  hello(mainWait)
  mainWait.Wait()
  fmt.Println("end")
}
func hello(wait sync.WaitGroup) {
  fmt.Println("hello")
  wait.Done()
}

Hata tüm goroutine'lerin çıktığını, ancak ana goroutine'in hala beklediğini gösterir. Bu deadlock oluşturur. Çünkü hello fonksiyonu içinde şekil parametresi WaitGroup üzerinde Done çağrısı yapmak, orijinal mainWait üzerinde çalışmaz. Bu nedenle işaretçi kullanılarak iletilmelidir.

hello
fatal error: all goroutines are asleep - deadlock!

::: ipucu

Sayaç negatif olduğunda veya sayaç sayısı alt goroutine sayısından büyük olduğunda, panic oluşur.

:::

Context

Context, bağlam olarak çevrilir. Go tarafından sağlanan bir eşzamanlılık kontrol çözümüdür. Kanal ve WaitGroup'a kıyasla, alt ve daha derin düzeydeki goroutine'leri daha iyi kontrol edebilir. Context kendisi bir arayüzdür. Bu arayüzü uygulayan her şey bağlam olarak adlandırılabilir. Örneğin ünlü Web çerçevesi Gin'deki gin.Context. context standart kütüphanesi de birkaç uygulama sağlar. Bunlar şunlardır:

  • emptyCtx
  • cancelCtx
  • timerCtx
  • valueCtx

Context

Önce Context arayüzünün tanımına bakalım, ardından somut uygulamasını anlayalım.

go
type Context interface {

   Deadline() (deadline time.Time, ok bool)

   Done() <-chan struct{}

   Err() error

   Value(key any) any
}

Deadline

Bu yöntemin iki dönüş değeri vardır. deadline, bağlamın iptal edilmesi gereken son tarihtir. İkinci değer deadline'ın ayarlanıp ayarlanmadığıdır. Ayarlanmamışsa her zaman false'tur.

go
Deadline() (deadline time.Time, ok bool)

Done

Dönüş değeri boş yapı tipi sadece okunabilir kanaldır. Bu kanal sadece bildirim işlevi görür ve hiçbir veri iletmez. Bağlamın yaptığı iş iptal edilmesi gerektiğinde, bu kanal kapatılır. İptal edilemeyen bazı bağlamlar için nil döndürebilir.

go
Done() <-chan struct{}

Err

Bu yöntem bir error döndürür. Bağlamın kapanma nedenini gösterir. Done kanalı kapatılmadığında nil döndürür. Kapatıldıktan sonra, neden kapatıldığını açıklayan bir err döndürür.

go
Err() error

Value

Bu yöntem ilgili anahtar değerini döndürür. Eğer key bulunamazsa veya yöntem desteklenmiyorsa, nil döndürür.

go
Value(key any) any

emptyCtx

Adından da anlaşılacağı gibi, emptyCtx boş bağlamdır. context paketi altındaki tüm uygulamalar dışarıya açık değildir. Ancak bağlam oluşturmak için karşılık gelen fonksiyonlar sağlar. emptyCtx, context.Background ve context.TODO kullanılarak oluşturulabilir. İki fonksiyon şu şekildedir

go
var (
  background = new(emptyCtx)
  todo       = new(emptyCtx)
)

func Background() Context {
  return background
}

func TODO() Context {
  return todo
}

Sadece emptyCtx işaretçisi döndürüldüğünü görebilirsiniz. emptyCtx alt türü aslında bir int'tir. Neden boş yapı kullanılmadığı çünkü emptyCtx örneklerinin farklı bellek adreslerine sahip olması gerekir. İptal edilemez, deadline yoktur ve değer alınamaz. Uygulanan yöntemler sıfır değer döndürür.

go
type emptyCtx int

func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
   return
}

func (*emptyCtx) Done() <-chan struct{} {
   return nil
}

func (*emptyCtx) Err() error {
   return nil
}

func (*emptyCtx) Value(key any) any {
   return nil
}

emptyCtx genellikle en üst düzey bağlam olarak kullanılır. Diğer üç bağlam oluşturulurken üst bağlam olarak iletilir. context paketindeki çeşitli uygulamaların ilişkisi aşağıdaki şekilde gösterilmiştir

valueCtx

valueCtx uygulaması oldukça basittir. İçinde sadece bir anahtar-değer çifti ve gömülü bir Context türü alanı bulunur.

go
type valueCtx struct {
   Context
   key, val any
}

Kendisi sadece Value yöntemini uygular. Mantık da çok basittir. Mevcut bağlamda bulunamazsa üst bağlamda arar.

go
func (c *valueCtx) Value(key any) any {
   if c.key == key {
      return c.val
   }
   return value(c.Context, key)
}

Aşağıda valueCtx için basit bir kullanım örneği bulunmaktadır

go
var waitGroup sync.WaitGroup

func main() {
  waitGroup.Add(1)
    // Bağlam ilet
  go Do(context.WithValue(context.Background(), 1, 2))
  waitGroup.Wait()
}

func Do(ctx context.Context) {
    // Yeni zamanlayıcı oluştur
  ticker := time.NewTimer(time.Second)
  defer waitGroup.Done()
  for {
    select {
    case <-ctx.Done(): // Asla çalışmayacak
    case <-ticker.C:
      fmt.Println("timeout")
      return
    default:
      fmt.Println(ctx.Value(1))
    }
    time.Sleep(time.Millisecond * 100)
  }
}

valueCtx çoğunlukla çok düzeyli goroutine'lerde veri iletmek için kullanılır. İptal edilemez, bu nedenle ctx.Done her zaman nil döndürür. select nil kanalını yoksayar. Son çıktı şu şekildedir

2
2
2
2
2
2
2
2
2
2
timeout

cancelCtx

cancelCtx ve timerCtx her ikisi de canceler arayüzünü uygular. Arayüz türü şu şekildedir

go
type canceler interface {
    // removeFromParent, üst bağlamdan kendini silip silmeyeceğini belirtir
    // err, iptal nedenini belirtir
  cancel(removeFromParent bool, err error)
    // Done, iptal nedenini bildirmek için bir kanal döndürür
  Done() <-chan struct{}
}

cancel yöntemi dışarıya açık değildir. Bağlam oluşturulurken kapama yoluyla dönüş değeri olarak paketlenir. context.WithCancel kaynak kodunda gösterildiği gibi

go
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
   if parent == nil {
      panic("cannot create context from nil parent")
   }
   c := newCancelCtx(parent)
   // Kendini üstün children'ına eklemeyi dene
   propagateCancel(parent, &c)
   // context ve bir fonksiyon döndür
   return &c, func() { c.cancel(true, Canceled) }
}

cancelCtx, iptal edilebilir bağlam olarak çevrilir. Oluşturulurken, üst canceler uygularsa, kendini üstün children'ına ekler. Aksi takdirde yukarı doğru aramaya devam eder. Tüm üstler canceler uygulamamışsa, bir goroutine başlatır ve üstün iptal edilmesini bekler. Sonra üst bittiğinde mevcut bağlamı iptal eder. cancelFunc çağrıldığında, Done kanalı kapatılır. Bu bağlamın herhangi bir altı da buna göre iptal edilir. Son olarak kendini üstten siler. Aşağıda basit bir örnek bulunmaktadır:

go
var waitGroup sync.WaitGroup

func main() {
  bkg := context.Background()
    // Bir cancelCtx ve cancel fonksiyonu döndürür
  cancelCtx, cancel := context.WithCancel(bkg)
  waitGroup.Add(1)
  go func(ctx context.Context) {
    defer waitGroup.Done()
    for {
      select {
      case <-ctx.Done():
        fmt.Println(ctx.Err())
        return
      default:
        fmt.Println("İptal bekleniyor...")
      }
      time.Sleep(time.Millisecond * 200)
    }

  }(cancelCtx)
  time.Sleep(time.Second)
  cancel()
  waitGroup.Wait()
}

Çıktı şu şekildedir

İptal bekleniyor...
İptal bekleniyor...
İptal bekleniyor...
İptal bekleniyor...
İptal bekleniyor...
context canceled

Daha derin iç içe geçmiş bir örneğe bakalım

go
var waitGroup sync.WaitGroup

func main() {
   waitGroup.Add(3)
   ctx, cancelFunc := context.WithCancel(context.Background())
   go HttpHandler(ctx)
   time.Sleep(time.Second)
   cancelFunc()
   waitGroup.Wait()
}

func HttpHandler(ctx context.Context) {
   cancelCtxAuth, cancelAuth := context.WithCancel(ctx)
   cancelCtxMail, cancelMail := context.WithCancel(ctx)

   defer cancelAuth()
   defer cancelMail()
   defer waitGroup.Done()

   go AuthService(cancelCtxAuth)
   go MailService(cancelCtxMail)

   for {
      select {
      case <-ctx.Done():
         fmt.Println(ctx.Err())
         return
      default:
         fmt.Println("HTTP isteği işleniyor...")
      }
      time.Sleep(time.Millisecond * 200)
   }

}

func AuthService(ctx context.Context) {
   defer waitGroup.Done()
   for {
      select {
      case <-ctx.Done():
         fmt.Println("auth üst iptal etti", ctx.Err())
         return
      default:
         fmt.Println("auth...")
      }
      time.Sleep(time.Millisecond * 200)
   }
}

func MailService(ctx context.Context) {
   defer waitGroup.Done()
   for {
      select {
      case <-ctx.Done():
         fmt.Println("mail üst iptal etti", ctx.Err())
         return
      default:
         fmt.Println("mail...")
      }
      time.Sleep(time.Millisecond * 200)
   }
}

Örnekte 3 cancelCtx oluşturuldu. Üst cancelCtx iptal edilirken alt bağlamlarını da iptal etse de, sigorta için bir cancelCtx oluşturulduysa, ilgili işlem bittikten sonra cancel fonksiyonu çağrılmalıdır. Çıktı şu şekildedir

HTTP isteği işleniyor...
auth...
mail...
mail...
auth...
HTTP isteği işleniyor...
auth...
mail...
HTTP isteği işleniyor...
HTTP isteği işleniyor...
auth...
mail...
auth...
HTTP isteği işleniyor...
mail...
context canceled
auth üst iptal etti context canceled
mail üst iptal etti context canceled

timerCtx

timerCtx, cancelCtx temelinde zaman aşımı mekanizması ekler. context paketi iki oluşturma fonksiyonu sağlar. Bunlar WithDeadline ve WithTimeout'tur. Her ikisi de benzer işlevlere sahiptir. İlki belirli bir zaman aşımı süresi belirtir. Örneğin belirli bir具体时间 2023/3/20 16:32:00 gibi. İkincisi bir zaman aşımı aralığı belirtir. Örneğin 5 dakika sonra. İki fonksiyonun imzası şu şekildedir

go
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

timerCtx, süresi dolduğunda mevcut bağlamı otomatik olarak iptal eder. İptal işlemi timer'ı kapatmanın yanı sıra, temel olarak cancelCtx ile aynıdır. Aşağıda basit bir timerCtx kullanım örneği bulunmaktadır

go
var wait sync.WaitGroup

func main() {
  deadline, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Second))
  defer cancel()
  wait.Add(1)
  go func(ctx context.Context) {
    defer wait.Done()
    for {
      select {
      case <-ctx.Done():
        fmt.Println("Bağlam iptal edildi", ctx.Err())
        return
      default:
        fmt.Println("İptal bekleniyor...")
      }
      time.Sleep(time.Millisecond * 200)
    }
  }(deadline)
  wait.Wait()
}

Bağlamın süresi dolunca otomatik olarak iptal edilse de, sigorta için ilgili işlem bittikten sonra manuel olarak bağlamı iptal etmek en iyisidir. Çıktı şu şekildedir

İptal bekleniyor...
İptal bekleniyor...
İptal bekleniyor...
İptal bekleniyor...
İptal bekleniyor...
Bağlam iptal edildi context deadline exceeded

WithTimeout aslında WithDeadline'e çok benzer. Uygulaması da sadece biraz paketleyip WithDeadline'i çağırır. Yukarıdaki örnekteki WithDeadline kullanımı ile aynıdır. Aşağıdaki gibi

go
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
   return WithDeadline(parent, time.Now().Add(timeout))
}

::: ipucu

Bellek tahsisinden sonra geri alınmaması bellek sızıntısına neden olduğu gibi, bağlam da bir kaynaktır. Oluşturulur ama hiç iptal edilmezse, bağlam sızıntısına neden olur. Bu nedenle bu durumdan kaçınmak en iyisidir.

:::

Select

select, Linux sisteminde bir IO çoklu kullanım çözümüdür. Benzer şekilde, Go'da select bir kanal çoklu kullanım kontrol yapısıdır. Çoklu kullanım nedir, basitçe tek bir cümleyle özetleyelim: Belirli bir anda, birden fazla öğenin kullanılıp kullanılamadığını izler. İzlenen öğeler ağ isteği, dosya IO vb. olabilir. Go'daki select'in izlediği öğe kanaldır ve sadece kanal olabilir. select sözdizimi switch ifadesine benzer. Aşağıda bir select ifadesinin nasıl göründüğüne bakalım

go
func main() {
  // Üç kanal oluştur
  chA := make(chan int)
  chB := make(chan int)
  chC := make(chan int)
  defer func() {
    close(chA)
    close(chB)
    close(chC)
  }()
  select {
  case n, ok := <-chA:
    fmt.Println(n, ok)
  case n, ok := <-chB:
    fmt.Println(n, ok)
  case n, ok := <-chC:
    fmt.Println(n, ok)
  default:
    fmt.Println("Tüm kanallar kullanılamıyor")
  }
}

Kullanım

switch ile benzer şekilde, select birden fazla case ve bir default'tan oluşur. default dalı atlanabilir. Her case sadece bir kanalı işleyebilir ve sadece bir işlem yapabilir. Ya okuma ya da yazma. Birden fazla case kullanılabildiğinde, select sözde rastgele bir case seçer ve çalıştırır. Tüm case'ler kullanılamıyorsa, default dalı çalıştırılır. default dalı yoksa, en az bir case kullanılana kadar engelleme bekler. Yukarıdaki örnekte kanala veri yazılmadığından, doğal olarak tüm case'ler kullanılamaz. Bu nedenle son çıktı default dalının çalıştırma sonucudur. Biraz değiştirdikten sonra aşağıdaki gibi olur:

go
func main() {
   chA := make(chan int)
   chB := make(chan int)
   chC := make(chan int)
   defer func() {
      close(chA)
      close(chB)
      close(chC)
   }()
   // Yeni bir goroutine başlat
   go func() {
      // A kanalına veri yaz
      chA <- 1
   }()
   select {
   case n, ok := <-chA:
      fmt.Println(n, ok)
   case n, ok := <-chB:
      fmt.Println(n, ok)
   case n, ok := <-chC:
      fmt.Println(n, ok)
   }
}

Yukarıdaki örnek A kanalına veri yazmak için yeni bir goroutine başlatır. select varsayılan dalı olmadığından, case kullanılana kadar sürekli engelleme bekler. A kanalı kullanılabildiğinde, karşılık gelen dal çalıştırıldıktan sonra ana goroutine doğrudan çıkar. Kanalı sürekli izlemek için for döngüsü ile birlikte kullanılabilir. Aşağıdaki gibi.

go
func main() {
  chA := make(chan int)
  chB := make(chan int)
  chC := make(chan int)
  defer func() {
    close(chA)
    close(chB)
    close(chC)
  }()
  go Send(chA)
  go Send(chB)
  go Send(chC)
  // for döngüsü
  for {
    select {
    case n, ok := <-chA:
      fmt.Println("A", n, ok)
    case n, ok := <-chB:
      fmt.Println("B", n, ok)
    case n, ok := <-chC:
      fmt.Println("C", n, ok)
    }
  }
}

func Send(ch chan<- int) {
  for i := 0; i < 3; i++ {
    time.Sleep(time.Millisecond)
    ch <- i
  }
}

Bu gerçekten üç kanalı da kullanılabilir hale getirir. Ancak sonsuz döngü + select ana goroutine'in sonsuza kadar engellenmesine neden olur. Bu nedenle yeni goroutine'e koyabilir ve bazı diğer mantıklar ekleyebilirsiniz.

go
func main() {
  chA := make(chan int)
  chB := make(chan int)
  chC := make(chan int)
  defer func() {
    close(chA)
    close(chB)
    close(chC)
  }()

  l := make(chan struct{})

  go Send(chA)
  go Send(chB)
  go Send(chC)

  go func() {
  Loop:
    for {
      select {
      case n, ok := <-chA:
        fmt.Println("A", n, ok)
      case n, ok := <-chB:
        fmt.Println("B", n, ok)
      case n, ok := <-chC:
        fmt.Println("C", n, ok)
      case <-time.After(time.Second): // 1 saniyelik zaman aşımı süresi ayarla
        break Loop // Döngüden çık
      }
    }
    l <- struct{}{} // Ana goroutine'e çıkabileceğini söyle
  }()

  <-l
}

func Send(ch chan<- int) {
  for i := 0; i < 3; i++ {
    time.Sleep(time.Millisecond)
    ch <- i
  }
}

Yukarıdaki örnekte for döngüsü select ile birlikte üç kanalı sürekli izlemek için kullanılır. Dördüncü case bir zaman aşımı kanalıdır. Zaman aşımından sonra döngüden çıkar ve alt goroutine'i bitirir. Son çıktı şu şekildedir

C 0 true
A 0 true
B 0 true
A 1 true
B 1 true
C 1 true
B 2 true
C 2 true
A 2 true

Zaman Aşımı

Önceki örnekte time.After fonksiyonu kullanıldı. Dönüş değeri sadece okunabilir bir kanaldır. Bu fonksiyon select ile birlikte kullanılarak çok basit bir şekilde zaman aşımı mekanizması uygulanabilir. Örnek aşağıdaki gibidir

go
func main() {
  chA := make(chan int)
  defer close(chA)
  go func() {
    time.Sleep(time.Second * 2)
    chA <- 1
  }()
  select {
  case n := <-chA:
    fmt.Println(n)
  case <-time.After(time.Second):
    fmt.Println("Zaman aşımı")
  }
}

Sonsuz Engelleme

select ifadesinde hiçbir şey olmadığında, sonsuza kadar engellenir. Örneğin

go
func main() {
  fmt.Println("start")
  select {}
  fmt.Println("end")
}

end asla çıktılanmaz. Ana goroutine sürekli engellenir. Bu durum genellikle özel amaçlar için kullanılır.

::: ipucu

select'in case'inde nil kanalı üzerinde işlem yapmak, engellemeye neden olmaz. Bu case yoksayılır ve asla çalıştırılmaz. Aşağıdaki kod kaç kez çalıştırılırsa çalıştırılsın sadece timeout çıktılanır.

go
func main() {
   var nilCh chan int
   select {
   case <-nilCh:
      fmt.Println("read")
   case nilCh <- 1:
      fmt.Println("write")
   case <-time.After(time.Second):
      fmt.Println("timeout")
   }
}

:::

Engellemesiz

select'in default dalı kanal ile birlikte kullanılarak engellemesiz gönderme/alma işlemleri uygulanabilir. Aşağıda gösterildiği gibi

go
func TrySend(ch chan int, ele int) bool  {
	select {
	case ch <- ele:
		return true
	default:
		return false
	}
}

func TryRecv(ch chan int) (int, bool)  {
	select {
	case ele, ok := <-ch:
		return ele, ok
	default:
		return 0, false
	}
}

Aynı şekilde, bir context'in bitip bitmediğini engellemesiz判断也可以实现

go
func IsDone(ctx context.Context) bool {
	select {
	case <-ctx.Done():
		return true
	default:
		return false
	}
}

Kilit

Önce bir örneğe bakalım

go
var wait sync.WaitGroup
var count = 0

func main() {
   wait.Add(10)
   for i := 0; i < 10; i++ {
      go func(data *int) {
         // Erişim gecikmesi simülasyonu
         time.Sleep(time.Millisecond * time.Duration(rand.Intn(5000)))
         // Veriye eriş
         temp := *data
         // Hesaplama gecikmesi simülasyonu
         time.Sleep(time.Millisecond * time.Duration(rand.Intn(5000)))
         ans := 1
         // Veriyi değiştir
         *data = temp + ans
         fmt.Println(*data)
         wait.Done()
      }(&count)
   }
   wait.Wait()
   fmt.Println("Son sonuç", count)
}

Yukarıdaki örnek için, count üzerinde +1 işlemi yapmak için on goroutine başlatıldı. Ve farklı gecikmeleri simüle etmek için time.Sleep kullanıldı. Sezgimize göre, 10 goroutine 10 +1 işlemi yaparsa, son sonuç kesinlikle 10 olmalıdır. Doğru sonuç da gerçekten 10'dur. Ancak gerçekte böyle değildir. Yukarıdaki örnek çalıştırma sonucu şu şekildedir:

1
2
3
3
2
2
3
3
3
4
Son sonuç 4

Sonucun 4 olduğunu görebilirsiniz. Bu sadece birçok olası sonuçtan biridir. Her goroutine'in veri erişimi ve hesaplama için harcadığı zaman farklı olduğundan, A goroutine'i veri erişimi için 500 milisaniye harcar. Bu sırada erişilen count değeri 1'dir. Ardından 400 milisaniye hesaplama harcar. Ancak bu 400 milisaniye içinde, B goroutine'i zaten erişimi ve hesaplamayı tamamlamış ve count değerini başarıyla güncellemiştir. A goroutine'i hesaplamayı bitirdikten sonra, A goroutine'inin başlangıçta eriştiği değer eski olmuştur. Ancak A goroutine'i bunu bilmez ve hala başlangıçta eriştiği değere bir ekler ve count'a atar. Bu şekilde, B goroutine'inin çalıştırma sonucu üzerine yazılır. Birden fazla goroutine bir paylaşılan veriyi okuyup eriştiğinde, özellikle bu tür sorunlar oluşur. Bunun için kilide ihtiyaç vardır.

Go'da sync paketi altındaki Mutex ve RWMutex karşılıklı dışlama kilidi ve okuma-yazma kilidi olmak üzere iki uygulama sağlar. Ve çok basit ve kullanışlı API'ler sağlar. Kilitleme sadece Lock() gerektirir ve kilidi açma da sadece Unlock() gerektirir. Unutulmaması gereken, Go'nun sağladığı kilitlerin özyinelemeli olmayan kilitler olduğudur. Yani yeniden girişli olmayan kilitlerdir. Bu nedenle tekrar kilitleme veya tekrar kilidi açma fatal'a neden olur. Kilidin anlamı değişmezleri korumaktır. Kilitleme, verinin diğer goroutine'ler tarafından değiştirilmemesini umar. Aşağıdaki gibi

go
func DoSomething() {
  Lock()
    // Bu süreçte, veri diğer goroutine'ler tarafından değiştirilmez
  Unlock()
}

Eğer özyinelemeli kilit olsaydı, aşağıdaki durum oluşabilirdi

go
func DoSomething() {
  Lock()
    DoOther()
  Unlock()
}

func DoOther() {
  Lock()
  // diğerini yap
  Unlock()
}

DoSomething fonksiyonu açıkça DoOther fonksiyonunun veriye ne yapabileceğini bilmez ve böylece veriyi değiştirir. Örneğin birkaç alt goroutine daha açarak değişmezi bozar. Bu Go'da çalışmaz. Bir kez kilitlendikten sonra değişmezin değişmezliğini garanti etmelidir. Bu sırada tekrar kilitleme ve kilidi açma deadlock'a neden olur. Bu nedenle kod yazarken yukarıdaki durumdan kaçınmalısınız. Gerekli olduğunda kilitleme ile birlikte hemen defer ifadesi kullanarak kilidi açın.

Karşılıklı Dışlama Kilidi

sync.Mutex, Go tarafından sağlanan karşılıklı dışlama kilidi uygulamasıdır. sync.Locker arayüzünü uygular

go
type Locker interface {
   // Kilitle
   Lock()
   // Kilidi aç
   Unlock()
}

Karşılıklı dışlama kilidi kullanarak yukarıdaki sorunu mükemmel bir şekilde çözebilirsiniz. Örnek aşağıdaki gibidir

go
var wait sync.WaitGroup
var count = 0

var lock sync.Mutex

func main() {
  wait.Add(10)
  for i := 0; i < 10; i++ {
    go func(data *int) {
      // Kilitle
      lock.Lock()
      // Erişim gecikmesi simülasyonu
      time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
      // Veriye eriş
      temp := *data
      // Hesaplama gecikmesi simülasyonu
      time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
      ans := 1
      // Veriyi değiştir
      *data = temp + ans
      // Kilidi aç
      lock.Unlock()
      fmt.Println(*data)
      wait.Done()
    }(&count)
  }
  wait.Wait()
  fmt.Println("Son sonuç", count)
}

Her goroutine veriye erişmeden önce kilitleme yapar. Güncelleme bittikten sonra kilidi açar. Diğer goroutine'ler erişmek istiyorsa önce kilidi almalıdır. Aksi takdirde engelleme bekler. Bu şekilde, yukarıdaki sorun oluşmaz. Bu nedenle çıktı şu şekildedir

1
2
3
4
5
6
7
8
9
10
Son sonuç 10

Okuma-Yazma Kilidi

Karşılıklı dışlama kilidi, okuma ve yazma işlemlerinin sıklığının benzer olduğu durumlar için uygundur. Okuma işlemlerinin yazma işlemlerinden çok daha fazla olduğu veriler için, karşılıklı dışlama kilidi kullanılırsa, çok sayıda gereksiz goroutine kilidi rekabeti oluşur. Bu çok fazla sistem kaynağı tüketir. Bu sırada okuma-yazma kilidine ihtiyaç vardır. Yani okuma-yazma karşılıklı dışlama kilidi. Bir goroutine için:

  • Eğer okuma kilidi alınırsa, diğer goroutine'ler yazma işlemi yaparken engellenir. Diğer goroutine'ler okuma işlemi yaparken engellenmez
  • Eğer yazma kilidi alınırsa, diğer goroutine'ler yazma işlemi yaparken engellenir. Diğer goroutine'ler okuma işlemi yaparken engellenir

Go'da okuma-yazma karşılıklı dışlama kilidi uygulaması sync.RWMutex'tir. Aynı zamanda Locker arayüzünü uygular. Ancak daha fazla kullanılabilir yöntem sağlar. Aşağıdaki gibi:

go
// Okuma kilidi al
func (rw *RWMutex) RLock()

// Okuma kilidi almayı dene
func (rw *RWMutex) TryRLock() bool

// Okuma kilidini aç
func (rw *RWMutex) RUnlock()

// Yazma kilidi al
func (rw *RWMutex) Lock()

// Yazma kilidi almayı dene
func (rw *RWMutex) TryLock() bool

// Yazma kilidini aç
func (rw *RWMutex) Unlock()

TryRlock ve TryLock iki deneme kilitleme işlemi engellemesizdir. Başarılı kilitleme true döndürür. Kilit alınamadığında engellemez, bunun yerine false döndürür. Okuma-yazma karşılıklı dışlama kilidi iç uygulaması hala karşılıklı dışlama kilididir. Okuma kilidi ve yazma kilidi diye iki kilit olduğu anlamına gelmez. Başından sonuna kadar sadece bir kilit vardır. Aşağıda okuma-yazma karşılıklı dışlama kilidi kullanım örneğine bakalım

go
var wait sync.WaitGroup
var count = 0

var rw sync.RWMutex

func main() {
  wait.Add(12)
  // Çok okuma az yazma
  go func() {
    for i := 0; i < 3; i++ {
      go Write(&count)
    }
    wait.Done()
  }()
  go func() {
    for i := 0; i < 7; i++ {
      go Read(&count)
    }
    wait.Done()
  }()
  // Alt goroutine'lerin bitmesini bekle
  wait.Wait()
  fmt.Println("Son sonuç", count)
}

func Read(i *int) {
  time.Sleep(time.Millisecond * time.Duration(rand.Intn(500)))
  rw.RLock()
  fmt.Println("Okuma kilidi alındı")
  time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
  fmt.Println("Okuma kilidi açıldı", *i)
  rw.RUnlock()
  wait.Done()
}

func Write(i *int) {
  time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
  rw.Lock()
  fmt.Println("Yazma kilidi alındı")
  temp := *i
  time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
  *i = temp + 1
  fmt.Println("Yazma kilidi açıldı", *i)
  rw.Unlock()
  wait.Done()
}

Bu örnek 3 yazma goroutine'i ve 7 okuma goroutine'i başlatır. Veri okurken önce okuma kilidi alırlar. Okuma goroutine'leri normal olarak okuma kilidi alabilir. Ancak yazma goroutine'lerini engeller. Yazma kilidi alındığında, hem okuma goroutine'lerini hem de yazma goroutine'lerini engeller. Yazma kilidi açılana kadar. Bu şekilde okuma goroutine'leri ile yazma goroutine'leri karşılıklı dışlama sağlar ve verinin doğruluğunu garanti eder. Örnek çıktısı şu şekildedir:

Okuma kilidi alındı
Okuma kilidi alındı
Okuma kilidi alındı
Okuma kilidi alındı
Okuma kilidi açıldı 0
Okuma kilidi açıldı 0
Okuma kilidi açıldı 0
Okuma kilidi açıldı 0
Yazma kilidi alındı
Yazma kilidi açıldı 1
Okuma kilidi alındı
Okuma kilidi alındı
Okuma kilidi alındı
Okuma kilidi açıldı 1
Okuma kilidi açıldı 1
Okuma kilidi açıldı 1
Yazma kilidi alındı
Yazma kilidi açıldı 2
Yazma kilidi alındı
Yazma kilidi açıldı 3
Son sonuç 3

::: ipucu

Kilit için, değer olarak iletilmemeli ve saklanmamalıdır. İşaretçi kullanılmalıdır.

:::

Koşul Değişkeni

Koşul değişkeni, karşılıklı dışlama kilidi ile birlikte ortaya çıkar ve kullanılır. Bu nedenle bazı kişiler yanlışlıkla koşul kilidi olarak adlandırabilir. Ancak kilit değildir, bir iletişim mekanizmasıdır. Go'da sync.Cond bunun için uygulama sağlar. Koşul değişkeni oluşturma fonksiyonunun imzası şu şekildedir:

go
func NewCond(l Locker) *Cond

Bir koşul değişkeni oluşturmanın ön koşulunun bir kilit oluşturmak olduğunu görebilirsiniz. sync.Cond kullanım için aşağıdaki yöntemleri sağlar

go
// Koşul geçerli olana kadar engelleme bekle, uyanana kadar
func (c *Cond) Wait()

// Koşul nedeniyle engellenen bir goroutine'i uyandır
func (c *Cond) Signal()

// Koşul nedeniyle engellenen tüm goroutine'leri uyandır
func (c *Cond) Broadcast()

Koşul değişkeni kullanımı çok basittir. Yukarıdaki okuma-yazma karşılıklı dışlama kilidi örneğini biraz değiştirerek kullanılabilir

go
var wait sync.WaitGroup
var count = 0

var rw sync.RWMutex

// Koşul değişkeni
var cond = sync.NewCond(rw.RLocker())

func main() {
  wait.Add(12)
  // Çok okuma az yazma
  go func() {
    for i := 0; i < 3; i++ {
      go Write(&count)
    }
    wait.Done()
  }()
  go func() {
    for i := 0; i < 7; i++ {
      go Read(&count)
    }
    wait.Done()
  }()
  // Alt goroutine'lerin bitmesini bekle
  wait.Wait()
  fmt.Println("Son sonuç", count)
}

func Read(i *int) {
  time.Sleep(time.Millisecond * time.Duration(rand.Intn(500)))
  rw.RLock()
  fmt.Println("Okuma kilidi alındı")
  // Koşul karşılanmazsa sürekli engelle
  for *i < 3 {
    cond.Wait()
  }
  time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
  fmt.Println("Okuma kilidi açıldı", *i)
  rw.RUnlock()
  wait.Done()
}

func Write(i *int) {
  time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
  rw.Lock()
  fmt.Println("Yazma kilidi alındı")
  temp := *i
  time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
  *i = temp + 1
  fmt.Println("Yazma kilidi açıldı", *i)
  rw.Unlock()
  // Koşul değişkeni nedeniyle engellenen tüm goroutine'leri uyandır
  cond.Broadcast()
  wait.Done()
}

Koşul değişkeni oluştururken, burada koşul değişkeni okuma goroutine'leri üzerinde çalıştığından, okuma kilidi karşılıklı dışlama kilidi olarak iletilir. Doğrudan okuma-yazma karşılıklı dışlama kilidi iletilirse, yazma goroutine'leri tekrar kilidi açma sorununa neden olur. Burada sync.rlocker iletilir. RWMutex.RLocker yöntemi ile alınır.

go
func (rw *RWMutex) RLocker() Locker {
   return (*rlocker)(rw)
}

type rlocker RWMutex

func (r *rlocker) Lock()   { (*RWMutex)(r).RLock() }
func (r *rlocker) Unlock() { (*RWMutex)(r).RUnlock() }

rlocker'ın sadece okuma-yazma karşılıklı dışlama kilidinin okuma kilidi işlemini paketlediğini görebilirsiniz. Aslında aynı referanstır ve hala aynı kilittir. Okuma goroutine'leri veri okurken, 3'ten küçükse sürekli engelleme bekler. Veri 3'ten büyük olana kadar. Yazma goroutine'leri veriyi güncelledikten sonra koşul değişkeni nedeniyle engellenen tüm goroutine'leri uyandırmayı dener. Bu nedenle son çıktı şu şekildedir

Okuma kilidi alındı
Okuma kilidi alındı
Okuma kilidi alındı
Okuma kilidi alındı
Yazma kilidi alındı
Yazma kilidi açıldı 1
Okuma kilidi alındı
Yazma kilidi alındı
Yazma kilidi açıldı 2
Okuma kilidi alındı
Okuma kilidi alındı
Yazma kilidi alındı
Yazma kilidi açıldı 3 // Üçüncü yazma goroutine'i çalışmayı bitirdi
Okuma kilidi açıldı 3
Okuma kilidi açıldı 3
Okuma kilidi açıldı 3
Okuma kilidi açıldı 3
Okuma kilidi açıldı 3
Okuma kilidi açıldı 3
Okuma kilidi açıldı 3
Son sonuç 3

Sonuçtan görülebileceği gibi, üçüncü yazma goroutine'i veriyi güncelledikten sonra, yedi okuma goroutine'i koşul değişkeni nedeniyle engellenmişti ve hepsi çalışmaya devam etti.

::: ipucu

Koşul değişkeni için, if yerine for kullanılmalıdır. Koşulun karşılanıp karşılanmadığını判断 etmek için döngü kullanılmalıdır. Çünkü goroutine uyandığında mevcut koşulun karşılandığı garanti edilemez.

go
for !condition {
  cond.Wait()
}

:::

sync

Go'da eşzamanlılıkla ilgili araçların çoğu sync standart kütüphanesi tarafından sağlanır. Yukarıda zaten sync.WaitGroup, sync.Locker vb. tanıtıldı. Bunun dışında, sync paketi altında kullanılabilecek başka araçlar da vardır.

Once

Bazı veri yapılarını kullanırken, bu veri yapıları çok büyükse, tembel yükleme yöntemi düşünülebilir. Yani gerçekten kullanılması gerektiğinde veri yapısı başlatılır. Aşağıdaki örnek gibi

go
type MySlice []int

func (m *MySlice) Get(i int) (int, bool) {
   if *m == nil {
      return 0, false
   } else {
      return (*m)[i], true
   }
}

func (m *MySlice) Add(i int) {
   // Slice gerçekten kullanıldığında başlatmayı düşün
   if *m == nil {
      *m = make([]int, 0, 10)
   }
   *m = append(*m, i)
}

Sorun şu ki, sadece bir goroutine kullanıyorsa kesinlikle sorun yoktur. Ancak birden fazla goroutine erişirse sorun oluşabilir. Örneğin goroutine A ve B aynı anda Add yöntemini çağırır. A biraz daha hızlı çalışır, başlatmayı tamamlar ve veriyi başarıyla ekler. Ardından goroutine B tekrar başlatır. Bu şekilde goroutine A'nın eklediği veri doğrudan üzerine yazılır. İşte sorun budur.

sync.Once'ın çözmek istediği sorun budur. Adından da anlaşılacağı gibi, Once bir kez anlamına gelir. sync.Once, eşzamanlılık koşullarında belirtilen işlemin sadece bir kez çalıştırılacağını garanti eder. Kullanımı çok basittir, sadece bir Do yöntemini dışarıya açar. İmza şu şekildedir:

go
func (o *Once) Do(f func())

Kullanırken, başlatma işlemini Do yöntemine iletmeniz yeterlidir. Aşağıdaki gibi

go
var wait sync.WaitGroup

func main() {
  var slice MySlice
  wait.Add(4)
  for i := 0; i < 4; i++ {
    go func() {
      slice.Add(1)
      wait.Done()
    }()
  }
  wait.Wait()
  fmt.Println(slice.Len())
}

type MySlice struct {
  s []int
  o sync.Once
}

func (m *MySlice) Get(i int) (int, bool) {
  if m.s == nil {
    return 0, false
  } else {
    return m.s[i], true
  }
}

func (m *MySlice) Add(i int) {
  // Slice gerçekten kullanıldığında başlatmayı düşün
  m.o.Do(func() {
    fmt.Println("Başlatma")
    if m.s == nil {
      m.s = make([]int, 0, 10)
    }
  })
  m.s = append(m.s, i)
}

func (m *MySlice) Len() int {
  return len(m.s)
}

Çıktı şu şekildedir

Başlatma
4

Çıktı sonucundan görülebileceği gibi, tüm veriler normal olarak slice'e eklenir ve başlatma işlemi sadece bir kez çalıştırılır. Aslında sync.Once uygulaması oldukça basittir. Yorumlar kaldırıldığında gerçek kod mantığı sadece 16 satırdır. Prensibi kilit + atomik işlemdir. Kaynak kodu şu şekildedir:

go
type Once struct {
    // İşlemin çalıştırılıp çalıştırılmadığını判断 etmek için kullanılır
  done uint32
  m    Mutex
}

func (o *Once) Do(f func()) {
  // Atomik yükleme verisi
  if atomic.LoadUint32(&o.done) == 0 {
    o.doSlow(f)
  }
}

func (o *Once) doSlow(f func()) {
    // Kilitle
  o.m.Lock()
    // Kilidi aç
  defer o.m.Unlock()
    // Çalıştırılıp çalıştırılmadığını判断 et
  if o.done == 0 {
        // Çalıştırma bittikten sonra done'ı değiştir
    defer atomic.StoreUint32(&o.done, 1)
    f()
  }
}

Pool

sync.Pool tasarım amacı, geçici nesneleri depolamak ve sonraki yeniden kullanım içindir. Geçici bir eşzamanlı güvenli nesne havuzudur. Geçici olarak kullanılmayan nesneler havuza konur. Sonraki kullanımda ekstra nesne oluşturmak yerine doğrudan yeniden kullanılabilir. Bellek tahsisi ve serbest bırakma sıklığını azaltır. En önemlisi GC baskısını azaltır. sync.Pool toplamda sadece iki yönteme sahiptir. Aşağıdaki gibi:

go
// Bir nesne talep et
func (p *Pool) Get() any

// Bir nesne koy
func (p *Pool) Put(x any)

Ve sync.Pool'un dışarıya açık bir New alanı vardır. Nesne havuzu nesne talep edemediğinde bir nesne başlatmak için kullanılır

go
New func() any

Aşağıda bir örnek ile gösterilmektedir

go
var wait sync.WaitGroup

// Geçici nesne havuzu
var pool sync.Pool

// Toplamda kaç nesne oluşturulduğunu saymak için kullanılır
var numOfObject atomic.Int64

// BigMemData, büyük bellek kaplayan bir yapı olduğunu varsayalım
type BigMemData struct {
   M string
}

func main() {
   pool.New = func() any {
      numOfObject.Add(1)
      return BigMemData{"Büyük bellek"}
   }
   wait.Add(1000)
   // Burada 1000 goroutine başlatılıyor
   for i := 0; i < 1000; i++ {
      go func() {
         // Nesne talep et
         val := pool.Get()
         // Nesneyi kullan
         _ = val.(BigMemData)
         // Kullanım bittikten sonra nesneyi havuza geri koy
         pool.Put(val)
         wait.Done()
      }()
   }
   wait.Wait()
   fmt.Println(numOfObject.Load())
}

Örnekte 1000 goroutine sürekli olarak havuzdan nesne talep eder ve serbest bırakır. Nesne havuzu kullanılmazsa, 1000 goroutine'in her biri nesneyi örneklemelidir. Ve bu 1000 örneklenen nesne kullanım bittikten sonra GC tarafından bellek serbest bırakılmalıdır. On binlerce goroutine varsa veya nesne oluşturma maliyeti çok yüksekse, bu durumda çok fazla bellek kaplar ve GC'ye çok fazla baskı yapar. Nesne havuzu kullanıldıktan sonra, nesneler yeniden kullanılabilir ve örnekleme sıklığı azaltılabilir. Yukarıdaki örnek çıktısı şu şekilde olabilir:

5

1000 goroutine başlatılsa bile, tüm süreçte sadece 5 nesne oluşturulur. Nesne havuzu kullanılmazsa 1000 goroutine 1000 nesne oluşturur. Bu optimizasyonun getirdiği iyileştirme açıktır. Özellikle eşzamanlılık çok büyük olduğunda ve nesne örnekleme maliyeti çok yüksek olduğunda avantaj daha belirgindir.

sync.Pool kullanırken birkaç noktaya dikkat edilmelidir:

  • Geçici nesne: sync.Pool sadece geçici nesneleri depolamak için uygundur. Havuzdaki nesneler herhangi bir bildirim olmadan GC tarafından kaldırılabilir. Bu nedenle ağ bağlantısı, veritabanı bağlantısı gibi nesnelerin sync.Pool'a konması önerilmez.
  • Tahmin edilemez: sync.Pool nesne talep ederken, nesnenin yeni oluşturulan mı yoksa yeniden kullanılan mı olduğunu tahmin edemez. Havuzda kaç nesne olduğunu da bilemez
  • Eşzamanlı güvenlik: Resmi sync.Pool'un kesinlikle eşzamanlı güvenli olduğunu garanti eder. Ancak nesne oluşturmak için kullanılan New fonksiyonunun eşzamanlı güvenli olduğunu garanti etmez. New fonksiyonu kullanıcı tarafından iletilir. Bu nedenle New fonksiyonunun eşzamanlı güvenliği kullanıcı tarafından korunmalıdır. Yukarıdaki örnekte nesne sayımının atomik değer kullanmasının nedeni de budur.

::: ipucu

Son olarak dikkat edilmesi gereken, nesne kullanıldıktan sonra mutlaka havuza geri konulmalıdır. Kullanıldıktan sonra serbest bırakılmazsa, nesne havuzunun kullanımı anlamsız olur.

:::

Standart kütüphane fmt paketi altında bir nesne havuzu kullanım örneği vardır. fmt.Fprintf fonksiyonunda

go
func Fprintf(w io.Writer, format string, a ...any) (n int, err error) {
   // Bir yazdırma arabelleği talep et
   p := newPrinter()
   p.doPrintf(format, a)
   n, err = w.Write(p.buf)
   // Kullanım bittikten sonra serbest bırak
   p.free()
   return
}

newPointer fonksiyonu ve free yönteminin uygulaması şu şekildedir

go
func newPrinter() *pp {
   // Nesne havuzundan bir nesne talep et
   p := ppFree.Get().(*pp)
   p.panicking = false
   p.erroring = false
   p.wrapErrs = false
   p.fmt.init(&p.buf)
   return p
}

func (p *pp) free() {
    // Nesne havuzundaki arabellek boyutunun yaklaşık olarak aynı olması için
    // Daha iyi esnek kontrol için çok büyük arabellekler nesne havuzuna geri konmaz
  if cap(p.buf) > 64<<10 {
    return
  }
  // Alanlar sıfırlandıktan sonra nesneyi havuza serbest bırak
  p.buf = p.buf[:0]
  p.arg = nil
  p.value = reflect.Value{}
  p.wrappedErr = nil
  ppFree.Put(p)
}

Map

sync.Map, resmi tarafından sağlanan eşzamanlı güvenli Map uygulamasıdır. Kutudan çıkar çıkmaz kullanılabilir ve kullanımı çok basittir. Aşağıda bu yapının dışarıya açık yöntemleri bulunmaktadır:

go
// Bir anahtara göre değer oku, dönüş değeri karşılık gelen değeri ve değerin var olup olmadığını döndürür
func (m *Map) Load(key any) (value any, ok bool)

// Bir anahtar-değer çifti sakla
func (m *Map) Store(key, value any)

// Bir anahtar-değer çiftini sil
func (m *Map) Delete(key any)

// Eğer anahtar zaten varsa, mevcut değeri döndür. Aksi takdirde yeni değeri sakla ve döndür. Değer başarıyla okunduğunda, loaded true olur, aksi takdirde false olur
func (m *Map) LoadOrStore(key, value any) (actual any, loaded bool)

// Bir anahtar-değer çiftini sil ve mevcut değerini döndür. loaded değeri anahtarın var olup olmadığına bağlıdır
func (m *Map) LoadAndDelete(key any) (value any, loaded bool)

// Map'i yinele, f() false döndürdüğünde, yinelemeyi durdur
func (m *Map) Range(f func(key, value any) bool)

Aşağıda sync.Map temel kullanımını gösteren basit bir örnek bulunmaktadır

go
func main() {
  var syncMap sync.Map
  // Veri sakla
  syncMap.Store("a", 1)
  syncMap.Store("a", "a")
  // Veri oku
  fmt.Println(syncMap.Load("a"))
  // Oku ve sil
  fmt.Println(syncMap.LoadAndDelete("a"))
  // Oku veya sakla
  fmt.Println(syncMap.LoadOrStore("a", "hello world"))
  syncMap.Store("b", "goodbye world")
  // Map'i yinele
  syncMap.Range(func(key, value any) bool {
    fmt.Println(key, value)
    return true
  })
}

Çıktı

a true
a true
hello world false
a hello world
b goodbye world

Ardından eşzamanlı map kullanım örneğine bakalım:

go
func main() {
  myMap := make(map[int]int, 10)
  var wait sync.WaitGroup
  wait.Add(10)
  for i := 0; i < 10; i++ {
    go func(n int) {
      for i := 0; i < 100; i++ {
        myMap[n] = n
      }
      wait.Done()
    }(i)
  }
  wait.Wait()
}

Yukarıdaki örnekte normal map kullanılır. 10 goroutine sürekli veri saklar. Açıkçası bu büyük olasılıkla fatal'ı tetikler. Sonuç muhtemelen şu şekildedir

fatal error: concurrent map writes

sync.Map kullanarak bu sorundan kaçınabilirsiniz

go
func main() {
  var syncMap sync.Map
  var wait sync.WaitGroup
  wait.Add(10)
  for i := 0; i < 10; i++ {
    go func(n int) {
      for i := 0; i < 100; i++ {
        syncMap.Store(n, n)
      }
      wait.Done()
    }(i)
  }
  wait.Wait()
  syncMap.Range(func(key, value any) bool {
    fmt.Println(key, value)
    return true
  })
}

Çıktı şu şekildedir

8 8
3 3
1 1
9 9
6 6
5 5
7 7
0 0
2 2
4 4

Eşzamanlı güvenlik için kesinlikle bazı fedakarlıklar yapılmalıdır. sync.Map performansı map'ten 10-100 kat daha düşüktür.

Atomik

Bilgisayar biliminde, atom veya ilkel işlem, genellikle daha fazla bölünemeyen işlemleri ifade eder. Bu işlemler daha küçük adımlara bölünemediğinden, çalıştırılmadan önce başka herhangi bir goroutine tarafından kesilemez. Bu nedenle çalıştırma sonucu ya başarılı olur ya da başarısız. Üçüncü bir durum söz konusu değildir. Başka bir durum ortaya çıkarsa, atomik işlem değildir. Aşağıdaki kod örneği:

go
func main() {
  a := 0
  if a == 0 {
    a = 1
  }
  fmt.Println(a)
}

Yukarıdaki kod basit bir判断 dalıdır. Kod çok az olsa da, atomik işlem değildir. Gerçek atomik işlem donanım komut düzeyi tarafından desteklenir.

Tür

Neyse ki çoğu durumda汇编 yazmaya gerek yoktur. Go standart kütüphanesi sync/atomic paketi atomik işlemle ilgili API'ler sağlar. Atomik işlem için aşağıdaki türleri sağlar.

go
atomic.Bool{}
atomic.Pointer[]{}
atomic.Int32{}
atomic.Int64{}
atomic.Uint32{}
atomic.Uint64{}
atomic.Uintptr{}
atomic.Value{}

Pointer atomik türü jenerikleri destekler. Value türü herhangi bir türü depolamayı destekler. Bunun dışında, işlemi kolaylaştırmak için birçok fonksiyon sağlar. Atomik işlemin granülaritesi çok ince olduğundan, çoğu durumda bu temel veri türlerini işlemek için daha uygundur.

::: ipucu

atomic paketi altındaki atomik işlemler sadece fonksiyon imzasına sahiptir, somut uygulama yoktur. Somut uygulama plan9汇编 ile yazılmıştır.

:::

Kullanım

Her atomik tür aşağıdaki üç yöntemi sağlar:

  • Load(): Atomik olarak değer al
  • Swap(newVal type) (old type): Atomik olarak değer değiştir ve eski değeri döndür
  • Store(val type): Atomik olarak değer sakla

Farklı türler için başka ek yöntemler de olabilir. Örneğin tamsayı türleri atomik toplama/çıkarma işlemi için Add yöntemi sağlar. Aşağıda int64 türü örneği gösterilmektedir:

go
func main() {
  var aint64 atomic.Uint64
  // Değer sakla
  aint64.Store(64)
  // Değer değiştir
  aint64.Swap(128)
  // Artır
  aint64.Add(112)
    // Değer yükle
  fmt.Println(aint64.Load())
}

Veya doğrudan fonksiyon kullanabilirsiniz

go
func main() {
   var aint64 int64
   // Değer sakla
   atomic.StoreInt64(&aint64, 64)
   // Değer değiştir
   atomic.SwapInt64(&aint64, 128)
   // Artır
   atomic.AddInt64(&aint64, 112)
   // Yükle
   fmt.Println(atomic.LoadInt64(&aint64))
}

Diğer türlerin kullanımı da çok benzerdir. Son çıktı şu şekildedir:

240

CAS

atomic paketi CompareAndSwap işlemi sağlar. Yani CAS. İyimser kilit ve kilitsiz veri yapısı uygulamasının çekirdeğidir. İyimser kilit kendisi bir kilit değildir. Eşzamanlılık koşullarında kilitsiz eşzamanlılık kontrol yöntemidir: İş parçacığı/goroutine veriyi değiştirmeden önce, önce kilitlemez. Bunun yerine önce veriyi okur, hesaplama yapar. Ardından değişikliği gönderirken CAS kullanarak bu sırada başka bir iş parçacığının veriyi değiştirip değiştirmediğini判断 eder. Değiştirilmediyse (değer hala önce okunan değere eşitse), değişiklik başarılı olur. Aksi takdirde başarısız olur ve yeniden dener. Bu nedenle iyimser kilit olarak adlandırılmasının nedeni, her zaman paylaşılan verinin değiştirilmeyeceğini iyimser bir şekilde varsaymasıdır. Sadece verinin değiştirilmediği bulunduğunda karşılık gelen işlemi yapar. Önce öğrenilen karşılıklı dışlama miktarı ise kötümser kilittir. Karşılıklı dışlama miktarı her zaman paylaşılan verinin değiştirileceğini kötümser bir şekilde varsayar. Bu nedenle işlem sırasında kilitleme yapar ve işlem bittikten sonra kilidi açar. Kilitsiz uygulamanın eşzamanlılığı, kilide göre güvenliği ve verimliliği daha yüksektir. Birçok eşzamanlı güvenli veri yapısı CAS kullanarak uygulanır. Ancak gerçek verimlilik belirli kullanım senaryosuna bağlıdır. Aşağıdaki örneğe bakalım:

go
var lock sync.Mutex

var count int

func Add(num int) {
   lock.Lock()
   count += num
   lock.Unlock()
}

Bu karşılıklı dışlama kilidi kullanan bir örnektir. Her sayı artırılmadan önce kilitlenir ve çalıştırma bittikten sonra kilidi açılır. Süreçte diğer goroutine'lerin engellenmesine neden olur. Ardından CAS kullanarak dönüştürelim:

go
var count int64

func Add(num int64) {
  for {
    expect := atomic.LoadInt64(&count)
    if atomic.CompareAndSwapInt64(&count, expect, expect+num) {
      break
    }
  }
}

CAS için üç parametre vardır. Bellek değeri, beklenen değer, yeni değer. Çalıştırılırken, CAS beklenen değeri mevcut bellek değeri ile karşılaştırır. Bellek değeri beklenen değerle aynıysa, sonraki işlemi yapar. Aksi takdirde hiçbir şey yapmaz. Go'da atomic paketi altındaki atomik işlemler için, CAS ilgili fonksiyonlar adres, beklenen değer, yeni değer gerektirir. Ve başarılı değiştirme boolean değerini döndürür. Örneğin int64 türü CAS işlem fonksiyon imzası şu şekildedir:

go
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)

CAS örneğinde, önce LoadInt64 kullanarak beklenen değeri alırız. Ardından CompareAndSwapInt64 kullanarak karşılaştırma ve değiştirme yaparız. Başarısız olursa sürekli döngü yaparız, başarılı olana kadar. Bu kilitsiz işlem goroutine'i engellemez. Ancak sürekli döngü CPU için azımsanmayacak bir yük oluşturur. Bu nedenle bazı uygulamalarda başarısızlık belirli bir sayıya ulaştığında işlem vazgeçilebilir. Ancak yukarıdaki işlem için, sadece basit sayı toplama söz konusudur,涉及 işlem karmaşık değildir. Bu nedenle kilitsiz uygulama düşünülebilir.

::: ipucu

Çoğu durumda, sadece değeri karşılaştırmak eşzamanlı güvenlik sağlayamaz. Örneğin CAS'ın neden olduğu ABA sorunu, version eklenerek çözülmelidir.

:::

Value

atomic.Value yapısı, herhangi bir türde değer depolayabilir. Yapı şu şekildedir

go
type Value struct {
   // any türü
   v any
}

Herhangi bir türü depolayabilse de, nil depolayamaz. Ve前后 depolanan değerlerin türü tutarlı olmalıdır. Aşağıdaki iki örnek derleme başarısız olur

go
func main() {
   var val atomic.Value
   val.Store(nil)
   fmt.Println(val.Load())
}
// panic: sync/atomic: store of nil value into Value
go
func main() {
   var val atomic.Value
   val.Store("hello world")
   val.Store(114514)
   fmt.Println(val.Load())
}
// panic: sync/atomic: store of inconsistently typed value into Value

Bunun dışında, kullanımı diğer atomik türlerden çok farklı değildir. Dikkat edilmesi gereken, tüm atomik türler değer kopyalamamalıdır. Bunun yerine işaretçileri kullanılmalıdır.

Golang by www.golangdev.cn edit