Zum Hauptinhalt springen

Syncronizing read/write - mutex in a Game example

faulty code

Given the following short "game":

package main

import (
"fmt"
"time"
"math/rand"
)

type Player struct{
health int
}

func NewPlayer() *Player{
return &Player{health:100}
}

func startUiLoop(p *Player){
ticker := time.NewTicker(time.Second)
for{
fmt.Printf("player health: %d\r", p.health)
<-ticker.C
}
}

func StartGameLoop(p *Player){
ticker := time.NewTicker(tprocessime.Millisecond*300)
for{

if p.health <= 0{
fmt.Println("Game Over, health = 0")
break
}
<-ticker.C
}
}

func main(){
player := NewPlayer()
go startUiLoop(player)
StartGameLoop(player)
}
  • if we run go test with the following everything seems fine
package main
import "testing"

func TestGame(t *testing.T){
player := NewPlayer()
go startUiLoop(player)
StartGameLoop(player)
}
  • but actually there is a race condition we can expose with:
  • go test --race the inbuild golang race-detection.
  • in the above code it could have been, that the player was already on other values but the ui was displaying wrong values without any clear indication.

fixed code

After we change the following, we remove the possible race condition:


type Player struct{
health int
mu sync.RWMutex //RW-Read-Write Mutex more optimized than the Mutex that just locks everything
}

func startUiLoop(p *Player){
ticker := time.NewTicker(time.Second)
for{
// reading only. from the "state" -> so read-lock it for the process
p.mu.RLock()
fmt.Printf("player health: %d\r", p.health)
p.mu.RUnlock()

<-ticker.C
}
}

func StartGameLoop(p *Player){
ticker := time.NewTicker(time.Millisecond*300)
for{
p.mu.Lock() // NOTICE Lock != RLock()
p.health -= rand.Intn(20) // adjusting the "state" here
if p.health <= 0{ // reading from the state here
fmt.Println("Game Over, health = 0")
break
}
p.mu.Unlock()

<-ticker.C
}
}

some refactoring

// introducing the getter-Setter functions we can abstract away responsibilites:
func (p *Player) getHealth() int {
p.mu.RLock()
defer p.mu.RUnlock()
return p.health
}

func (p *Player) changeHealth(change int) {
p.mu.Lock()
defer p.mu.Unlock()
p.health += change
}

// now the concern of the loops is clearer and easier to read:
func startUiLoop(p *Player) {
ticker := time.NewTicker(time.Second)
for {
fmt.Printf("player health: %d\r", p.getHealth())
<-ticker.C
}
}

func StartGameLoop(p *Player) {
ticker := time.NewTicker(time.Millisecond * 300)
for {
p.changeHealth(-1 * rand.Intn(20))
if p.getHealth() <= 0 {
fmt.Println("Game Over, health = 0")
break
}
<-ticker.C
}
}

atomic-values another solution to the problem:

There can only happen one atomic Process at a time.

  • so this is another way to avoid a race condition by reading/writing at the same time

  • pros: less overhead and complexity, or setup required compared to a mutex

type Player struct {
health int32
}

func (p *Player) getHealth() int {
return int(atomic.LoadInt32(&p.health))
}

func (p *Player) changeHealth(change int) {
health := p.getHealth()
atomic.StoreInt32(&p.health, int32(health+change))
}