Detailed interpretation of go atomic atomic operations

overview

The atomic package is an atomic operation package provided by golang by encapsulating the atomic operations supported by the underlying system, and is used to achieve lock-free concurrent and safe operation of data. Supports addition (Add), comparison and exchange (CompareAndSwap), direct exchange (Swap), setting pointer variable value (Store), and obtaining pointer variable value (Load); it also sets up an atomic.Value structure to support object storage, acquisition, and comparison. And exchange, direct exchange operations.

How to use atmoic package? Take addition as an example. Variable addition operations are not atomic. They include taking values ​​from memory and putting them into addition registers, register operations to obtain results, and writing the results back to memory. In a concurrent environment, the operation result of a coroutine may be Another overwrite, resulting in the problem of losing modifications. For example, the final printed result of the following code will not be 10000.

var wg sync.WaitGroup
var num = 0

func Add() {
    
    
	num++
	wg.Done()
}
func main() {
    
    
	size := 10000
	wg.Add(size)
	for i := 0; i < size; i++ {
    
    
		go Add()
	}
	wg.Wait()
	fmt.Println(num)
}

And the following code is 10000:

var wg sync.WaitGroup
var num int32 = 0

func Add() {
    
    
	atomic.AddInt32(&num, 1)
	wg.Done()
}
func main() {
    
    
	size := 10000
	wg.Add(size)
	for i := 0; i < size; i++ {
    
    
		go Add()
	}
	wg.Wait()
	fmt.Println(num)
}

1. Basic knowledge

1.1 What are atomic operations?

Atomic operations refer to operations that will not be interrupted during execution. They are either fully executed or not executed at all. There is no partial execution. An atomic operation can be thought of as an indivisible unit in which other threads or processes cannot interfere or insert.
Atomic operations are usually used to read, write, and modify shared data to ensure the consistency and correctness of the data. In a multi-threaded or multi-process environment, if multiple threads or processes access and modify the same shared data at the same time, not having the correct synchronization mechanism or using atomic operations may lead to data races and uncertain behavior.
Common atomic operations include:

  • Atomic Read: Get the value of a shared variable from memory and ensure that other threads or processes do not modify the value during the reading process.
  • Atomic Write: Write a value to a shared variable and ensure that other threads or processes do not read or modify the value during the writing process.
  • Atomic Add: Add a specific value to a shared variable and store the result back into the shared variable to ensure that other threads or processes will not interfere during the addition process.
  • Atomic Compare-and-Swap: Compares the current value of the shared variable with the expected value. If they are equal, the new value is written to the shared variable; if they are not equal, no modification is made. This operation is often used to implement synchronization mechanisms and locks.

1.2 How does the CPU implement atomic operations?

Today's CPUs are generally multi-core processors. There are many ways to implement atomic operations at the bottom. Here are some common methods:

  1. Bus locking: In multi-core processors, a bus locking mechanism can be used to ensure atomic operations. When a processor needs to perform an atomic operation, it sends a signal to the bus requesting a lock. After receiving this signal, other processors will suspend access to the bus to prevent interference with ongoing atomic operations. Only when the atomic operation completes and the lock is released, other processors can continue to access the bus.

  2. Cache coherence protocol: Each core in a multi-core processor usually has its own cache, which requires ensuring the consistency of cache data between cores. Cache coherence protocols enable atomic operations by sharing and updating processor cache line (a piece of data cached in the CPU core's local cache) state information across multiple cores. A common cache consistency protocol is MESI. When multiple cores update data, the following principles need to be followed:

    • Modified state: When a core modifies the data in a cache line, the cache line is marked as "modified" state. At this time, the corresponding cache lines in the caches of other cores are considered invalid (Invalid), that is, invalid data.

    • Exclusive state: A cache line is in the "exclusive" state when one core loads a cache line from memory into its own cache and there is no copy of the cache line in other cores' caches. At this time, when other cores need to read or write the data, they must access it through this core, and the data in the exclusive state can be modified directly.

    • Shared state: When multiple cores have a copy of the same cache line, the cache line is in the "shared" state. At this point, for read operations, other cores can read data directly from their own caches. For write operations, a command needs to be issued through the bus to update the status of other caches to "invalid", and the data in the cache can be updated to exclusive before it can be modified.

    • Invalid state: When one core modifies the data of a cache line, the data status of the cache line in other cores will be marked as "invalid" state. This means that other cores need to reload the latest data from memory.

      The general logic is that when a core reads data in memory in its own local cache and only the core's cache owns the cache line, the status of this cache line is Exclusive state , at this time, read and write operations can be performed directly. Other caches need to send requests to the core through the bus to read the cache line. At this time, the cache line status changes to . The core can read the cache line data, but the modification needs to first initiate a modification request through the bus, invalidate the cache line data of other cores, and then modify the data in the cache line (it seems inefficient? In fact, the CPU will modify the cache immediately OK, after the modification, the cache will not take effect immediately. Instead, it will be put into the store buffer, and then the invalidation request will be sent back to all cores. The store buffer will wait for all cores to respond successfully, and then the modification of the cache line will take effect. During this period, the core can continue do other things). After the modification is completed, the cache behavior is modified and the cache behavior of other cores is invalid. At this time, if a core reads the cache line, the cache line is written to the memory, and the cache line status on the core is updated to shared; if it is modified request, the core cache line status is updated to invalid. Share

  3. Atomic instructions: Modern multi-core processors usually provide some atomic instructions, such as compare and swap, load-linked/store-conditional, etc. These instructions can be executed atomically at the hardware level, ensuring that access to shared data is atomic. By using these atomic instructions, you avoid the overhead of locks and provide more efficient atomic operations.

2. atomic package

The atomic operations of Golang's atomic package are implemented through CPU instructions. In most CPU architectures, the implementation of atomic operations is based on 32-bit or 64-bit registers. The atomic operation function of Golang's atomic package will convert the address of the variable into a pointer type variable, and use CPU instructions to operate on this pointer type variable.
Golang’s atomic package provides a set of atomic operation functions, including Add, CompareAndSwap, Load, Store, Swap and other functions. The specific functions of these functions are as follows:

  • Add function: used to add an integer variable and return a new value.
  • CompareAndSwap function: used to compare and exchange the value of a pointer type variable. If the value of the variable is equal to the old value, the value of the variable is set to the new value and true is returned; otherwise, the value of the variable is not modified and false is returned.
  • Load function: used to obtain the value of a pointer type variable.
  • Store function: used to set the value of a pointer variable.
  • Swap function: used to exchange the value of a pointer type variable and return the old value.
    Let’s take a more specific look at the atomic operations of Golang’s atomic package:

2.1. Add function

The Add function is used to add an integer variable and return a new value. The Add function is defined as follows:

func AddInt32(addr *int32, delta int32) (new int32)
func AddInt64(addr *int64, delta int64) (new int64)
func AddUint32(addr *uint32, delta uint32) (new uint32)
func AddUint64(addr *uint64, delta uint64) (new uint64)
func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)

Among them, addr represents the address of the variable to be added, and delta represents the value to be added. The Add function adds delta to the value of the variable and returns the new value.

2.2. CompareAndSwap function

The CompareAndSwap function is used to compare and exchange the value of a pointer type variable. If the value of the variable is equal to the old value, the value of the variable is set to the new value and true is returned; otherwise, the value of the variable is not modified and false is returned. The CompareAndSwap function is defined as follows:

func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)
func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)

Among them, addr represents the address of the variable to be compared and exchanged, old represents the old value, and new represents the new value. If the value of the variable is equal to the old value, the value of the variable is set to the new value and true is returned; otherwise, the value of the variable is not modified and false is returned.

2.3. Swap function

The Swap function is used to exchange the value of a pointer type variable and return the old value. The Swap function is defined as follows:

func SwapInt32(addr *int32, new int32) (old int32)
func SwapInt64(addr *int64, new int64) (old int64)
func SwapUint32(addr *uint32, new uint32) (old uint32)
func SwapUint64(addr *uint64, new uint64) (old uint64)
func SwapUintptr(addr *uintptr, new uintptr) (old uintptr)
func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)

Among them, addr represents the address of the variable to be exchanged, and new represents the new value. The Swap function will set the value of the variable to new and return the old value.

2.4. Load function

The Load function is used to obtain the value of a pointer type variable. The Load function is defined as follows

func LoadInt32(addr *int32) (val int32)
func LoadInt64(addr *int64) (val int64)
func LoadUint32(addr *uint32) (val uint32)
func LoadUint64(addr *uint64) (val uint64)
func LoadUintptr(addr *uintptr) (val uintptr)
func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)

2.5. Store function

The Store function is used to set the value of a pointer variable. The Store function is defined as follows:

func StoreInt32(addr *int32, val int32)
func StoreInt64(addr *int64, val int64)
func StoreUint32(addr *uint32, val uint32)
func StoreUint64(addr *uint64, val uint64)
func StoreUintptr(addr *uintptr, val uintptr)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)

Among them, addr represents the address of the variable to be set, and val represents the value to be set. The Store function will set the value of the variable to val.

3. atomic.Value value

A structure that can be used to store any type objects. Access operations are all atomic operations. The specific methods are as follows:

type Value struct {
    
    
	v any
}
// 加载Value中存的值
func (v *Value) Load() (val any) {
    
    }
// 存储对象放入Value中
func (v *Value) Store(val any) {
    
    }
// 交换Value中存的数据
func (v *Value) Swap(new any) (old any) {
    
    )
// 比较并存储(值必须铜类型且可比较采用使用该方法,否则panic);传入old、new对象,如果old对象等于Value内存储的对象,就将新的对象存入,返回treu。否则false
func (v *Value) CompareAndSwap(old, new any) (swapped bool) {
    
    }

Try it out:

package main

import (
	"fmt"
	"sync/atomic"
)

type Config struct {
    
    
	Addr string
}

var config atomic.Value

func main() {
    
    
	conf1 := Config{
    
    
		Addr: "1.1.1.1",
	}
	conf2 := Config{
    
    
		Addr: "2.2.2.2",
	}
	config.Store(conf1)
	oldData := config.Swap(conf2)
	newData := config.Load()
	fmt.Println(oldData, newData)
	conf3 := Config{
    
    
		Addr: "3.3.3.3",
	}
	ok := config.CompareAndSwap(conf1, conf3)
	fmt.Println(ok, config.Load())
	ok = config.CompareAndSwap(conf2, conf3)
	fmt.Println(ok, config.Load())
}

Output:

{1.1.1.1} {2.2.2.2}
false {2.2.2.2}
true {3.3.3.3}

Guess you like

Origin blog.csdn.net/sunningzhzh/article/details/132207109