racecheck
racecheck runs your operations concurrently against one object, many times, and checks your predicate on the final state. It finds check-then-act data races that free-threaded Python (PEP 703) exposes once the GIL is gone.
Install
pip install racecheck
No runtime dependencies. Requires Python 3.10+.
Example
This function is very unlikely to break under the GIL and broken without it. Two
threads run it at once, both read patient.dosage, both pass the check, both
write, and dosage ends above the limit (Mark Shannon's example,
discuss.python.org #93339):
SAFE_DOSAGE = 50
class Patient:
def __init__(self):
self.dosage = 0
def increase_dosage(patient, amount):
if patient.dosage + amount < SAFE_DOSAGE: # check
patient.dosage += amount # act
Declare a setup, two or more ops, and an invariant, then assert on the
result:
from racecheck import check
def test_increase_dosage_is_atomic():
result = check(
setup=Patient,
ops=[lambda p: increase_dosage(p, 30), lambda p: increase_dosage(p, 30)],
invariant=lambda p: p.dosage <= SAFE_DOSAGE,
trials=2000,
)
assert result.ok, result.report()
On a free-threaded build that assertion can fail; report() names the invariant
and shows the trial, seed, switch interval and final state. One lock around the
check and the act makes it pass:
import threading
lock = threading.Lock()
def increase_dosage(patient, amount):
with lock:
if patient.dosage + amount < SAFE_DOSAGE:
patient.dosage += amount
Run the test on a free-threaded build (uv python install 3.14t).
API
check(*, setup, ops, invariant, trials=2000, seed=None, collect=1, timeout=10.0, invariant_name=None, warn_on_gil=True) -> Result
setupbuilds fresh state for each trial. An exception it raises is not a finding; it propagates out ofcheck.opsare two or more callables, each taking the state and running in its own thread, released together on a barrier that raises the chance they overlap. The barrier cannot force a collision, only make one likelier. Return values are discarded.invariantis checked against the final state. ReturningFalseor raising is a violation, as is an operation that raises; a raising operation short-circuits the invariant for that trial.KeyboardInterruptandSystemExitare re-raised, not recorded.seedseeds the only randomness, the per-trial switch interval. It reproduces the sequence of switch intervals, not the interleaving, which the interpreter schedules.collectstops the run after this many violations, so it changes how many trials actually run.timeoutbounds the wait for a trial's operations to finish, measured once their threads start. Overrunning raisesTimeoutError; the abandoned worker keeps running, so the run stops.
Result exposes ok, trials (trials actually run, which the first violation or
collect can cut short), seed, invariant, gil_enabled, violations, and
report().
Violation exposes trial, seed, switch_interval, state, and error.
state is a rendered string of the final state, using field values for a plain
class and repr otherwise, truncated to 200 characters.
gil_enabled() -> bool reports whether the interpreter currently holds the GIL.
When it does, check emits GILEnabledWarning and report() says the run proved
nothing.
Limitations
racecheck triggers races; it cannot prove their absence. CPython gives no control over the scheduler, so a clean run means only that no violation surfaced under the interleavings this run tried. A reported violation means the invariant failed or something raised; usually a race, but a wrong predicate or an ordinary bug can trigger it too, so read it before concluding.
The invariant sees only the final state, so a race that corrupts state and repairs
it before the operations return stays invisible. A Ledger whose writers hold a
lock leaves value and checksum equal at the end, so check reports ok=True,
while an unsynchronised reader can still observe the half-updated state mid-write.
On CPython 3.14.3t one run of
examples/ledger_blindspot.py
counted 93,386 inconsistent reads out of 255,520 while check stayed clean; on a
GIL build the split read is wildly improbable and that run counted none.
These races need a free-threaded build (3.13+, GIL disabled). A thread switch can
land at almost any bytecode boundary, so a two-bytecode window can in principle
split under the GIL, but hitting it is wildly improbable: the increase_dosage
window gave zero violations in 50,000 trials on a GIL build. A wider window that
spans a C call or I/O releasing the GIL can still surface under the GIL, and a
violation there is worth investigating. check varies sys.setswitchinterval()
per trial to shake loose interleavings and restores it afterwards; that setting is
process-global, so check holds a module-level lock and concurrent calls run one
after another.
Related tools
pytest-run-parallelruns each test concurrently in N threads with shared fixtures and athread_compfixture that asserts named values agree across threads at a barrier.pytest-freethreadedruns a test in N threads with a fixed iteration count and is no longer maintained.cereggiiprovides thread-synchronisation utilities (AtomicDict,AtomicInt64,ReadersWriterLock, and more) for writing correct concurrent code rather than finding races in existing code.- Linearizability and history checking (Lincheck's territory) is a much larger tool; a final-state invariant is enough for check-then-act races.
racecheck fills a narrower slot: interleave two or more different operations and check one invariant across the combined final state.
License
Licensed under either of Apache License, Version 2.0 (LICENSE-APACHE) or MIT license (LICENSE-MIT) at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file racecheck-0.1.0.tar.gz.
File metadata
- Download URL: racecheck-0.1.0.tar.gz
- Upload date:
- Size: 20.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a853de7809f0e8e3cf48c26e7f44828e9dc08fb1f30c0f39a787f4099df9ccc8
|
|
| MD5 |
74e932c65675ba4c210608f631a6f58b
|
|
| BLAKE2b-256 |
802110e7837fad78e7d4c6a18d89ecbd119dd5d885227046feefe0486dc2dccd
|
File details
Details for the file racecheck-0.1.0-py3-none-any.whl.
File metadata
- Download URL: racecheck-0.1.0-py3-none-any.whl
- Upload date:
- Size: 16.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
71e352a1cfd028296f669a708fc13e65a33cee03213f92e86818baa3b059e7fe
|
|
| MD5 |
ebd9fd0554fb6c96f0129af3b8c697c6
|
|
| BLAKE2b-256 |
7a2455946cf4e2e19e84019a8e4e7a353479140ca38388d9e43f9f516f4c2deb
|