Atomics and the memory model
A mutex is the general answer to a data race, and an atomic type is the narrow one: a single variable whose reads and writes cannot be split by another thread. C11 puts them in stdatomic.h. The operations also define the order in which other memory becomes visible, which is the memory model the standard describes.
An atomic counter
atomic_int is an integer with atomic operations. atomic_fetch_add adds and returns the old value in one indivisible step, so the lost update of lesson 73 cannot happen. Two threads and a million increments each give exactly two million.
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>
static atomic_int counter = 0;
static void *bump(void *argument)
{
(void) argument;
for (int i = 0; i < 1000000; i++) {
atomic_fetch_add(&counter, 1);
}
return NULL;
}
int main(void)
{
pthread_t first;
pthread_t second;
pthread_create(&first, NULL, bump, NULL);
pthread_create(&second, NULL, bump, NULL);
pthread_join(first, NULL);
pthread_join(second, NULL);
printf("counter: %d\n", atomic_load(&counter));
return 0;
}
gcc -Wall -Wextra -pthread counter.c -o counter
./counter
counter: 2000000
The count is exact without a mutex because the hardware does the whole read-modify-write as one operation. On x86_64 an atomic int is lock-free; a larger type may need a library call, which the linker pulls in automatically. Reading the value uses atomic_load rather than a plain read.
fetch_add and compare_exchange
Two operations cover most lock-free code. fetch_add returns the value before the addition, which is useful for handing out sequence numbers. compare_exchange_strong writes a new value only if the current one equals the expected value, and on success it reports true.
#include <stdatomic.h>
#include <stdio.h>
int main(void)
{
atomic_int value = 10;
int old = atomic_fetch_add(&value, 5);
int expected = 15;
printf("fetch_add returned %d, now %d\n", old, atomic_load(&value));
int matched = atomic_compare_exchange_strong(&value, &expected, 100);
printf("exchange matched: %d, now %d\n", matched, atomic_load(&value));
return 0;
}
gcc -Wall -Wextra ops.c -o ops
./ops
fetch_add returned 10, now 15
exchange matched: 1, now 100
The first line shows the old value and the new one. The second succeeds because 15 was the current value and 15 was expected; on success the variable becomes 100 and the expected variable is left alone. On failure the function writes the actual value into the expected variable, which is how a retry loop learns what to compare next.
Acquire and release
Atomicity alone does not order other memory. A store with memory_order_release publishes the writes before it, and a load with memory_order_acquire that sees the value also sees those writes. The program sends a flag this way, and the main thread spins on the flag before reading the data.
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>
static atomic_int data = 0;
static atomic_int ready = 0;
static void *producer(void *argument)
{
(void) argument;
atomic_store_explicit(&data, 42, memory_order_relaxed);
atomic_store_explicit(&ready, 1, memory_order_release);
return NULL;
}
int main(void)
{
pthread_t thread;
pthread_create(&thread, NULL, producer, NULL);
while (atomic_load_explicit(&ready, memory_order_acquire) == 0) {
}
printf("data: %d\n", atomic_load_explicit(&data, memory_order_relaxed));
pthread_join(thread, NULL);
return 0;
}
gcc -Wall -Wextra -pthread order.c -o order
./order
data: 42
The relaxed store to data may be reordered by the compiler, but the release store to ready cannot move before it, and the acquire load cannot move after the read of data. The pair is the standard way to hand a structure to another thread without a mutex.
A non-pointer expected value
compare_exchange needs the address of the expected value, not the value itself: on failure it writes the actual value there, and a plain int cannot receive that write. Passing the int is an error that the compiler reports at the call, with the name of the builtin behind the macro.
#include <stdatomic.h>
#include <stdio.h>
int main(void)
{
atomic_int value = 10;
int expected = 10;
int matched = atomic_compare_exchange_strong(&value, expected, 20);
printf("%d\n", matched);
return 0;
}
gcc -Wall -Wextra badexpected.c -o badexpected
In file included from badexpected.c:1:
badexpected.c: In function 'main':
badexpected.c:9:19: error: argument 2 of '__atomic_compare_exchange' must be a pointer type
9 | int matched = atomic_compare_exchange_strong(&value, expected, 20);
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The second argument in the working example was &expected. The message names the builtin rather than the macro because the macro expands to it, and the caret points at the whole call; the argument number is what identifies the mistake.
Exercises
- Use atomic_fetch_add to hand each of four threads a unique sequence number and print the numbers in creation order.
- Write a spin lock with atomic_flag_test_and_set and protect a counter with it.
- Change the acquire/release example to memory_order_relaxed on both sides and explain why the result is no longer guaranteed.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
What does
atomic_fetch_addreturn?Answer
The value before the addition
The old value is returned, which is useful for handing out sequence numbers while the new value is stored atomically.
-
What does
atomic_compare_exchange_strongneed as its second argument?Answer
A pointer to the expected value
On failure the function writes the actual value into the expected variable, so it needs its address.
-
What does a release store publish?
Answer
The writes that came before it
A release store orders earlier writes before it, and an acquire load that sees the value sees those writes too.
-
Why read an atomic with
atomic_loadinstead of a plain read?Answer
A plain read is not an atomic operation
The load is part of the atomic contract and keeps the access indivisible and correctly ordered.
-
What does this program print?
#include <pthread.h> #include <stdatomic.h> #include <stdio.h> static atomic_int c=0;static void *b(void *a){(void)a;for(int i=0;i<1000000;i++)atomic_fetch_add(&c,1);return 0;} int main(void){pthread_t x,y;pthread_create(&x,0,b,0);pthread_create(&y,0,b,0);pthread_join(x,0);pthread_join(y,0);printf("%d\n",atomic_load(&c));return 0;}Answer
2000000
atomic_fetch_addmakes each increment indivisible, so both million-iteration loops count exactly. -
What does this program print?
#include <stdatomic.h> #include <stdio.h> int main(void){atomic_int v=10;int old=atomic_fetch_add(&v,5);int e=15;printf("%d %d\n",old,atomic_load(&v));int ok=atomic_compare_exchange_strong(&v,&e,100);printf("%d %d\n",ok,atomic_load(&v));return 0;}Answer
10 15 / 1 100
The fetch returns the old value 10 and leaves 15, then the successful exchange stores 100.
-
What does this program print?
#include <pthread.h> #include <stdatomic.h> #include <stdio.h> static atomic_int data=0,ready=0; static void *producer(void *a){(void)a;atomic_store_explicit(&data,42,memory_order_relaxed);atomic_store_explicit(&ready,1,memory_order_release);return 0;} int main(void){pthread_t t;pthread_create(&t,0,producer,0);while(atomic_load_explicit(&ready,memory_order_acquire)==0){}printf("%d\n",atomic_load_explicit(&data,memory_order_relaxed));pthread_join(t,0);return 0;}Answer
42
The producer stores 42 before the release flag, and the reader sees it after the acquire load.
-
What does GCC report for a non-pointer expected value?
Answer
Argument 2 of the builtin must be a pointer
The macro expands to the builtin, and the message names argument 2 as the one that must be a pointer.
-
What is the mistake in passing
expectedby value?Answer
A failure cannot write the actual value back
The function updates the expected variable on failure, so it needs the address rather than a copy.
-
What breaks when both sides of the flag use
memory_order_relaxed?Answer
The data write may become visible after the flag
Without release and acquire, the reader may see the flag before the data write, which is exactly the ordering the pair provides.
The code on this page was run with gcc (Alpine 15.2.0) 15.2.0 on Alpine Linux 3.24.3 (x86_64) in a Docker container.
Article author: Arthur Isaev