Balking — Find the Bug¶
Buggy balking snippets. Read the code, find the defect, then check the diagnosis. Most bugs here are variations of one theme: a non-atomic check-then-act, or a silent no-op masking a real problem. Foundations in junior.md.
Table of Contents¶
- Bug 1 — Non-atomic check-then-act
- Bug 2 —
volatileis not atomicity - Bug 3 — Dirty flag reset before write
- Bug 4 — Plain field visibility
- Bug 5 — Cleanup in the CAS loser branch
- Bug 6 — Balking where you must wait
- Bug 7 — Silent balk hides a bug
- Bug 8 — Lock object reassigned
- Bug 9 — Double-checked balk without
volatile - Bug 10 — CAS loop instead of single CAS
- Bug 11 — Go data race on a bool flag
- Bug 12 — Treating balk
falseas failure - Practice Tips
Bug 1 — Non-atomic check-then-act¶
private boolean started = false;
public boolean start() { // NOT synchronized
if (started) return false;
started = true;
init();
return true;
}
started == false before either writes true; both run init(). Root cause. The classic check-then-act race — the check and the flag write are not atomic. Fix. Make the method synchronized, or use if (!startedAtomic.compareAndSet(false, true)) return false;. Bug 2 — volatile is not atomicity¶
private volatile boolean closed = false;
public void close() {
if (closed) return;
closed = true; // still two separate operations
cleanup();
}
volatile fixes visibility but not the race: two threads can both read false, both run cleanup(). Root cause. Confusing visibility (volatile) with atomic read-modify-write. Fix. AtomicBoolean closed; if (!closed.compareAndSet(false, true)) return;. Bug 3 — Dirty flag reset before write¶
public synchronized void save() {
if (!changed) return;
changed = false; // reset FIRST
writeToDisk(content); // throws IOException sometimes
}
writeToDisk throws, the edit is lost and changed is already false, so a later save() balks and never persists it. Root cause. Resetting the dirty flag before the side effect succeeds. Fix. Reset after a successful write, or restore in a catch: Bug 4 — Plain field visibility¶
private boolean ready = false; // plain field
public void markReady() { ready = true; } // writer thread
public boolean tryUse() { // reader thread
if (!ready) return false; // may NEVER see the write
use(); return true;
}
volatile/lock there's no happens-before edge; the reader may spin forever seeing false, or see ready==true before use()'s prerequisites are published. Root cause. Cross-thread read of a plain field — no visibility guarantee. Fix. volatile boolean ready (if a stale balk is acceptable) or AtomicBoolean. Bug 5 — Cleanup in the CAS loser branch¶
public void close() {
if (closed.compareAndSet(false, true)) {
// winner does nothing here?!
} else {
releaseResources(); // WRONG: loser runs cleanup
}
}
compareAndSet's return: true = "I won, I do the work." Fix. if (!closed.compareAndSet(false, true)) return; releaseResources();. Bug 6 — Balking where you must wait¶
public Item take() {
if (queue.isEmpty()) return null; // balk
return queue.poll();
}
// consumer:
Item i = take();
process(i); // NPE when it balked
null forever even though a producer will soon add one. The consumer NPEs or busy-loops. Root cause. Using Balking where Guarded Suspension is required — "later" is the correct answer. Fix. Block: while (queue.isEmpty()) wait(); (or use a BlockingQueue.take()). Bug 7 — Silent balk hides a bug¶
public void capturePayment(String key) {
if (captured.contains(key)) return; // silent balk
captured.add(key);
gateway.charge(key);
}
contains + add is itself non-atomic (see Bug 11's theme). Root cause. Swallowing an invariant-violating balk with no signal. Fix. Make the dedupe atomic and observable: if (!captured.add(key)) { // atomic on a concurrent set
metrics.counter("capture.double_submit").increment();
log.warn("capture balked: {} already processed", key);
return;
}
Bug 8 — Lock object reassigned¶
private Object lock = new Object();
public void start() {
synchronized (lock) {
if (started) return;
lock = new Object(); // reassigning the lock mid-method
started = true; init();
}
}
private final Object lock = new Object(); — never reassign a lock. Bug 9 — Double-checked balk without volatile¶
private boolean done = false; // not volatile
public void runOnce(Runnable r) {
if (done) return; // fast path
synchronized (this) {
if (done) return;
r.run();
done = true;
}
}
done is outside the lock and done isn't volatile, so a thread may read a stale false indefinitely, or (worse) see done==true before r.run()'s writes are visible. Root cause. Double-checked locking with a non-volatile flag — see Double-Checked Locking. Fix. Make done volatile, or just use sync.Once/AtomicBoolean. Bug 10 — CAS loop instead of single CAS¶
public void close() {
while (!closed.compareAndSet(false, true)) {
// spin
}
cleanup(); // EVERY caller eventually runs this
}
if (!closed.compareAndSet(false, true)) return; cleanup(); — one CAS, no loop. Bug 11 — Go data race on a bool flag¶
type S struct{ started bool }
func (s *S) Start() {
if s.started { return } // racy read
s.started = true // racy write
s.init()
}
bool accessed from multiple goroutines is a data race (go test -race flags it) and check-then-act isn't atomic. Root cause. No synchronization on shared mutable state. Fix. Use sync.Once or atomic.Bool.CompareAndSwap: Bug 12 — Treating balk false as failure¶
What's wrong. start() returns false when it balks (already running) — a normal idempotent outcome, not an error. This throws on a perfectly healthy second call. Root cause. Conflating "balked" with "failed." Fix. Treat false as "already running" — log at DEBUG or ignore; only throw on actual exceptions. Practice Tips¶
- For every balk, ask two questions: Is check-then-act atomic? and Does a silent no-op hide a real bug? Most defects here fail one of those.
- Memorize the distinction:
volatile= visibility,compareAndSet= atomicity. Bugs 2, 4, 9, 11 all stem from conflating them. - When you see
compareAndSet, verify the winner (true) does the work and the loser balks — Bug 5 is a frequent inversion. - Run Go tests with
-raceand Java once-only code underjcstress; these races almost never reproduce on a single casual run. - Watch the dirty-flag ordering (Bug 3): the reset must follow the successful side effect, never precede it.
In this topic