0

Race condition — two concurrent operations producing wrong results due to timing

Intermediate5 min read·eng-20-011
interviewperformancesecurity

Concept

Race condition — a bug that occurs when the outcome of a program depends on the timing or interleaving of multiple concurrent operations. Two (or more) processes or threads access shared state simultaneously, and the result is non-deterministic.

Why race conditions are hard: They're intermittent. The bug only manifests when two operations happen to execute at exactly the wrong time. In testing, it works fine. In production under load, it fails.

Classic example — bank account:

text
Process A: reads balance = $100
Process B: reads balance = $100
Process A: subtracts $50, writes $50
Process B: subtracts $50, writes $50  ← overwrites A's write!
Final balance: $50 (should be $0)

Both processes read $100 before either wrote, causing $50 to be "double-spent."

Common race conditions in PHP/Laravel:

  • Two requests check "user has credits" simultaneously, both see 1 credit, both deduct 1 credit → negative balance.
  • Two workers pick up the same job.
  • Two processes create the same unique record → duplicate key violation (but only sometimes, depending on timing).

Solutions:

  • Optimistic locking: Add a version column. On update, check version still matches. If someone else updated first, retry.
  • Pessimistic locking: Lock the row when reading. lockForUpdate() or sharedLock() in Laravel.
  • Atomic operations: Use database-level atomic operations (INCREMENT, DECREMENT) instead of read-modify-write.
  • Redis locks / mutexes: Cache::lock() for distributed locks across multiple servers.
  • Unique constraints: Let the database enforce uniqueness — duplicate inserts fail with an exception.

Code Example

php
<?php
// RACE CONDITION — the "check then act" anti-pattern
public function purchaseWithCredits(User $user, int $amount): void
{
    // RACE CONDITION: Two requests can both pass the check
    if ($user->credits < $amount) {
        throw new \Exception('Insufficient credits');
    }

    // Both processes reach here if they both read credits before either writes
    $user->decrement('credits', $amount); // second process overwrites first!
}

// FIX 1: Pessimistic locking — lock the row
public function purchaseWithCredits(User $user, int $amount): void
{
    DB::transaction(function () use ($user, $amount) {
        // lockForUpdate() prevents other reads (SELECT ... FOR UPDATE)
        $user = User::where('id', $user->id)->lockForUpdate()->first();

        if ($user->credits < $amount) {
            throw new \Exception('Insufficient credits');
        }

        $user->decrement('credits', $amount);
        // Transaction ends → lock released → other processes can proceed
    });
}

// FIX 2: Optimistic locking
public function purchaseWithCredits(User $user, int $amount): void
{
    $maxAttempts = 3;
    for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
        $updated = User::where('id', $user->id)
            ->where('credits', '>=', $amount) // check credits
            ->where('version', $user->version) // check version (optimistic lock)
            ->update([
                'credits' => DB::raw("credits - {$amount}"),
                'version' => DB::raw('version + 1'),
            ]);

        if ($updated === 1) return; // success!
        // 0 rows updated: either insufficient credits OR version changed (conflict)
        $user = $user->fresh(); // reload and retry
    }
    throw new \Exception('Update failed after retries');
}

// FIX 3: Atomic DB operation (no read-modify-write)
// MySQL: only update if balance won't go negative
$affected = DB::table('users')
    ->where('id', $user->id)
    ->where('credits', '>=', $amount) // atomic check+update
    ->decrement('credits', $amount);

if ($affected === 0) {
    throw new \Exception('Insufficient credits');
}

// Redis distributed lock — for cross-server races
$lock = Cache::lock("purchase:{$user->id}", 5); // 5-second lock
try {
    $lock->block(3); // wait up to 3s for the lock
    // Only one process runs this at a time per user
    if ($user->fresh()->credits < $amount) throw new \Exception('Insufficient credits');
    $user->decrement('credits', $amount);
} finally {
    $lock->release();
}