An operation on data is said to be atomic if it is impossible to find the operation half-way done. It’s very easy to see when an operation is not atomic when used in a struct:
1
2
3
4
5
6
7
8
9
10
11
struct Type {
int a;
int b;
}
Type data;
void fillType(int a, int b) {
data.a = a;
data.b = b;
}
The example above shows a non-atomic function that sets some values in a struct (fillType
). The operation is not atomic, because two threads calling the same function would leave the data in an inconsistent state. Imagine this scenario:
Thread1
callsfillType
with values1
and2
Thread2
callsfillType
with values9
and8
Thread1
sets data.a to1
Thread2
sets data.a to9
Thread2
sets data.b to8
Thread1
sets data.b to2
After both threads are done executing, data.a
will be set to 9
and data.b
will be set to 2
. This is a state that neither Thread1
nor Thread2
expected. This unexpected behavior can be prevented by using a mutex.
Are mutexes necessary for primitive types?
Once I learned how to use mutexes, I started using them every time I had to make an operation on an object atomic. One thing that I wasn’t sure was if a mutex is necessary for a primitive type. Could setting a primitive type to a value end up in an incosistent state?
I decided to put this to the test with a little program:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <thread>
#include <iostream>
// Testing the `unsigned int` type
unsigned int number = 0;
void setToValue(int val) {
while (true) {
number = val;
}
}
int main() {
// Each thread will set a different byte. If at any point there is more than
// one byte set, it means the type is not atomic
std::thread t1(setToValue, 1);
std::thread t2(setToValue, 1 << 8);
std::thread t3(setToValue, 1 << 16);
std::thread t4(setToValue, 1 << 24);
// Keep printing the value of number
while (true) {
std::cout << number << std::endl;
}
// I don't join the threads because the program will run until I kill it. At
// that point, everything will be killed
}
I left the program to execute for a couple of minutes and no value got corrupted. The output was always one of the expected values. After doing some research about this, it seems like the result depends on the architecture. Although this experiment didn’t fail in my system, it is possible that it fails in other systems, so primitive types shouldn’t be trusted to be atomic.
Atomic types
Because there is no guarantee that operations on primitive types are atomic, C++ provides atomic types. Atomic types are a wrapper on a type that allows only certain operations that are guaranteed to be atomic.
The most common operations on atomic types are:
store(T desired)
- Set the value todesired
load()
- Get the valueexchange(T desired)
- Set the value, and return the previous valuecompare_exchange_strong(T expected, T desired)
- First it checks if the value is set toexpected
. If it is not, it returns false without doing anything else. If the current value is the same asexpected
, it is set todesired
and the function returns true.compare_exchange_weak(T expected, T desired)
- Does the same ascompare_exchange_strong
, but it is possible that it will returnfalse
even if the value is asexpected
. The reason this version exists is that it can give better performance in some situations.
Even when I didn’t see any failure in my previous test, I’m going to update it, so it is guaranteed to give an expected result regardless of the platform.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <thread>
#include <iostream>
#include <atomic>
// Testing the `unsigned int` type
std::atomic<unsigned int> number(0);
void setToValue(int val) {
while (true) {
number.store(val);
}
}
int main() {
// Each thread will set a different byte. If at any point there is more than
// one byte set, it means the type is not atomic
std::thread t1(setToValue, 1);
std::thread t2(setToValue, 1 << 8);
std::thread t3(setToValue, 1 << 16);
std::thread t4(setToValue, 1 << 24);
// Keep printing the value of number
while (true) {
std::cout << number.load() << std::endl;
}
// I don't join the threads because the program will run until I kill it. At
// that point, everything will be killed
}
This is a very simple use of atomic types, just to get familiar with them. In future articles, I will explore in which situations they are useful and how to use them in those situations.
c++
programming
]