skip to content

Why does incrementing an atomic.Int64 only while it stays under a cap need CompareAndSwap rather than Add?

level: middleimportance: should knowfreq 47%

answer

  1. Add cannot be told to give up
  2. the test and the increment are two operations
  3. write only if the value is unchanged
  4. false means somebody else moved it
  5. re-read inside the loop, not above it

basics

~20 s

Add is unconditional, and a Load followed by an Add leaves a gap in which another goroutine can push the value over the cap. CompareAndSwap writes only if the value is still the one you read, and you retry the whole read-and-decide when it is not.

solid answer

~50 s

`Add` on an `atomic.Int64` always applies the delta; it cannot be told to give up. So the natural shape — `if n.Load() < limit { n.Add(1) }` — is a check-then-act race: between the `Load` and the `Add` any number of goroutines can run the same check, and the counter ends up above the cap. `CompareAndSwap(old, new)` closes the gap: it writes `new` only if the current value is still exactly `old`, and returns a bool saying whether it did. The idiom is a retry loop — read the current value with `Load`, decide (return false if it is already at the cap), and attempt `CompareAndSwap(cur, cur+1)`. A false result means another goroutine won, so you loop, re-read and re-decide against the fresh value. The loop terminates because every failed attempt implies somebody else's succeeded.

code

go · 13 lines
go
// tryAcquire increments n only while it stays strictly below limit.
func tryAcquire(n *atomic.Int64, limit int64) bool {
	for {
		cur := n.Load()
		if cur >= limit {
			return false
		}
		if n.CompareAndSwap(cur, cur+1) {
			return true
		}
		// CAS failed: the value moved, so re-read and decide again
	}
}

go deeper

for a junior

Know that Add always applies and cannot be made conditional, and that checking with Load first and then adding is not one operation. Recognise CompareAndSwap as the write-only-if-unchanged method.

for a middle

Be able to write the retry loop from memory and explain each part: why the read is inside the loop, what the boolean result means, and why a failed attempt is normal rather than an error.

for a senior

Show that you can tell a strict cap from an advisory one, and pick between the CAS loop and the reserve-and-refund shape on that basis. Be ready to explain why this bug survives a clean -race run.

for a principal

Own the question of whether a cap belongs in a lock-free counter at all. Argue about where the limit is enforced, what an overshoot costs the system downstream, and when the simpler, slightly loose version is the right engineering call.

## The gap that `Add` cannot close `atomic.Int64.Add(delta)` is a single indivisible read-modify-write that returns the new value. What it is not, is *conditional*. There is no argument that says "apply this only if the result stays below 100", and there is no way to undo it inside the same operation. So the obvious code is wrong: ```go if n.Load() < limit { n.Add(1) // WRONG: the value may have changed since Load } ``` Both lines are individually atomic, and the sequence of the two is not. This is a **check-then-act** race. With `limit` at 100 and the counter at 99, twenty goroutines can all execute the `Load`, all see 99, all pass the check, and all call `Add(1)`. The counter lands at 119. Nothing is torn or corrupted — every operation did exactly what it promised — but the invariant the code was written to enforce is broken. The race detector will not report this, because there is no data race: every access went through an atomic operation. ## What compare-and-swap actually promises `func (x *Int64) CompareAndSwap(old, new int64) (swapped bool)` does one indivisible thing: if the current value is exactly `old`, replace it with `new` and report `true`; otherwise change nothing and report `false`. The comparison and the conditional write cannot be separated, which is precisely what the check-then-act version lacked. The false result is information, not an error. It means "the value moved under you since you read it", so any decision you based on the old value has to be made again against the new one. ## The retry loop ```go func tryAcquire(n *atomic.Int64, limit int64) bool { for { cur := n.Load() if cur >= limit { return false // at the cap: give up, do not retry } if n.CompareAndSwap(cur, cur+1) { return true } // somebody else changed it; re-read and decide again } } ``` Four details are worth naming. **The re-read is inside the loop.** A common bug is hoisting `cur := n.Load()` above the `for` and retrying `CompareAndSwap(cur, cur+1)` with the same stale `cur`. That can only ever fail again, or — worse — succeed later against a value that happens to have come back to `cur`, silently clobbering the updates in between. **The decision is inside the loop too.** The whole point is that the cap check must be re-evaluated against the fresh value, not just the arithmetic. **Failure is not an error path.** A `false` return from `CompareAndSwap` under contention is normal and expected; it means progress was made, just not by this goroutine. The loop is lock-free in the technical sense: some goroutine always makes progress, though no individual goroutine is guaranteed to finish in a bounded number of iterations. **Both branches leave the loop deliberately.** `return false` is the "at the cap" answer; `return true` is the "reservation taken" answer. Only the contention case loops. ## The reserve-and-refund alternative There is a second idiom worth knowing, because it appears often in admission code: ```go if n.Add(1) > limit { n.Add(-1) return false } return true ``` This uses `Add`'s return value to reserve first and give the slot back if it turns out to be over the cap. It is simpler and has no loop, but it is not equivalent: the counter *transiently* exceeds the limit, so a concurrent reader calling `Load` can observe a value above the cap, and a strict cap on a real resource (open connections, in-flight requests reserved against a quota) would be momentarily violated. Choose it when the counter is advisory and the transient overshoot is harmless; choose the CAS loop when the cap is an invariant somebody depends on. ## Where the other methods fit `Swap(new)` writes unconditionally and returns the old value — that is how you take a batch of counted events and reset the counter to zero in one step, with no window in which an increment is dropped. `Store` is the unconditional write with no result at all. `CompareAndSwap` is the only one of the family that lets the *current value* participate in the decision, and it is available on the whole typed set — `atomic.Int32`, `atomic.Bool`, `atomic.Pointer[T]` and `atomic.Value` all carry it, with the same "only if unchanged" contract. The classic caveat about compare-and-swap on pointers — the value coming back to an earlier state between your read and your swap — is much less dangerous in Go than in a manually-managed language, because a pointer you still hold cannot have been freed and its address reused while you hold it. It is still a real hazard for CAS loops over values that legitimately cycle, so if a loop's decision depends on the value never having moved, rather than on its current contents, add a monotonic counter to the state you swap.

  • What is wrong with reading the value once above the loop and retrying CompareAndSwap with it?
    The stale value can never match again while others are updating, so the loop spins pointlessly — and if the counter ever returns to that value, the swap succeeds against a decision made long ago and overwrites the intervening updates. The `Load` and the decision that depends on it both have to sit inside the loop body so each attempt is judged on fresh state.
  • When is the simpler Add-then-Add(-1) reservation acceptable instead of a CAS loop?
    When a transient overshoot is harmless. `Add(1)` followed by `Add(-1)` if the result exceeds the cap has no loop and is easy to read, but for a moment the counter really is above the limit, and a concurrent `Load` can see that. Use it for advisory counters; use the CAS loop when the cap guards a real resource and must never be seen exceeded.
  • How would you drain a counter and reset it to zero without dropping concurrent increments?
    Use `Swap(0)`: it writes zero and returns the value it replaced, both in one indivisible step, so every increment counted before the swap is in the returned number and every one after it lands in the fresh zero. A `Load` followed by a `Store(0)` leaves a window in which increments arriving between the two are silently discarded.

saying these in an interview costs you the question

  • Says Load then Add is fine because both calls are atomic
  • Hoists the Load above the retry loop and reuses the stale value
  • Treats a false result from CompareAndSwap as an error to report
  • Believes the race detector will catch a broken cap
  • Thinks Add can be given a maximum or a condition
  • Resets a counter with Load followed by Store and loses increments