29 Atomic Operations Part 1

29 Atomic Operations - Part 1 #

In the previous two articles, we discussed mutex locks, reader-writer locks, and condition variables based on them. Let’s summarize.

Mutex locks are very useful synchronization tools that ensure only one goroutine can enter the critical section at any given time. Reader-writer locks differentiate between write and read operations on shared resources and eliminate mutual exclusion between read operations.

Condition variables are mainly used to coordinate threads that want to access shared resources. When the state of the shared resource changes, a condition variable can be used to notify threads that are blocked by a mutex lock. It can be based on both mutex locks and reader-writer locks. Of course, reader-writer locks are also a type of mutex lock, with the former being an extension of the latter.

By using mutex locks appropriately, we can allow a goroutine to execute code in the critical section without being disturbed by other goroutines. However, even though it won’t be disturbed, it can still be interrupted.

Introduction: Atomic Execution and Atomic Operations #

We already know that for a Go program, the scheduler in the Go runtime system will schedule the execution of all goroutines properly. However, at any given moment, only a few goroutines can be truly in the running state, and this number will be consistent with the number of M’s, regardless of the increase in G’s.

Therefore, for the sake of fairness, the scheduler always frequently swaps in or swaps out these goroutines. “Swapping in” means changing a goroutine from a non-running state to a running state and causing its code to be executed on a CPU core.

“Swapping out” means the opposite, i.e., interrupting the execution of a goroutine’s code and changing it from a running state to a non-running state.

There are many moments when this interruption can occur, including the gap between any two statements, or even during the execution of a single statement.

This applies even within critical sections. Therefore, we say that although a mutex can guarantee the sequential execution of code in a critical section, it cannot guarantee the atomicity of the execution of this code.

Among the many synchronization mechanisms, the only ones that can guarantee atomic execution are atomic operations. Atomic operations cannot be interrupted during their execution. At the low level, this is supported by the CPU chip, so it is absolutely effective. Even in computer systems with multiple CPU cores or multiple CPUs, the guarantee of atomic operations is unshakable.

This makes atomic operations completely eliminate race conditions and ensure absolute concurrency safety. Moreover, their execution speed is much faster than other synchronization mechanisms, usually by several orders of magnitude. However, their disadvantages are also apparent.

Specifically, because atomic operations cannot be interrupted, they require simplicity and speed.

Imagine if an atomic operation cannot be completed for a long time and cannot be interrupted, it would have a significant impact on the efficiency of executing computer instructions. Therefore, the operating system only provides support for atomic operations on binary bits or integers.

Of course, Go’s atomic operations are based on the CPU and operating system, so they only provide atomic operation functions for a few data types. These functions are located in the standard library package sync/atomic.

I usually explore the familiarity of job applicants with the sync/atomic package with the following question.

Today’s question is: How many types of atomic operations are provided in the sync/atomic package? What are the data types that can be operated?

The typical answer here is:

The atomic operations that can be performed by the functions in the sync/atomic package are: addition (add), compare-and-swap (CAS), load, store, and swap.

These functions operate on a few data types. However, for each of these types, the sync/atomic package provides a set of functions to support them. The supported data types are: int32, int64, uint32, uint64, uintptr, and Pointer from the unsafe package. However, the package does not provide atomic addition operations for the unsafe.Pointer type.

In addition, the sync/atomic package also provides a type called Value, which can be used to store values of any type.

Problem Analysis #

This problem is simple because the answer is clearly stated in the code package documentation. However, if you haven’t read the documentation, you may not be able to answer the question comprehensively.

I usually derive several questions from this one. Let me explain each of them below.

First Derived Question: We all know that the first parameter value passed to these atomic operation functions should correspond to the value being operated on. For example, the first parameter of the atomic.AddInt32 function must be the integer to be incremented. However, why is the type of this parameter not int32 but *int32?

The answer is: because atomic operation functions require a pointer to the value being operated on, not the value itself. The parameter values passed into the function will be copied, and once a value of this basic type is passed into a function, it has no relation to the value outside the function.

Therefore, passing the value itself has no significance. Although the unsafe.Pointer type is a pointer type, the atomic operation functions operate on the pointer value itself, not the value it points to. So what is needed is still a pointer to this pointer value.

Once the atomic operation function obtains the pointer to the value being operated on, it can locate the memory address where this value is stored. Only then can it accurately operate on the data at this memory address using low-level instructions.

Second Derived Question: Can the functions used for atomic addition also perform atomic subtraction? For example, can the atomic.AddInt32 function be used to decrease the value being operated on?

The answer is: of course it can. The second parameter of the atomic.AddInt32 function represents the difference, and its type is int32, which is a signed integer. If we want to perform atomic subtraction, then we can set this difference to a negative integer.

The same applies to the atomic.AddInt64 function. However, performing atomic subtraction using the atomic.AddUint32 and atomic.AddUint64 functions is not as straightforward because their second parameters have types of uint32 and uint64 respectively, which are both unsigned. But this can still be achieved, albeit with a little more complexity.

For example, if we want to perform atomic subtraction on the uint32 value 18, with a difference of -3, we can first convert this difference to a signed int32 value, and then convert the value’s type to uint32. The expression to describe this is uint32(int32(-3)).

However, it should be noted that writing it this way will cause the Go language compiler to report an error, telling you that “the constant -3 is not in the range that can be represented by the uint32 type”. In other words, this will cause the result value of the expression to overflow.

However, if we first assign the result value of int32(-3) to a variable delta, and then convert the value of delta to a uint32 type value, we can bypass the compiler’s check and obtain the correct result.

Finally, we use this result as the second parameter value for the atomic.AddUint32 function, and we can achieve the goal of performing atomic subtraction on a uint32 value.

There is also a more direct way. We can use the following expression to provide the second parameter value for the atomic.AddUint32 function:

^uint32(-N-1))

Where N represents the difference expressed as a negative integer. That is to say, we first subtract 1 from the absolute value of the difference, then convert the resulting untyped integer constant to a uint32 type value, and finally perform a bitwise XOR operation on this value to obtain the final parameter value.

The principle behind this approach is not complicated either. In simple terms, the complement of the result value of this expression is the same as the complement of the value obtained using the previous method, so these two methods are equivalent. We all know that integers are represented in computers in two’s complement form, so here, the fact that the complements of the result values are the same means that the expressions are equivalent.

Summary #

Today, we learned about the atomic operation functions and atomic value types provided by the sync/atomic package. The atomic operation functions are easy to use, but there are some details that we need to pay attention to. I have explained them one by one in the follow-up questions to the main question.

In the next article, we will continue to share more about atomic operations. If you have any questions about atomic operations, feel free to leave a comment and we can discuss it together. Thank you for listening, and see you in the next issue.

Click here to view the detailed code associated with the Go language column article.