concurrency
atomic operations
CAS
thread safety
programming techniques

Atomically increment two integers with CAS

Master System Design with Codemia

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

Introduction

If two integers must be incremented as one indivisible operation, updating two separate atomic variables is not enough. Another thread can still observe an intermediate state between the two increments. To make the pair update atomic with compare-and-swap, you need one shared state value that represents both integers together, or you need a lock.

Why Two Independent Atomics Are Not One Atomic Operation

Consider two atomics:

cpp
std::atomic<int> a{0};
std::atomic<int> b{0};

If one thread does:

cpp
++a;
++b;

another thread can observe:

  • 'a updated and b old'
  • 'a old and b updated'
  • both updated

Each increment is atomic by itself, but the pair is not atomic as a unit. That is the core problem.

Pack Both Integers into One CAS Target

One common lock-free approach is to store both integers in one machine word and use CAS on that combined value.

cpp
1#include <atomic>
2#include <cstdint>
3
4struct Pair {
5    uint32_t x;
6    uint32_t y;
7};
8
9static uint64_t pack(Pair p) {
10    return (static_cast<uint64_t>(p.x) << 32) | p.y;
11}
12
13static Pair unpack(uint64_t value) {
14    return Pair{
15        static_cast<uint32_t>(value >> 32),
16        static_cast<uint32_t>(value & 0xffffffffu)
17    };
18}
19
20std::atomic<uint64_t> state{pack({0, 0})};
21
22void increment_both() {
23    uint64_t old_value = state.load(std::memory_order_relaxed);
24
25    while (true) {
26        Pair current = unpack(old_value);
27        Pair next{current.x + 1, current.y + 1};
28        uint64_t new_value = pack(next);
29
30        if (state.compare_exchange_weak(
31                old_value,
32                new_value,
33                std::memory_order_acq_rel,
34                std::memory_order_relaxed)) {
35            return;
36        }
37    }
38}

Here the CAS operates on one uint64_t, so the pair transition from old to new is atomic as long as the platform supports atomic operations for that width.

CAS Works Best When the State Fits in One Atomic Object

The combined-state approach is the core CAS idea:

  1. read the whole state
  2. compute the next whole state
  3. CAS old state to new state
  4. retry if another thread won the race

This pattern is good when both values fit comfortably into one atomic slot. If the state is larger, you either need platform-specific wide CAS support or a different synchronization strategy.

Sometimes a Lock Is the Better Answer

If the only requirement is correctness, a mutex is often simpler and clearer.

cpp
1#include <mutex>
2
3int a = 0;
4int b = 0;
5std::mutex m;
6
7void increment_both() {
8    std::lock_guard<std::mutex> lock(m);
9    ++a;
10    ++b;
11}

A lock is not automatically a bad solution. CAS-based code is harder to write, harder to test, and easier to get wrong. Use CAS when you have a real reason to avoid locks, not because lock-free sounds better in theory.

Watch Out for Overflow and ABA-Like Design Issues

Packing integers into one word introduces practical constraints:

  • each field must fit the chosen bit width
  • overflow behavior must be defined
  • readers and writers must agree on the packing layout

The CAS loop can also spin heavily under contention. That is another reason to compare the lock-free version against a simple mutex before declaring the CAS solution better.

Common Pitfalls

  • Assuming two separate atomic increments automatically form one atomic pair update.
  • Trying to CAS two memory locations independently and calling the combined effect atomic.
  • Using a packed representation without checking integer width and overflow behavior.
  • Choosing CAS for simplicity when a mutex would be easier to maintain and verify.
  • Ignoring contention costs in a retry loop that may spin frequently under load.

Summary

  • Two integers can be incremented atomically with CAS only if both live inside one atomic state value.
  • Packing both integers into one uint64_t is a common lock-free technique.
  • The CAS loop reads the whole state, computes the next state, and retries on conflict.
  • A mutex is often the simpler and more maintainable solution when raw lock-free behavior is not required.
  • Correctness comes from atomicity of the combined state, not from using CAS on two separate variables.

Course illustration
Course illustration

All Rights Reserved.