A race condition occurs when the timing of access to shared state can break a program’s rules. Two tasks may each read an old value, then overwrite one another’s updates.
The fix must guard the whole operation across all actors that share the state. A different finish order alone need not be a bug.
Here, the rule is simple: two requested increments from zero must leave two. We tested the example under CPython 3.12.14, in one process, with one event loop. The failing schedule is forced, not a measure of how often a real service fails.
Follow the race condition through a lost update
MITRE’s CWE-362 describes a shared-resource timing window in which another sequence can interfere. For this counter, the linked steps are read, compute and write. If each task works from its own stale copy, both can finish while one update is lost.
| Step | Task A | Task B | Shared counter |
|---|---|---|---|
| Start | — | — | 0 |
| Read | Saves 0 | Saves 0 | 0 |
| Compute | Prepares 1 | Prepares 1 | 0 |
| Write | Stores 1 | Then stores 1 | 1 |
Either task could write first and the final value would still be one in this schedule. Both based their result on zero.
The last write does not add one more. It replaces the current value with a result based on an old read.
This is a failed rule, not just a surprising log order. If the tasks took turns over the full operation, the second would read one and store two. That distinction gives the test a concrete assertion to check.

Two stale reads lead to one.
Reproduce the window deliberately
Save this complete program as race_demo.py and run it with python race_demo.py. It needs no third-party packages. The unprotected branch uses a two-party barrier after the read, so neither worker can write until both have read the initial value.
import asyncio
async def counter_test(use_lock):
counter = 0
barrier = asyncio.Barrier(2)
lock = asyncio.Lock()
async def worker():
nonlocal counter
if use_lock:
async with lock:
before = counter
await asyncio.sleep(0) # Yield while holding the lock.
counter = before + 1
else:
before = counter
await barrier.wait() # Both tasks must read before writing.
counter = before + 1
await asyncio.gather(worker(), worker())
return counter
async def main():
print('unprotected:', await counter_test(False))
print('shared lock:', await counter_test(True))
asyncio.run(main())
Observed output:
unprotected: 1
shared lock: 2
The Python task documentation describes cooperative scheduling: the event loop runs one task at a time. When a task waits, other work can run. The race here does not need two CPU cores to run the Python steps at once.
The barrier makes the bad window clear. It avoids relying on a random delay or a lucky stress run.
Removing it would change the test; it would not prove that the surrounding design is safe. Nor does this program show that a plain increment always loses updates in every Python runtime.
The floating Python 3.12 documentation currently identifies itself as 3.12.16. We also checked the version-tagged 3.12.14 synchronization documentation, matching the tested runtime. Barrier support starts in 3.11; this is not a claim that older Python versions can run the program unchanged.
Protect the operation, not isolated statements
In the corrected branch, both workers use the same lock, made outside the worker. It covers the read, the planned yield and the write.
While the first task pauses, the second cannot enter that guarded section. Its later read therefore sees the first update.
Do not move the two-party barrier inside this shared lock. The first task would wait for a second participant that cannot enter. The corrected branch uses a cooperative yield instead; it can proceed without a second task at the barrier.

Both tasks share the same guarded path.
We also ran three tempting alternatives and the unprotected case. Each failing setup forced both stale reads with the barrier. Twenty runs per setup gave these results; the counts describe controlled tests, not natural failure rates.
| Arrangement | Final value in all 20 runs | Why |
|---|---|---|
| No guard | 1 | Both tasks read zero. |
| Shared lock over the whole operation | 2 | The second read sees the first write. |
| Separate read and write lock sections | 1 | The stale value survives outside the lock. |
| A new private lock in each worker | 1 | Different locks do not block one another. |
| One shared semaphore with two permits | 1 | Both tasks can enter together. |
The too-narrow version releases the lock after reading and reacquires it only to write. Both writes hold the lock.
The full operation still loses an update. Lock coverage is the issue. A lock call somewhere in the path does not prove the code is sound.
Read the failed fixes as a question about who can still reach the state. With a private lock, each task has its own gate.
Both gates can be open at once. With narrow lock sections, each read is guarded, but both reads can still happen before the first write.
Draw the guard around the whole group of steps that must stay together. Check that every path to the shared value uses it.
A second path that skips the guard can bring the bug back. The test only covers the paths it runs.

These are counter values, not failure rates.
Choose the right coordination boundary
Python’s asyncio synchronization reference states that these primitives are not thread-safe. They coordinate tasks that use the same guard, not arbitrary OS threads. A lock in this process also cannot guard another worker process, a separate service or every client of a database.
A semaphore with two permits lets two tasks in. That limit is useful for some resource bounds, but it does not provide one-at-a-time access to this counter. Choose the rule first, then the primitive and its scope.
A lost update gives a wrong result. A deadlock prevents progress through waiting dependencies, as the barrier-inside-lock mistake illustrates. Adding locks can introduce a different bug; the order of locks and their release need care too.
Webhook and API delivery timing can create overlapping work. Delivery alone does not serialize handlers. Likewise, request idempotency and retry contracts address repeated requests; they do not replace coordination of distinct operations on shared state.
For database state, inspect the database’s own rules. PostgreSQL 18 transaction isolation and its locking reference describe separate coordination mechanisms. We did not run a PostgreSQL concurrency test or establish a universal SQL fix here.
Keep a small regression check
Keep the invariant, the forced schedule and at least one failed lock-scope case in the test. Assert the final state, not just that both tasks returned. Record which actors share the guard and which can bypass it.
Keep a timeout around the test harness so a broken fix cannot leave the test run stuck. A timeout reports a lack of progress; it does not turn a wrong result into a pass.
Use structured cleanup such as async with for the tested lock. The teaching yield shows the boundary; it is not advice to hold a lock across unbounded network work.
Review cancellation, exceptions and lock lifetime for the actual application. Passing this small test does not prove that a live system is safe or free of deadlocks.
Computerphile’s video offers human-scale examples of race conditions. It supplies context; the Python results above come from our local tests.
