Golang When should Atomic be used instead of Mutex?

eye-catch Golang

I have used sync.atomic for the first time in my work. It’s lighter than Mutex. If the program requires speed and it doesn’t require a function call, atomic is an alternative way of Mutex.

Sponsored links

How to use atomic for int value

Let’s look at the example first. It’s easy. It contains sync.WaitGroup in order to wait for all the goroutines.

import (
    "sync"
    "sync/atomic"
)


func WriteInGoroutine() int64 {
    var ops atomic.Int64    // atomic here
    var wg sync.WaitGroup

    for i := 0; i < 50; i++ {
        wg.Add(1)
        go func() {
            for c := 0; c < 1000; c++ {
                ops.Add(1)  // same as ops += 1
            }
            wg.Done()
        }()
    }

    wg.Wait()

    return ops.Load()   // return 50000
}

If ops is a normal int64 data type, the result won’t be 50000 because multiple goroutines try to update the value at the same time without interacting with other goroutines.

By the way, atomic struct has the following methods. The methods that are often used are Add to update data, Load to read it, and Store to assign a new value or initialize.

func (x *Int64) Add(delta int64) (new int64)
func (x *Int64) CompareAndSwap(old, new int64) (swapped bool)
func (x *Int64) Load() int64
func (x *Int64) Store(val int64)
func (x *Int64) Swap(new int64) (old int64)

It’s easy enough.

Sponsored links

Can a normal int value be updated in the atomic way?

There might be a case where we declare a variable and don’t want to change the data type so that the existing code can still be used without modification.

Use e.g. atomic.AddInt64 function in this case.

func WriteInGoroutine2() int64 {
    var ops int64 // normal int64 value
    var wg sync.WaitGroup

    for i := 0; i < 50; i++ {
        wg.Add(1)
        go func() {
            for c := 0; c < 1000; c++ {
                atomic.AddInt64(&ops, 1) // static function
            }
            wg.Done()
        }()
    }

    wg.Wait()

    return ops // 50000
}

In this way, we can keep the existing code but update the value in an atomic way.

Handling struct with atomic

We can use atomic.Value type if we want to use struct. Assign the value by using Store() method. The data can be read by Load() method.

func storeDifferentType() {
    fmt.Println("--- storeDifferentType ---")

    var value atomic.Value
    fmt.Println(value.Load()) // nil
    myData := myStruct{name: "Yuto", age: 36}

    value.Store(myData)
    loadedData := value.Load()
    fmt.Println(loadedData) // { name: Yuto, age: 36 }
    fmt.Println(loadedData.(myStruct)) // { name: Yuto, age: 36 }
}

atomic.Value doesn’t have a data type. It looks like a different value can be assigned but it’s of course not possible.

func storeDifferentType() {
    fmt.Println("--- storeDifferentType ---")

    var value atomic.Value
    fmt.Println(value.Load()) // nil
    myData := myStruct{name: "Yuto", age: 36}

    value.Store(myData)
    loadedData := value.Load()
    fmt.Println(loadedData) // { name: Yuto, age: 36 }
    fmt.Println(loadedData.(myStruct)) // { name: Yuto, age: 36 }

    person := person{
        Name:   "Yuto",
        Age:    36,
        Gender: "man",
    }

    // panic: sync/atomic: swap of inconsistently typed value into Value
    value.Store(person)
    fmt.Println(value.Load())
}

It panics if a different struct is assigned.

A new struct needs to be assigned by using CompareAndSwap() or Swap() method to update the value. A method in the struct should not be called to update the property because its method call is no longer an atomic process. Get the struct from atomic.Value is atomic but calling the method is not. It should be handled as an immutable variable.

Handling a pointer with atomic

Handling a pointer is almost the same as atomic.Value. The only difference is to give the data type.

func atomicPointer() {
    fmt.Println("--- atomicPointer ---")

    var pointerValue atomic.Pointer[myStruct]
    fmt.Println(pointerValue.Load() == nil) // true

    old := myStruct{name: "name-1", age: 1}
    pointerValue.Store(&old)
    fmt.Println("whole struct: ", pointerValue.Load()) // whole struct:  { name: name-1, age: 1 }
    fmt.Println("name: ", pointerValue.Load().name)    // name:  name-1
    fmt.Println("age: ", pointerValue.Load().age)      // age:  1

    fmt.Println("--- CompareAndSwap ---")
    newData := myStruct{name: "name-1", age: 2}
    pointerValue.CompareAndSwap(&old, &newData)
    fmt.Println("whole struct: ", pointerValue.Load()) // whole struct:  { name: name-1, age: 2 }
    fmt.Println("name: ", pointerValue.Load().name)    // name:  name-1
    fmt.Println("age: ", pointerValue.Load().age)      // age:  2
}

Since Pointer has a data type, it’s more readable than using Value in my opinion.

Performance comparison between Mutex and atomic

To compare the performance, I wrote the following code. The methods used below are already shown above.

import (
    "play-with-go-lang/utils"
    "testing"
)

func BenchmarkWriteWithAtomic(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = utils.WriteInGoroutine()
    }
}

func BenchmarkWriteWithAtomic2(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = utils.WriteInGoroutine2()
    }
}
func BenchmarkWriteWithMutex(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = utils.WriteInGoroutineWithMutex()
    }
}

The result is as follows.

$ go test ./benchmark -bench Write
goos: linux
goarch: amd64
pkg: play-with-go-lang/benchmark
cpu: Intel(R) Core(TM) i7-9850H CPU @ 2.60GHz
BenchmarkWriteWithAtomic-12                 1567            816342 ns/op
BenchmarkWriteWithAtomic2-12                1396            820650 ns/op
BenchmarkWriteWithMutex-12                   306           3964910 ns/op
PASS
ok      play-with-go-lang/benchmark     4.206s

Mutex is about 4 times slower. If the application requires performance and the logic is simple enough, atomic is a better choice.

Comments

Copied title and URL