Hi, what are atomics and atomic operations ?
# getting-started
a
Hi, what are atomics and atomic operations ?
👍 1
j
Atomic operations in general are operations that are composed of several steps, but have a guarantee that either all steps are executed or none of them (kind of like a DB transaction). Usually, this term is used for reading and writing values into memory when multithreading is involved. Imagine a simple increment operation:
i++
. What happens here is that you will first have to read the value of
i
, then write
i + 1
in this memory location. Because 2 things happen, these 2 things can be interlaced when multiple threads are doing such operations. Imagine 2 threads are doing
i++
at the same time. Without atomic guarantees, you can end up in a situation where the following events occur: 1. thread 1 reads value 42 2. thread 2 reads value 42 3. thread 1 writes value 43 (42+1) 4. thread 2 writes value 43 as well So you expected 2 increments, but you end up with 43. When you use something that performs this increment "atomically", it means the read and write cannot be interlaced with other things happening to the same memory location, so thread 2 would wait for thread 1's write before reading the value, and you wouldn't have these problems. Atomic integers, long or booleans are sort of "synchronized values" that provide this kind of guarantees with operations like
incrementAndGet
,
getAndIncrement
, etc.
👍🏼 1
👍 1
You can use them to implement CAS approaches (compare-and-set) instead of using locks. You can say that you want to update a value only if it is currently in the expected state (so you read/compare/write atomically like a DB transaction).
e
to start with, JVM specifies that reads and writes of most primitives (everything except
long
and
double
) and all references are atomic: this means each read or write operation is completed in full, without being interleaved with any other operation. in practice, this means that those types are always consistent; you cannot read a value that was not explicitly written. by contrast, look at
long
and
double
, where the specification allows implementations of read and write that perform multiple 32-bit operations non-atomically instead of a single atomic 64-bit operation: it is possible for
Copy code
var value = 0L
Thread { value = -1L }.start()
println(value)
to print
0
,
-1
,
-4294967296
, or
4294967295
as the 64-bit read or write may be implemented as two 32-bit reads or writes which may be interleaved.
now, even though Int-sized reads and writes themselves are atomic, that does not make higher-level operations atomic (such as
++
as Joffrey said), nor does it imply any memory ordering either. for example,
Copy code
var value = 0
Thread { value = 1 }.start()
while (true) {
    when (value) {
        0 -> continue
        1 -> break
        else -> throw Exception()
    }
}
will not throw (
value
will not be read as anything other than
0
or
1
), but it may loop forever, and is it likewise permitted for
Copy code
var x = 0
var y = 0
Thread {
    x = 1
    y = 1
}.start()
while (y == 0) {}
println(x)
to print either
0
or
1
or loop forever, because there is nothing that causes the writes to memory from one thread to be observed (at all, or in any particular order) when reading memory from another thread, unless
volatile
or other things that imply ordering are involved
on the JVM, reads and writes on
volatile
variables form "happens-before" links between two threads, as do object monitors (and
synchronized
and locks) and higher-level tools such as
AtomicInteger
and kotlinx.atomicfu. in fact, the guarantee is stronger than in many other languages, with all memory operations becoming ordered, not only the specific ones involving the
volatile
or atomic that is being operated on
although one little diversion,
Copy code
class Foo(var value: Int)
var foo: Foo? = null
Thread { foo = Foo(1) }.start()
while (foo != null) {}
println(foo!!.value)
as mentioned earlier, this may not terminate, or it may print either
0
or
1
, but if we change it to
Copy code
class Foo(val value: Int)
then it may still not terminate, or print
1
, but it may no longer print
0
, as initialization of
final
fields are guaranteed to be published before a reference to a fully-constructed object
anyhow, to link this back to Joffrey's comment: atomics do ensure that
Copy code
val i = AtomicInteger(0)
arrayOf(Thread { i.getAndIncrement() }, Thread { i.getAndIncrement() }).onEach { it.start() }.forEach { it.join() }
println(i.get())
prints
2
, because the read-increment-write cannot be interleaved, but can also provide similar power as
volatile
when dealing with the other scenarios I brought up
j
Thanks for such a complete answer, I actually learnt more myself here. I didn't know longs could be written non-atomically with multiple 32bit operations.
e
https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.7
For the purposes of the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write.
Some implementations may find it convenient to divide a single write action on a 64-bit long or double value into two write actions on adjacent 32-bit values. For efficiency's sake, this behavior is implementation-specific; an implementation of the Java Virtual Machine is free to perform writes to long and double values atomically or in two parts.
note that even on 64-bit hardware, on many platforms a load or store of a 64-bit value is non-atomic if it is only 32-bit aligned, and on all platforms is non-atomic (or forbidden) if it crosses a page boundary, and while both of those are avoidable scenarios, the way that JVM defines call frames as a stack of 32-bit slots with long/double using two adjacent slots means that even 64-bit implementations are likely to use only 32-bit alignment