6.1.9 · D4Parallelism & Multicore

Exercises — Atomic operations and CAS

2,185 words10 min readBack to topic

Before we start, one shared vocabulary reminder so no symbol is used unearned:


Level 1 — Recognition

Exercise 1.1

Which of these operations is atomic on typical hardware, and which is a race-prone read-modify-write? (a) x = 5; (single aligned 32-bit store) (b) counter++; (c) CAS(&p, old, new) (d) total += array[i];

Recall Solution

Atomic: (a) and (c). Not atomic: (b) and (d).

  • (a) A single aligned store of a machine word is atomic — no other thread can see it half-written.
  • (b) counter++ compiles to load → add → store: three separate steps with a race window between them.
  • (c) CAS is a single hardware primitive, atomic by definition.
  • (d) += is also load → add → store, same race window as (b). Count of atomic operations: 2.

Exercise 1.2

CAS is called . If *addr = 7, what does CAS(addr, 4, 9) do to memory and what does it return?

Recall Solution

The stored value is but . Since , the compare fails: memory stays and CAS returns false. Return = false, memory unchanged (still 7).


Level 2 — Application

Exercise 2.1

Trace atomic_increment on a counter starting at , where CAS succeeds on the second attempt (another thread bumped the counter to between your read and your CAS). List each old_val, new_val, and CAS result. What is the final counter value?

Recall Solution
old_val = *counter        // read 10
new_val = old_val + 1      // 11
CAS(counter, 10, 11)       // FAILS: counter is now 11, not 10
old_val = *counter         // re-read 11
new_val = old_val + 1      // 12
CAS(counter, 11, 12)       // SUCCEEDS: counter was 11
  • Attempt 1: old_val=10, new_val=11, CAS → false
  • Attempt 2: old_val=11, new_val=12, CAS → true
  • Final counter = 12. (The interfering thread's increment made it 11, ours made it 12 — no update lost.)

Exercise 2.2

Using fetch_and_add(addr, delta) which returns the value before the add: if *addr = 100 and two threads run fetch_and_add(addr, 5) and fetch_and_add(addr, 3), what is the final value of *addr, and what two return values are possible (list both orderings)?

Recall Solution

Fetch-and-add is atomic, so the two adds compose regardless of order. Final value = . Return values depend on order:

  • If +5 runs first: it returns , the +3 returns .
  • If +3 runs first: it returns , the +5 returns . Exactly one thread always sees ; the other sees the partially-updated value. Final = 108 either way.

Level 3 — Analysis

Exercise 3.1

A spinlock uses test_and_set, which atomically writes and returns the old value. The lock is "free" when the word is . Write the acquire loop and explain: what old value means "I got the lock"? See the figure.

Figure — Atomic operations and CAS
Recall Solution
void acquire(int* lock) {
    while (test_and_set(lock) == 1) {
        // spin: old value was 1 => lock was already held
    }
    // old value was 0 => it was free, and we just set it to 1: we own it
}

You got the lock when test_and_set returns . Returning means the word was free and you atomically flipped it to in the same indivisible step. Returning means it was already held by someone; you keep spinning. See Spinlocks and Mutexes.

Exercise 3.2

Below is a lock-free pop trace. Identify the exact line where the ABA problem strikes and why CAS wrongly succeeds.

Initial: stack_top -> A -> B -> NULL
T1: old_top = A
T1: new_top = A.next = B
    [T1 preempted]
T2: pop A          (stack_top = B)
T2: pop B          (stack_top = NULL)
T2: push A         (stack_top = A, reusing freed node A)
T1: CAS(&stack_top, A, B)   <-- ???
Recall Solution

The final CAS wrongly succeeds. T1's expected = A and stack_top is once again A, so the pointer comparison matches — but the structure changed underneath. T1 sets stack_top = B, yet was already popped and freed by T2. Result: stack_top points to freed memory → corruption. The root cause: CAS compares the pointer value, not the object identity. The pointer returned to (the "A → B → A" sequence), so CAS cannot tell that everything moved. Fix in ABA Problem Solutions and Exercise 4.1.


Level 4 — Synthesis

Exercise 4.1

Fix Exercise 3.2 with a version counter (tagged pointer). The stacked value is a pair and double-width CAS must match both fields. Suppose T1 read . During the A→B→A recycling, T2 performed 3 modifications (pop, pop, push). What version does stack_top hold when T1 finally runs CAS, and does T1's CAS succeed?

Recall Solution

Each modification increments the version by :

  • start:
  • after pop A:
  • after pop B:
  • after push A:

T1's CAS uses expected = ⟨A, 7⟩. Current is . The pointer matches () but , so double-width CAS fails — exactly what we want. Final version = 10; T1's CAS fails and it safely retries. The ABA is defeated.

Exercise 4.2

Derive swap(addr, new) (unconditionally store new, return the old value) using only CAS in a loop. Prove it always terminates in the absence of contention with exactly one CAS.

Recall Solution
int swap(int* addr, int new) {
    int old_val;
    do {
        old_val = *addr;               // read current
    } while (!CAS(addr, old_val, new));// install new only if unchanged
    return old_val;
}

No contention: we read old_val, and nothing modifies *addr before CAS, so *addr == old_val still holds. CAS succeeds on the first attempt → exactly one CAS, then we return old_val. With contention, each failure means someone else wrote, so we re-read and retry; every retry makes progress by some thread (lock-freedom). See Lock-Free Data Structures.


Level 5 — Mastery

Exercise 5.1

On an ARM/RISC-V machine you have LL/SC instead of CAS. LL(addr) loads and sets a reservation; SC(addr, val) stores and returns on success, if the reservation was broken by any intervening write. Explain in one line why LL/SC is immune to ABA in the A→B→A scenario of Exercise 3.2, without any version counter. Then implement CAS on top of LL/SC.

Recall Solution

Immunity: SC fails if any write touched the address after the LL — even a write that restores the same value. So the A→B→A sequence breaks the reservation and SC returns , unlike CAS which only compares final bits. (Caveat: hardware may lose reservations spuriously, so LL/SC always lives in a retry loop.)

bool CAS(int* addr, int expected, int new) {
    int cur = LL(addr);          // load + reserve
    if (cur != expected)
        return false;            // value differs, give up
    return SC(addr, new) == 1;   // store only if reservation intact
}

If SC returns (reservation broken), CAS returns false, matching CAS semantics. See Memory Barriers for the ordering fences usually paired with this.

Exercise 5.2

A counter is incremented by threads, each doing successful atomic increments via a CAS loop. Give the final counter value and a lower bound on the minimum total number of CAS executions (successful + failed) across all threads. Compute both for , .

Recall Solution

Final value: every increment lands exactly once (CAS guards against lost updates), so final . Minimum total CAS executions: in the best case there is zero contention — every CAS succeeds first try — so each of the successful increments costs exactly one CAS. Minimum . (Failures only add to this; the count can be much higher under contention, but never lower than .) Answers: final = 4000, minimum total CAS = 4000.


Recall Self-test cloze

An atomic op executes completely and indivisibly, so no thread sees a partially-completed state. CAS returns true only when *addr equals expected. The ABA problem fools CAS because it compares the pointer value not the object identity. Version counters defeat ABA by forcing CAS to match both the pointer and the version. LL/SC is ABA-immune because SC fails on any intervening write, not just a value mismatch.