Golang中的競態條件

編寫多線程程序是一項重要的工作,需要在編寫前規劃。如果您使用的是單線程語言,例如 JavaScript,瞭解基礎知識就行了。但如果您熟悉 C 或 C++ 等服務端編程語言,他們多線程概念是相似的,用法略有區別。在這篇博文中,我想解釋競態條件是如何發生的,以及如何使用 Golang 實現同步數據訪問。

什麼是競態條件?

當多個線程嘗試訪問和修改相同的數據(內存地址)時,就會出現競態條件。例如,如果一個線程試圖增加一個整數而另一個線程試圖讀取它,這將導致競態條件。另一方面,如果變量是隻讀的,就不會有競態條件。在 golang 中,當使用 Goroutines 時,線程是隱式創建的。

讓我們嘗試創建一個競態條件。最簡單的方法是使用多個 goroutines,並且至少一個 goroutines 必須寫入共享變量。

以下代碼演示了一種創建競態條件的簡單方法。

  • goroutine 讀取名爲“sharedInt”的變量
  • 另一個 goroutine 通過增加它的值來寫入同一個變量。
package main

import "time"

// This is an example of race condition
// 2 goroutines tries to read&write sharedInt and there is no access control.

var sharedInt int = 0
var unusedValue int = 0

func runSimpleReader() {
 for {
  var val int = sharedInt
  if val%10 == 0 {
   unusedValue = unusedValue + 1
  }
 }
}

func runSimpleWriter() {
 for {
  sharedInt = sharedInt + 1
 }
}

func startSimpleReadWrite() {
 go runSimpleReader()
 go runSimpleWriter()
 time.Sleep(10 * time.Second)
}

如果您運行此代碼,並不會導致崩潰,但讀 goroutine 會訪問“sharedInt”的過時副本。如果你使用內置的競態條件檢查器運行代碼,go 編譯器會提示這個問題。

go run -race .

關於 Golang 競態條件檢查器的一個小說明:如果您的代碼偶爾訪問共享變量,它可能無法檢測到競態條件。爲了檢測它,代碼應該在高負載下運行,並且必鬚髮生競態條件。

您可以看到競態條件檢查器的輸出。它提示數據訪問不同步。

我們如何解決這個問題?如果共享數據是單個變量,我們可以使用sync/atomic包中提供的計數器。在下面的示例中,我們可以使用atomic.LoadInt64()/atomic.AddInt64()對來訪問它,而不是直接訪問共享變量。競態條件檢查器將不再提示不同步的數據訪問。

package main

import (
 "sync/atomic"
 "time"
)

var sharedIntForAtomic int64 = 0
var unusedValueForAtomic int = 0

func runAtomicReader() {
 for {
  var val int64 = atomic.LoadInt64(&sharedIntForAtomic)
  if val%10 == 0 {
   unusedValueForAtomic = unusedValueForAtomic + 1
  }
 }
}

func runAtomicWriter() {
 for {
  atomic.AddInt64(&sharedIntForAtomic, 1)
 }
}

func startAtomicReadWrite() {
 go runAtomicReader()
 go runAtomicWriter()
 time.Sleep(10 * time.Second)
}

這解決了我們在使用原始變量時的問題,但是在很多情況下我們需要訪問多個變量並使用複雜的數據結構。在這些情況下,使用互斥鎖來控制訪問更容易解決問題。

以下示例演示了對Map的非同步訪問。使用複雜的數據結構時,競態條件可能會導致崩潰。因此,如果我們在沒有啓用競態檢查的情況下運行此示例,go 運行時將提示併發訪問,並且進程將退出。

fatal error: concurrent map read and map write
package main

import "time"

// This is an example of race condition
// 2 goroutines tries to read&write sharedMap and there is no access control.
// This code should raise a panic condition

var sharedMap map[string]int = map[string]int{}

func runSimpleMapReader() {
 for {
  var _ int = sharedMap["key"]
 }
}

func runSimpleMapWriter() {
 for {
  sharedMap["key"] = sharedMap["key"] + 1
 }
}

func startMapReadWrite() {
 sharedMap["key"] = 0

 go runSimpleMapReader()
 go runSimpleMapWriter()
 time.Sleep(10 * time.Second)
}

可以通過控制對臨界區的訪問來解決此問題。在這個例子中,臨界區是我們讀寫“sharedMap”的地方。在下面的示例中,我們調用mutex.Lock()mutex.Unlock()對來控制訪問。

互斥鎖是如何工作的?

  • 互斥鎖是在解鎖狀態下創建的。
  • 當第一次調用 mutex.Lock() 時,互斥鎖狀態更改爲 Locked。
  • 對 mutex.Lock() 的任何其他調用都將阻塞 goroutine,直到調用 mutex.Unlock()
  • 因此,只有一個線程可以訪問臨界區。

例如,我們可以使用互斥鎖來控制對臨界區的訪問。我添加了一個上下文來在工作 2 秒後取消 goroutine。

package main

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

var sharedMapForMutex map[string]int = map[string]int{}
var mapMutex = sync.Mutex{}
var mutexReadCount int64 = 0

func runMapMutexReader(ctx context.Context, readChan chan int) {
 readCount := 0
 for {
  select {
  case <-ctx.Done():
   fmt.Println("reader exiting...")
   readChan <- readCount
   return
  default:
   mapMutex.Lock()
   var _ int = sharedMapForMutex["key"]
   mapMutex.Unlock()
   readCount += 1
  }
 }
}

func runMapMutexWriter(ctx context.Context) {
 for {
  select {
  case <-ctx.Done():
   fmt.Println("writer exiting...")
   return
  default:
   mapMutex.Lock()
   sharedMapForMutex["key"] = sharedMapForMutex["key"] + 1
   mapMutex.Unlock()
   time.Sleep(100 * time.Millisecond)
  }
 }
}

func startMapMutexReadWrite() {
 testContext, cancel := context.WithCancel(context.Background())

 readCh := make(chan int)
 sharedMapForMutex["key"] = 0

 numberOfReaders := 15
 for i := 0; i < numberOfReaders; i++ {
  go runMapMutexReader(testContext, readCh)
 }
 go runMapMutexWriter(testContext)

 time.Sleep(2 * time.Second)

 cancel()

 totalReadCount := 0
 for i := 0; i < numberOfReaders; i++ {
  totalReadCount += <-readCh
 }

 time.Sleep(1 * time.Second)

 var counter int = sharedMapForMutex["key"]
 fmt.Printf("[MUTEX] Write Counter value: %v\n", counter)
 fmt.Printf("[MUTEX] Read Counter value: %v\n", totalReadCount)
}

如果我們運行示例代碼,go 運行時將不再提示併發讀取和寫入問題,因爲一次只有一個 goroutine 可以訪問臨界區。在示例中,我使用了 15 個讀取器 goroutine 和一個寫入器 goroutine。每 100 毫秒更新一次“sharedMap”。在這種情況下,最好使用 RWMutex(讀/寫互斥鎖)。它類似於互斥鎖,但它還有另一種鎖定機制,可以讓多個讀者在安全的情況下訪問臨界區。當寫入很少並且讀取更常見時,這可能會表現得更好。

RWMutex 是如何工作的?

  • 簡單來說,如果沒有寫入者,多個讀可以同時訪問臨界區。如果寫入者試圖訪問臨界區,則所有讀取都會被阻止。當寫入很少並且讀取很常見時,這會更有效。
  • rwMutex.Lock() 和 rwMutex.Unlock() 的工作方式類似於互斥鎖-解鎖機制。
  • 如果互斥鎖處於解鎖狀態,rwMutex.RLock() 不會阻止任何讀取器。這允許多個讀者同時訪問臨界區。
  • 當 rwMutex.Lock() 被調用時;調用者被阻塞,直到所有讀者都調用 rwMutex.RUnlock()。此時,任何對 RLock() 的調用都開始阻塞,直到調用 rwMutex.Unlock()。這可以防止發生任何飢餓。
  • 當 rwMutex.Unlock() 被調用時;RLock() 的所有調用者都被解除阻塞並且可以訪問臨界區。
package main

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

var sharedMapForRWMutex map[string]int = map[string]int{}
var mapRWMutex = sync.RWMutex{}
var rwMutexReadCount int64 = 0

func runMapRWMutexReader(ctx context.Context, readChan chan int) {
 readCount := 0
 for {
  select {
  case <-ctx.Done():
   fmt.Println("reader exiting...")
   readChan <- readCount
   return
  default:
   mapRWMutex.RLock()
   var _ int = sharedMapForRWMutex["key"]
   mapRWMutex.RUnlock()
   readCount += 1
  }
 }
}

func runMapRWMutexWriter(ctx context.Context) {
 for {
  select {
  case <-ctx.Done():
   fmt.Println("writer exiting...")
   return
  default:
   mapRWMutex.Lock()
   sharedMapForRWMutex["key"] = sharedMapForRWMutex["key"] + 1
   mapRWMutex.Unlock()
   time.Sleep(100 * time.Millisecond)
  }
 }
}

func startMapRWMutexReadWrite() {
 testContext, cancel := context.WithCancel(context.Background())

 readCh := make(chan int)
 sharedMapForRWMutex["key"] = 0

 numberOfReaders := 15
 for i := 0; i < numberOfReaders; i++ {
  go runMapRWMutexReader(testContext, readCh)
 }
 go runMapRWMutexWriter(testContext)

 time.Sleep(2 * time.Second)

 cancel()

 totalReadCount := 0
 for i := 0; i < numberOfReaders; i++ {
  totalReadCount += <-readCh
 }

 time.Sleep(1 * time.Second)

 var counter int = sharedMapForRWMutex["key"]
 fmt.Printf("[RW MUTEX] Write Counter value: %v\n", counter)
 fmt.Printf("[RW MUTEX] Read Counter value: %v\n", totalReadCount)
}

Mutex 與 RWMutex 性能對比

我運行了五次示例並比較了平均值。結果,RWMutex 執行的讀取操作增加了 14.35%。但請注意,這個例子是在特定場景下進行的,因爲有 15 個讀取器 goroutine 和一個寫入器 goroutine。

總結

在這篇博文中,我試圖回顧導致競爭條件的非同步數據訪問的基礎知識,來進一步討論如何避免競態條件的發生問題。根據我的個人經驗,我更願意讓每個 goroutine 上下文中使用自己的局部變量,並通過使用通道傳播數據。設計通過通道或隊列進行通信的單線程組件更容易。如果這種方法並不適用於你的場景,這時互斥鎖可以派上用場。

推薦

K8s Pod優雅關閉,沒你想象的那麼簡單!

分享下雲原生技術之外的另類話題



原創不易,隨手關注或者”在看“,誠摯感謝!

本文分享自微信公衆號 - 雲原生技術愛好者社區(programmer_java)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章