DEV Community

Cover image for Achieving High-Level Atomic Operations in Go
Alexey Shevelyov
Alexey Shevelyov

Posted on

Achieving High-Level Atomic Operations in Go

Achieving High-Level Atomic Operations in Go

Go offers first-class support for concurrency, but mastering the art of concurrent programming involves intricate challenges. One of the most critical tasks is managing shared state across multiple Goroutines, and that's where atomic operations come into play. The Go standard library provides a range of low-level atomic operations that can be leveraged to build thread-safe applications. However, these operations often require a deep understanding of memory models and can be cumbersome to implement. In this article, we explore how to perform high-level atomic operations using the go.uber.org/atomic package, making it easier to write safe and maintainable Go code.

The Scenario

Let's consider a real-world example involving a shared bank balance, a common scenario in financial software. Multiple threads or Goroutines may need to read or update this balance concurrently. While Go's standard library provides atomic operations to manage such shared state, it often lacks higher-level abstractions, which can make the code harder to read and maintain. That's where the go.uber.org/atomic package comes in handy.

package main

import (
    "fmt"
    "go.uber.org/atomic"
)

var balance atomic.Int64

func updateBalance(minAmount int64, change int64) bool {
    for {
        // Load current balance atomically
        currentBalance := balance.Load()

        // Check if balance is greater than minAmount
        if currentBalance < minAmount {
            return false
        }

        // Calculate new balance
        newBalance := currentBalance + change

        // Try to update balance atomically
        if balance.CompareAndSwap(currentBalance, newBalance) {
            return true
        }
    }
}

func main() {
    balance.Store(100)
    success := updateBalance(50, -20)
    fmt.Println("Operation Successful:", success)
    fmt.Println("Updated Balance:", balance.Load())
}
Enter fullscreen mode Exit fullscreen mode

How It Works

Atomic Loading and Storing

The balance.Load() and balance.Store(100) methods are examples of atomic operations that safely load and store the value of the balance variable. These operations ensure that the value of balance can be read or written safely, without being interrupted or accessed simultaneously by another Goroutine, thanks to the atomic methods provided by the go.uber.org/atomic package.

Compare and Swap: The Cornerstone of Atomicity

The CompareAndSwap method is the workhorse of our atomic operations. This method takes two arguments: the expected current value and the new value to set. It atomically checks if the current value matches the expected value and, if so, sets it to the new value. This operation is atomic, meaning it's done in a single, uninterruptible step, ensuring that no other Goroutine can change the balance in the middle of the update.

This replaces the CAS method we used earlier. While CAS offered similar functionality, CompareAndSwap is more idiomatic and aligns better with Go's naming conventions, making the code easier to understand.

Why go.uber.org/atomic?

You might wonder why not stick with Go's built-in atomic package? The go.uber.org/atomic package offers a more ergonomic API and additional type-safety, making it easier to write correct concurrent code. It provides a cleaner, more intuitive interface for dealing with atomic operations, as you can see from our example.

Managing shared state in a concurrent application is a complex but crucial aspect of Go programming. While Go's standard library offers low-level atomic operations, the go.uber.org/atomic package provides a higher level of abstraction, making it easier to write, read, and maintain concurrent code. By understanding and leveraging these atomic operations, you can write more robust and efficient Go applications.

Top comments (0)