DEV Community

kozhevniko
kozhevniko

Posted on

Race Conditions in File and State Transitions: Why a Lock on One Step Is Not a Control

Race Conditions in File and State Transitions: Why a Lock on One Step Is Not a Control

Concurrency bugs live in the gap between deciding and doing. A program inspects a condition, concludes that an action is safe, and then performs the action. Another request can change the world in between. The gap is narrow, which is exactly why the defect survives testing: it does not appear under sequential load, and it appears unpredictably under real load. Reviewers who look for missing checks miss it, because the check is there and the action is there. The problem is the distance between them.

The shape of the bug

Two requests arrive close together. Both read the same state, both find it acceptable, and both proceed. The first writes its result. The second overwrites that result, or acts on a resource the first has already consumed. The outcome is a duplicate, a lost update, or an action performed twice. None of these needs an unusual sequence; they need two ordinary sequences that overlap.

Check and use are separate moments

The classic form checks one piece of state and uses a different piece of state. A program verifies that a path is permitted and then opens the file by name. A program confirms that an account balance covers a transfer and then writes the new balance. The check is correct when it runs. The use is correct when it runs. The pair is wrong because the state they depend on can change between them. Renaming the operations does not close the gap; only removing the gap does.

Why a lock on one step is not enough

Adding a lock around the check feels like the fix. It is not, unless that lock also covers the use and is the same lock every other path respects. A lock protecting the read but not the write leaves the window open and adds the appearance of safety. Worse, partial locking tends to be inconsistent across a codebase, so one path locks and another does not. The control has to cover the whole transition, and it has to be the only way in.

Files, queues, and database rows

Filesystems give you atomic rename and atomic create-if-not-exists, and those are the primitives worth building on. Open the file with the exclusive-create flag instead of checking whether it exists and then creating it. Queues give you visibility timeouts and, in some systems, deduplication identifiers; use those instead of a read-then-write pair. Databases give you transactions, unique constraints, and conditional updates that only succeed when the current value matches what you read. Reach for the primitive rather than rebuilding it in application code.

Making transitions atomic

State machines give the cleanest frame. Define the states a resource can occupy and the transitions between them. Make each transition a single conditional operation that succeeds only if the resource is in the expected state, and fails otherwise. The failing request retries or reports a conflict; it does not fall through to a second code path lacking the guard. Written this way, the transition is the control, and no window exists inside it.

Idempotency and retries

Retries make race conditions more visible and more damaging. A client that resends a request which already succeeded can turn one action into two. Give every mutating operation a key supplied by the client, and have the server record which keys it has processed. The second attempt then returns the first result rather than repeating the work. Idempotency does not remove the underlying race; it removes the duplicate outcome that makes the race harmful.

Testing under concurrency

Sequential tests will not find these defects. Send the same request many times in parallel and check that exactly one succeeds. Repeat the test with slight timing offsets, because some implementations win the race by accident. Assert on invariants rather than on individual responses: a counter that must never go negative, a table that must never hold duplicate rows, a file that must exist exactly once. Invariant assertions catch the family of bugs, not just the instance.

What to review

Search the codebase for the pattern of a read followed by a dependent write, with no transactional boundary around the pair. Search for existence checks followed by creation. Search for balance or quota reads followed by debits. Search for any place where a permission decision is made and then acted upon later. Every hit is a candidate. The fix is normally to collapse the pair into one atomic operation, and the review is how you find the pairs.

Top comments (0)