Atomic operations using sync/atomic package – GoLang

by sagacity

We have seen sync. Mutex and sync.RWMutexconcepts in our previous blogs and we’ve also seen various examples associated to them. They all are used to resolve contention amongst the contesting go-routines.

As we’ve seen through all these three episodes, these synchronization primitives are used to guard critical sections and also ensure the reliability of CRUD operations around common data-stores and global variables.

However, for global variables, in particular, there’re very good synchronization primitives made available by the first class package in GoLang. This package is sync/atomic.

Let’s go through a practical scenario. Let’s assume there’re certain IOT devices need to be commissioned in the area where 24 hours surveillance is promised. Each device needs to be tagged with the following format: dd-mm-yyyy:hh-mm-ss-mmm:gid Where dd-mm-yyyy:hh-mm-ss-mmm is the time signature – up through milliseconds – of the time when
the device is commissioned. And gid is a unique number that resembles auto_increment or says sequence column level constraint of a db table. This means each device needs a GID that has to be unique. One way, as we’ve seen in the 1st blog of this series, is to use sync. Mutex, i.e., create a global unsigned integer variable and use a sync. Mutex variable to guard the same. We’ve studied this approach and also done some hands-on coding around the same. However, the exactly same functionality is provided by the first class package sync/atomic of GoLang.

This exactly what the package itself says, “Package atomic provides low-level atomic memory primitives useful for implementing synchronization algorithms.” The package is full of such exported functions that help to achieve exactly what we’ve stated in the above mentioned example.

Let’s have a look what all primitives are provided by sync/atomic package. They’re of following five types of atomic functions for an integer type T, where T is either of int32, int64, uint32, uint64, or uintptr.

func AddT(addr *T, delta T) (new T)
func LoadT(addr *T) (val T)
func StoreT(addr *T, val T)
func SwapT(addr *T, new T) (old T)
func CompareAndSwapT(addr *T, old, new T) (swapped bool)

For instance, AddInt32(), AddUint64(), LoadInt32(), etc..
While AddT are used to update, LoadT are used to read atomically while they’re being updated.

Let’s jump to the code directly to understand how effective these functions are in the context of concurrency.

Example-1: Using AddT
Let’s assume a case where we’re maintaining the number of times a specific handler is invoked. We’ll write a very primitive example to demonstrate the same. This’s a go-routine and resembles an API handler which is executed and an API is invoked from the outside world. We’re kind of attempting to simulate this very situation.
==

package main

import (
	"runtime"
	"sync"
	"fmt"
	"sync/atomic"
	"math/rand"
	"time"
)

var handlerCounter uint64  // defaulted to 0

func randomInt(min int, max int) int {
	if min >= max {
		return -1
	}

	return rand.Intn(max - min + 1) + min
}

func handler(pWG *sync.WaitGroup, id int) {
	for i := 0; i < 4; i++ {
		time.Sleep(time.Duration(randomInt(100, 300)) * time.Millisecond)
		fmt.Printf("go-routine[%d]:  loop: %d,  counter: %02d\n", id, i, atomic.AddUint64(&handlerCounter, 1))
	}
	pWG.Done()
}

func main() {
	runtime.GOMAXPROCS(runtime.NumCPU())  // allocates one logical processor for the scheduler to use

	var wg sync.WaitGroup

	for i := 0; i < 4; i++ {
		wg.Add(1)
		go handler(&wg, i)
	}

	wg.Wait()
	fmt.Printf("handlerCounter: %d\n", handlerCounter)
}
==

I got the following o/p when I executed this code. Your o/p may come out differently. It’s however guaranteed that counter is updated atomically.
o/p:
go-routine[1]: loop: 0, counter: 03
go-routine[1]: loop: 1, counter: 05
go-routine[3]: loop: 0, counter: 01
go-routine[0]: loop: 0, counter: 02
go-routine[0]: loop: 1, counter: 08
go-routine[0]: loop: 2, counter: 09
go-routine[0]: loop: 3, counter: 10
go-routine[1]: loop: 2, counter: 06
go-routine[1]: loop: 3, counter: 11
go-routine[2]: loop: 0, counter: 04
go-routine[2]: loop: 1, counter: 12
go-routine[2]: loop: 2, counter: 13
go-routine[2]: loop: 3, counter: 14
go-routine[3]: loop: 1, counter: 07
go-routine[3]: loop: 2, counter: 15
go-routine[3]: loop: 3, counter: 16
handlerCounter: 16

handlerCounter value, as expected, is 16. However, had we used the non-atomic way of incrementing handlerCounter, it was possible that we’d gotten a completely wayward value. This was because the go-routines wouldn’t be bound to restrict into their own operation and therefore would likely to interfere with each other. It was also very likely that we’d gotten into a race condition in case we’d executed the code using -race flag.

Let’s go through the other 2 functions.
func LoadT(addr *T) (val T)
func StoreT(addr *T, val T)

So, there’re 4 functions for LoadT and 4 functions for StoreT operations. SO, what’re they exactly?

Let’s try to understand this scenario:

counter := atomic.LoadUint64(66736473323)

This’s a fairly large number, isn’t it? But of course it’s in the limit of uint64, that we’re trying to assign to a variable “counter” which is going to be of the same type, i.e., uint64.

Assume the following algorithm that is actually used in the assignment of 66736473323 to the variable counter where direct 64 bit arithmetic isn’t quite possible due to the limits imposed by processor
architecture and/or OS.
1> A lock is acquired.
2> Lower 32 bits are read.
3> Upper 32 bits are read.
4> Lower 32 bits are added.
5> Carry of step-3 is added to the upper 32 bits.
6> Lower 32 bits are written.
7> Upper 32 bits are written.
8> Lock is released.

If atomic.LoadUint64 isn’t used, for instance, something like
counter := 66736473323
one could end up reading an intermediate result between step 6 and 7. This means, to safe-guard this 64 bit operation, use of locking synchronization primitives were mandatory.

Now, the question is why haven’t we been doing this so far, if anyway atomic call is the only the dead-sure way?
The answer is, we use, well most of the times now-a-days, 64 bit architecture. And the 64 bit OS gets native support for 64 bit integer operations. However, on certain platforms, for instance, older ARM processors without native support for 64 bit integer operations, atomic operations are the bets bet. And if not, the above algorithm may very well be implemented to perform 64 bit operations.

The same applies for other sized integers/pointers as well. The exact behaviour, though, depends upon implementation of atomic package and the CPU/memory architecture the program is executed on.

func SwapT(addr *T, new T) (old T)
SwapT() functions mutate the value pointed to by addr with the new value and return the older value pointed to by addr before swap.

func CompareAndSwapT(addr *T, old, new T) (swapped bool) CompareAndSwapT() functions mutate (store operation) value pointed to by addr with “new” value iff the current value pointed to by addr matches the passed “old” value. The return¬†value indicates whether or not the compare-and-swap was successful, in turn, store operation
was successful or failed.

func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)

The above Functions do their respective load, store, swap, and compare-and-swap operations on unsafe pointers.
Pointer arithmetic is deprecated in GoLang, thus, we don’t have atomic add operations on pointers.

SHARE

Write a response

© 2019 Sagacity. All Rights Reserved. Sagacity logo is registered trademarks of Sagacity Software Pvt. Ltd.