C++
Thread Safety
Concurrent Programming
Integer Operations
Multithreading

C Thread Safe Integer

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

When multiple threads update the same integer in C++, a plain int is not enough. Reads and writes can interleave in undefined ways, producing lost updates, stale values, and race conditions that are often difficult to reproduce.

The usual answer is either std::atomic<int> or a mutex around a normal integer. The correct choice depends on whether you are protecting a single value or a larger invariant that spans several operations.

Use std::atomic<int> for Simple Shared Counters

For counters, flags, and single-value state transitions, std::atomic<int> is the standard tool. It guarantees that each operation is atomic and that the compiler will not optimize it into unsafe code.

cpp
1#include <atomic>
2#include <iostream>
3#include <thread>
4#include <vector>
5
6int main() {
7    std::atomic<int> counter{0};
8    std::vector<std::thread> workers;
9
10    for (int i = 0; i < 4; ++i) {
11        workers.emplace_back([&counter]() {
12            for (int j = 0; j < 100000; ++j) {
13                counter.fetch_add(1, std::memory_order_relaxed);
14            }
15        });
16    }
17
18    for (auto& worker : workers) {
19        worker.join();
20    }
21
22    std::cout << "Final count: " << counter.load() << '\n';
23}

The call to fetch_add ensures that no increments are lost. Without the atomic type, two threads could read the same old value and both write back the same new value.

Understand What Atomic Does and Does Not Protect

Atomic operations protect the integer itself, not the meaning around it. If your logic says "increment only if another variable has a certain value", then the whole decision must be synchronized, not just the increment.

Consider this pattern:

cpp
if (remaining.load() > 0) {
    remaining.fetch_sub(1);
}

That code is still racy at the logical level because another thread can change remaining between the load and the subtraction. In that case, use compare_exchange or a mutex.

cpp
1bool try_take_slot(std::atomic<int>& remaining) {
2    int current = remaining.load();
3
4    while (current > 0) {
5        if (remaining.compare_exchange_weak(current, current - 1)) {
6            return true;
7        }
8    }
9
10    return false;
11}

This loop retries until it either successfully updates the value or discovers that no slots remain.

Use a Mutex for Compound Invariants

If the integer is part of a larger state update, a mutex is clearer and safer. Mutexes protect critical sections rather than individual machine operations.

cpp
1#include <iostream>
2#include <mutex>
3#include <thread>
4
5struct Account {
6    int balance = 0;
7    int version = 0;
8    std::mutex mtx;
9
10    void deposit(int amount) {
11        std::lock_guard<std::mutex> lock(mtx);
12        balance += amount;
13        version += 1;
14    }
15};

Here, both balance and version must move together. Using an atomic integer for only one field would not keep the pair consistent.

Choosing Memory Ordering

Many examples use the default sequentially consistent ordering because it is easiest to reason about. For simple counters where you only care about accurate totals, std::memory_order_relaxed is often enough and can reduce overhead.

Use stronger ordering when the integer coordinates access to other shared data. For example, a published state flag may need release and acquire semantics:

cpp
1std::atomic<int> ready{0};
2int payload = 0;
3
4void writer() {
5    payload = 42;
6    ready.store(1, std::memory_order_release);
7}
8
9void reader() {
10    if (ready.load(std::memory_order_acquire) == 1) {
11        std::cout << payload << '\n';
12    }
13}

The release and acquire pair ensures that once reader sees ready == 1, it also sees the initialized payload.

Common Pitfalls

  • Treating volatile int as a thread-safety mechanism. In C++, volatile is not a substitute for atomic or mutex-based synchronization.
  • Mixing atomic and non-atomic access to the same variable. Every access must follow the same synchronization strategy.
  • Assuming atomic operations protect related data structures. They do not preserve wider invariants by themselves.
  • Overusing lock-free techniques for code that would be simpler with a mutex. Correctness is more important than theoretical speed.
  • Choosing relaxed memory ordering without understanding whether the integer coordinates access to other data.

Summary

  • Use std::atomic<int> for simple counters, flags, and single shared integers.
  • Use compare_exchange when an update depends on the current value.
  • Use a mutex when the integer is part of a larger state transition.
  • 'volatile does not make integer access thread-safe in C++.'
  • Pick memory ordering deliberately, with sequential consistency as the safest default.

Course illustration
Course illustration

All Rights Reserved.