An atomic operation is an operation that either fully executes or doesn't execute at all, and this occurs independently of other operations. In other words, "atomicity" means "indivisibility." In the context of multithreaded programming, this ensures that atomic operations are guaranteed not to be interrupted by other operations, making them ideally suited for use in a multithreaded environment.
This is akin to the concept of critical sections, but unlike critical sections, which are typically protected by mutexes and require locking, atomic operations ensure safety without the need to block execution. This simplifies development and can enhance performance.
Today, we will delve into the world of atomic operations in the Go programming language. You will learn about the methods provided for working with them and how to effectively apply them in various scenarios. Additionally, we will explore some limitations and nuances of atomic operations in Go, which are essential to know in order to avoid common mistakes and issues in multithreaded code.
sync/atomic package in Go
In the context of the Go programming language, "atomics" typically refer to variables or values that can be safely modified using atomic operations. These operations are provided by the sync/atomic package, and their key feature is that they ensure thread safety without the need for using mutexes. Another property of these atomic operations is that they usually execute as a single processor instruction, thereby preventing them from being interrupted by other operations.
In Go, the sync/atomic package offers methods to work with the following basic data types:
int32int64uint32uint64uintptr
And also for specialized types:
ValueBoolPointer[T any](only in Go versions supporting generics)
We will delve into these in more detail later on.
To begin with, let's consider a simple example that uses the Add method for the int32 type. This method allows you to atomically add a value to an existing one across multiple goroutines.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var counter int32 // The value we will be modifying atomically
var wg sync.WaitGroup
// Launching 100 goroutines that will increment the counter
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
// Using the atomic Add operation to increment the counter
atomic.AddInt32(&counter, 1)
wg.Done()
}()
}
wg.Wait()
fmt.Println("Counter:", counter)
// Output:
// Counter: 100
}
In this example, we use the atomic operation atomic.AddInt32 to safely increment the counter variable from one hundred goroutines. Note that for synchronizing the goroutines, we use sync.WaitGroup. However, mutexes are not used for the counter variable itself because atomic operations guarantee correct access to the variable even in a multi-threaded environment.
Methods for primitive types
As previously discussed, the sync/atomic package provides a set of functions for the atomic manipulation of primitive data types: int32, int64, uint32, uint64, uintptr.
The methods provided for these types are: Add, Load, Store, Swap, CompareAndSwap. These methods are particularly useful when working with simple counters, state flags, and situations that require high performance without complex synchronization logic. Let's explore each function with examples.
The Add method takes a pointer to a variable of a primitive type and a value to add. It atomically adds the value to the variable and returns the new value of the variable.
var count int32 = 2
newCount := atomic.AddInt32(&count, 3) // the new value of count will be 5
This method is commonly used to increment counters in multi-threaded applications.
The Load method takes a pointer to a variable and atomically returns its value.
var count int32 = 42
currentValue := atomic.LoadInt32(&count) // currentValue will be 42
Use this method when you need to safely read the value of a variable in a multi-threaded environment.
The Store method takes a pointer to a variable and a new value. It atomically sets the new value for the variable.
var count int32 = 0
atomic.StoreInt32(&count, 42) // count is now 42
This method is useful when you need to update the value of a variable and ensure that the update is visible to all other goroutines.
The Swap method takes a pointer to a variable and a new value. It atomically changes the value of the variable to the new value and returns the old value.
var count int32 = 0
oldValue := atomic.SwapInt32(&count, 42) // count is now 42, oldValue is 0
Use this for atomic value replacement when you need to obtain the previous value.
The CompareAndSwap method takes a pointer to a variable, an expected current value, and a new value. If the current value of the variable matches the expected value, the method atomically sets the new value and returns true. Otherwise, the method returns false.
var count int32 = 0
swapped := atomic.CompareAndSwapInt32(&count, 0, 42) // count will now be 42 if its initial value was 0, swapped is true
This method is used in race conditions when you need to change the value of a variable based on its current value.
Now you know the basic methods for atomic operations with primitive types in Go. Choose the appropriate method depending on your specific task: for atomic counter incrementation — Add, for safe reading — Load, for atomic updating — Store, Swap, or CompareAndSwap.
Methods for specialized types
In this section, we will examine the methods for specialized types Value, Bool, and Pointer[T any] (the latter being available only in Go versions that support generics). These types are provided by the sync/atomic package and include methods for atomic modification, similar to those for basic types except for the Add method.
Let's look at each type individually with examples, starting with the simplest of the specialized types.
atomic.Bool
atomic.Bool is a specialized type that provides a simple and reliable way to manage boolean flags in a multithreaded environment. The variable of type atomic.Bool is initially set to false upon initialization, and the Load() method will return false until a different value is set.
Let's look at a straightforward example of using all the methods available for this type:
func main() {
// Initialize atomic.Bool
var b atomic.Bool
// Store sets the value
b.Store(true)
fmt.Println("Load current value after Store:", b.Load())
// Expected output: Load current value after Store: true
// Swap replaces the current value and returns the old one
previous := b.Swap(false)
fmt.Printf("Previous value before Swap: %v\nNew value after Swap: %v\n", previous, b.Load())
// Expected output: Previous value before Swap: true
// New value after Swap: false
// CompareAndSwap compares the current value with the provided one and, if they match, sets a new value
swapped := b.CompareAndSwap(false, true)
fmt.Printf("CompareAndSwap successful: %v\nValue after CompareAndSwap: %v", swapped, b.Load())
// Expected output: CompareAndSwap successful: true
// Value after CompareAndSwap: true
}
Let's examine how this works:
b.Store(true): This operation sets the flag value to true, ensuring that all subsequent read operations usingb.Load()will see this new value.b.Swap(false): The Swap method atomically changes the current value to false and returns the previous value.b.CompareAndSwap(false, true): This method first checks if the current value equals false, and if so, atomically sets it to true. This prevents changing the value if another thread has already changed it to true.
This type is often used for managing states that can change in different parts of a program, for example, to control system readiness, indicate the need to stop execution or restart operations. Thanks to the atomicity of operations, atomic.Bool ensures reliability and consistency of the flag state without the need for locks or mutexes, making your code simpler and more efficient.
atomic.Value
atomic.Value in Go is a universal container that allows atomic storage and retrieval of any type of value. By default, a variable of type atomic.Value is initialized with a nil value, therefore, the Load() method returns nil if no save operations have been performed previously.
Imagine we have a configuration object that we want to modify so that any goroutines can see these changes. We can atomically modify and retrieve the configuration object. Look closely at this example and try running it in a sandbox:
type Config struct {
key string
retry int
}
func main() {
var v atomic.Value
// Store: Set the initial value
v.Store(Config{"initial", 42})
// Load: Retrieve the current value
current := v.Load().(Config)
fmt.Println("Load after Store:", current)
// Expected output: Load after Store: {initial 42}
// Swap: Replace the value and get the old one
old := v.Swap(Config{"swapped", 43}).(Config)
fmt.Println("Swap (old):", old)
// Expected output: Swap (old): {initial 42}
current = v.Load().(Config)
fmt.Println("Swap (new):", current)
// Expected output: Swap (new): {swapped 43}
// CompareAndSwap: If the current value is equal to old, set it to new
// Try using Config{"swapped", 43} instead of current – the result will be the same
if v.CompareAndSwap(current, Config{"final", 44}) {
fmt.Println("CompareAndSwap success:", v.Load())
// Expected output: CompareAndSwap success: {final 44}
} else {
fmt.Println("CompareAndSwap failed:", v.Load())
}
}
Here's what this code does with an atomic.Value:
v.Store: This method atomically writes a new configuration into anatomic.Value. This means that any goroutine accessing this value afterStoreis executed will see the updated configuration.v.Swap: The Swap method atomically replaces the current value with a new one, returning the old one.v.CompareAndSwap: This method compares the current value with the provided old value, and if they match, atomically sets a new one. This prevents changes based on outdated data.
As you may notice, the result of the last block doesn't change if you use a new object with identical field values for comparison. Let's look at how exactly CompareAndSwap compares objects and their fields for atomic.Value:
| Primitive Types | Compared by value. If the values are identical, CompareAndSwap returns true. |
|---|---|
| Structs | Compared by content. All fields of the structure must be identical. If structures contain pointers, slices, or maps, they are compared by their memory addresses, not the content they point to. |
| Slices and Maps | Compared by their memory addresses, not by content. Even if two slices or two maps contain identical elements, they are considered different if stored at different memory addresses. |
| Pointers to Primitive Types | Compared by pointer addresses. Two pointers are considered equal if they point to the same place in memory. |
| Pointers to Structures | Similar to pointers to primitive types, compared by pointer addresses. Two pointers to structures are considered equal if they point to the same object in memory, regardless of the content of the structure. |
Note that all method calls for a given atomic.Value object must use values of the same concrete type. If the types do not match, Go initiates a panic, which is an unrecoverable runtime error leading to the program's termination.
atomic.Pointer[T any]
The atomic.Pointer[T any] type is designed for atomically changing references to any data and is useful when you need to atomically change a data reference in a multi-threaded environment. By default, a variable of type atomic. Pointer[T any] is initialized as nil, and the Load() method will return nil if a new pointer has not been set.
Let's imagine we have a system with multiple components, each having its own configuration. For example, consider a database service whose configuration may change during application runtime and requires connection settings to be updated without stopping the service. The code below demonstrates how to atomically update the database's configuration using atomic.Pointer[T any]:
type DatabaseConfig struct {
Host string
Port int
}
type AppConfig struct {
Database atomic.Pointer[DatabaseConfig]
// Other configuration fields
}
func main() {
appConfig := AppConfig{}
// Initializing initial database configuration
initialDBConfig := &DatabaseConfig{Host: "localhost", Port: 5432}
appConfig.Database.Store(initialDBConfig)
// Loading current database configuration
loadedDBConfig := appConfig.Database.Load()
fmt.Printf("Current DB config: %+v\n", *loadedDBConfig)
// Expected output: Current DB config: {Host:localhost Port:5432}
// Updating database configuration
newDBConfig := &DatabaseConfig{Host: "db.internal", Port: 5432}
appConfig.Database.Swap(newDBConfig)
fmt.Printf("DB config updated to: %+v\n", appConfig.Database.Load())
// Expected output: DB config updated to: &{Host:db.internal Port:5432}
// Attempting atomic update of the database configuration using CompareAndSwap
finalDBConfig := &DatabaseConfig{Host: "db.internal", Port: 5050}
if appConfig.Database.CompareAndSwap(newDBConfig, finalDBConfig) {
fmt.Println("DB config atomic update successful")
// Expected output: DB config atomic update successful
} else {
fmt.Println("DB config atomic update failed")
}
// Displaying the updated database configuration
loadedDBConfig = appConfig.Database.Load()
fmt.Printf("Final DB config: %+v\n", *loadedDBConfig)
// Expected output: Final DB config: {Host:db.internal Port:5050}
}
What's happening in this code:
-
appConfig.Database.Store(initialDBConfig): This method atomically writes theinitialDBConfigpointer intoDatabase atomic.Pointer[DatabaseConfig], thereby replacing the old pointer that was stored there. -
appConfig.Database.Swap(newDBConfig): The Swap method atomically replaces the current pointer withnewDBConfigand returns the previous pointer value, which we do not retain as it's not needed (pointer toinitialDBConfig). -
appConfig.Database.CompareAndSwap(newDBConfig, finalDBConfig): Compares the current pointer value with the pointer innewDBConfig, and if they match, atomically replaces it with thefinalDBConfigpointer and returns true. If the current pointer does not matchnewDBConfig, no replacement occurs, and it returns false.
Note that, unlike atomic.Value, it's not possible to use a "clone" of the object for comparison in CompareAndSwap, as it directly compares the addresses of the pointers.
Keep in mind that the objects these pointers refer to remain unchanged. The change only occurs at the level of pointers in atomic.Pointer, not the content of the objects themselves.
This behavior of atomic.Pointer allows safe and immediate updating of object references in a multi-threaded environment, often used for "hot" configurations or state updates where minimizing latency and avoiding locks is required.
Now you know how to work with specialized types for atomic programming in Go. Choose the type and methods that best suit your specific task.
Features and use of atomics
Let's delve into some key features of atomics and how they can be utilized effectively in your Go applications.
- Atomicity: Atomic operations cannot be interrupted and are executed as a single, indivisible instruction at the processor level. This eliminates the potential for conflicts and race conditions between various threads or goroutines.
- Simplicity: Atomics can be simpler to use than mutexes, especially for straightforward, discrete operations. Code using atomics can be cleaner and more understandable.
- Performance: Atomic operations are generally faster than mutexes as they do not require locking and consume fewer system resources.
- Deadlock Probability: Unlike mutexes, atomics do not have a "locking state," thereby eliminating the risk of encountering a deadlock situation where a mutex is acquired but not released.
Now let's consider several examples of how atomics can be used in our applications:
- Counters: In situations where multiple goroutines increment a counter, atomics ensure fast and correct incrementation.
- Signaling completion: Atomics can be used to signal to goroutines that they need to finish execution, which is a lighter-weight alternative to channels, especially when there is no need to pass data.
- Conditional flags: Atomics can be used to implement conditions under which certain actions should only be performed when specific flags are set.
- One-time initialization: Atomics can ensure that certain code is executed only once. This is similar to the sync.Once mechanism, which also uses atomics internally but is a higher-level abstraction.
- Dynamic reconfiguration: Atomics are useful in systems where configuration needs to be updated on the fly, without restarting the service or blocking the execution of current tasks. For example, you can atomically update thresholds for monitoring systems or logging parameters.
- Resource management: In managing resource pools (such as database connections), atomics can be used to track and manage the number of used and available resources.
- Implementing other synchronization primitives: Atomics are often used to implement other, more complex synchronization primitives, such as mutexes or channels.
These are just a few examples of use that demonstrate how effective and flexible atomics can be utilized to solve a multitude of problems in multithreaded programming. They can simplify code and enhance performance by reducing reliance on traditional locks and mutexes.
They can also be useful in developing low-level libraries or system components. However, it is important to understand that atomic operations do not provide transaction-level consistency like mutexes do. This means if your code requires complex synchronization conditions or execution of multiple operations as a single transaction, then mutexes are preferable in such cases.
Conclusion
Let's summarize the key points from today's topic:
- Recalled the concept of atomic operations, compared their meaning with critical sections, and discussed the differences.
- Learned that atomic operations in Go are executed over a value as a single processor instruction, ensuring their independence from other operations.
- Explored basic data types (
int32,int64,uint32,uint64,uintptr) and specialized types (Value,Bool,Pointer[T any]) that are used with the sync/atomic package, which provides atomic operations. - Studied methods such as
Add,Load,Store,Swap, andCompareAndSwap, offered by Go for atomic operations, analyzed several code examples using them, and examined their features. - Discussed key features of atomic operations like indivisibility, ease of use, performance, and the absence of deadlock probability, along with usage scenarios including counters, completion signaling, conditional flags, one-time initialization, dynamic reconfiguration, and resource management.
Now that we have mastered the theory and reviewed practical examples, it's time to apply our newly acquired knowledge in practice. Let's move on to the theory and programming tasks!