add atomic global counter system#1323
Conversation
for more information, see https://pre-commit.ci
…nto global-state
Co-authored-by: J. Nick Koston <nick+github@koston.org>
for more information, see https://pre-commit.ci
Co-authored-by: J. Nick Koston <nick+github@koston.org>
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
Pull request overview
This PR updates multidict’s global version counter to be atomic (supporting CPython free-threading) and adds an isolated regression test to detect lost increments under concurrent mutation.
Changes:
- Make
mod_state.global_versionan atomic and updateNEXT_VERSION()to useatomic_fetch_add_explicit(..., memory_order_relaxed). - Add an isolated free-threading test that asserts the version delta matches the number of concurrent writes.
- Adjust build configuration to add C11 atomics flags for MSVC via a custom
build_ext.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
multidict/_multilib/state.h |
Switches the module-level global counter to an atomic and updates increment logic. |
tests/isolated/multidict_global_counter.py |
New isolated regression test for concurrent version increments (free-threaded builds). |
tests/test_leaks.py |
Registers the new isolated script in the isolated-script runner list. |
setup.py |
Introduces custom build_ext to apply platform-specific C compile flags (incl. MSVC C11 atomics). |
CHANGES/1328.bugfix.rst |
Adds a changelog entry for the atomic counter bugfix. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for flag in BASE_CFLAGS: | ||
| # XXX: MSVC Doesn't have a /O3 flag only O2 is possible... | ||
| ext.extra_compile_args.append("/O2" if flag == "O3" else f"/{flag}") | ||
| else: |
There was a problem hiding this comment.
MSVC handling currently prefixes BASE_CFLAGS values with / (e.g. O0 -> /O0, g3 -> /g3). These aren't valid MSVC flags (/Od and /Zi//Z7 are the usual equivalents), so Windows builds with MULTIDICT_DEBUG_BUILD=1 will fail. Consider defining a separate MSVC-specific debug/release flag list instead of reusing BASE_CFLAGS verbatim.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
| import sysconfig | ||
| import threading | ||
|
|
||
| import multidict |
There was a problem hiding this comment.
Should probably resolve this, no reason to import it twice.
There was a problem hiding this comment.
Concur — easiest fix is from multidict import MultiDict, getversion and then call getversion(md) directly. That also removes one of the two type: ignore[arg-type] comments below, since getversion would be imported with its real signature rather than accessed through the bare module namespace.
|
Looks like the tests still need some work |
@bdraco I think the functions aren't all locked yet which is why the numbers are still off other than that I agree with you. I think I'll create an extra PR today for locking the functions down in free-threaded mode since I have the time to. |
| md[f"k{tid}_{i}"] = i | ||
|
|
||
|
|
||
| if (__name__ == "__main__") and FREETHREADED: |
There was a problem hiding this comment.
Same concern as in other PRs: we shouldn't need to gate on FT.
There was a problem hiding this comment.
Agreeing with this concern. Now that global_version is atomic, the invariant delta == N*M should hold under the GIL too — it just becomes a less interesting test. Gating on FREETHREADED means non‑FT CI runs the script for no benefit (still pays a subprocess + import) and any future regression that reverts NEXT_VERSION back to a plain ++ won't be caught unless someone happens to run an FT build locally. Unless there's a concrete __setitem__ path on the GIL build that legitimately skips a NEXT_VERSION call, I'd drop the gate. If the assertion does fail on a GIL build today, that's a separate bug that this PR is masking by gating it out.
|
|
||
| md: MultiDict[int] = MultiDict() | ||
| N, M = 3, 100 | ||
| baseline = multidict.getversion(md) # type: ignore[arg-type] |
There was a problem hiding this comment.
If there's an error here, then maybe my strict Multidict[object] is causing problems. Might need to be changed to Multidict[Any]. Probably a contravariant thing I missed.
| for t in threads: | ||
| t.join() | ||
|
|
||
| observed = multidict.getversion(md) - baseline # type: ignore[arg-type] |
There was a problem hiding this comment.
MultiDict[object] is what getversion(..) wants but here we have a completely different type hence type: ignore[arg-type]
Co-authored-by: 🇺🇦 Sviatoslav Sydorenko (Святослав Сидоренко) <wk.cvs.github@sydorenko.org.ua>
|
@aiolibsbot review |
PR Review — add atomic global counter systemThe core change — making Blocking issues before merge:
Lower priority: apply @webknjaz's changelog wording, the inline‑formatting suggestion on 🟡 Important1. MSVC debug build will pass invalid flags (`setup.py`, L13)With
So BASE_UNIX_DEBUG = ["-O0", "-g3", "-UNDEBUG"]
BASE_UNIX_RELEASE = ["-O3", "-DNDEBUG"]
BASE_MSVC_DEBUG = ["/Od", "/Zi", "/UNDEBUG"]
BASE_MSVC_RELEASE = ["/O2", "/DNDEBUG"]or at least extend the translation to handle 2. Duplicate import of `multidict` (`tests/isolated/multidict_global_counter.py`, L3-5)CodeQL and @Dreamsorcerer both flagged this: the script does from multidict import MultiDict, getversionThen 3. Gating on `FREETHREADED` makes this a no‑op under the GIL (`tests/isolated/multidict_global_counter.py`, L20-33)As @webknjaz noted, the threaded section is wrapped in
For a counter that's now atomic, the assertion If there's a concrete reason the assertion can't hold under the GIL (e.g. 4. Assertion will likely fail without per‑dict locking (`tests/isolated/multidict_global_counter.py`, L24-32)As both you and @bdraco noted in the issue thread, the assertion Until per‑MultiDict locking lands (the follow‑up PR you mentioned), this test will flake on FT builds and the failure will look like "atomic counter is broken" when really the dict mutation path is what needs serializing. Two options:
Option 1 is probably cleanest given the comment trail. 🟢 Suggestions1. Prefer keeping dashes inline per reviewer suggestion (`setup.py`, L13-42)@bdraco already laid out a cleaner shape that avoids the string‑stripping dance entirely: keep 2. `extra_compile_args=[]` is dead and misleading (`setup.py`, L46-48)Now that 3. Pass `cmdclass` symmetrically (`setup.py`, L54-58)
4. Apply @bdraco's single‑line formatting suggestion (`multidict/_multilib/state.h`, L133-136)The committed comment is good, but the call is split across three lines while the prior, non‑atomic implementation was a single statement. @bdraco's suggested form keeps the diff tighter and matches the surrounding style: /* relaxed is fine here as we only care about the atomicity of the RMW itself */
return atomic_fetch_add_explicit(
&state->global_version, 1, memory_order_relaxed) + 1;Non‑blocking — purely cosmetic. 5. `MultiDict[int]` vs `MultiDict[object]` invariance (`tests/isolated/multidict_global_counter.py`, L12)The
Either removes both 6. Take @webknjaz's changelog suggestion (`CHANGES/1328.bugfix.rst`, L1-2)@webknjaz already proposed dropping the trailing Checklist
SummaryThe core change — making Blocking issues before merge:
Lower priority: apply @webknjaz's changelog wording, the inline‑formatting suggestion on Automated review by Kōandcbc141 |
That matches @bdraco's read. Suggestion: until the per‑dict locking PR lands, either (a) mark |
What do these changes do?
These changes are based off @devdanzin's second fuzzer for freethreading mode which noted using a global atomic counter system instead of a normal
uint64_tvalue I did however make one small nitpick/change from that original design to just recast(_Atomic(uint64_t)*)&state->global_versionwhich was not in the original suggestion mainly due to my code editor misbehaving on me when I would go to set it into the actual structure.Are there changes in behavior for the user?
Just bug fixes.
Related issue number
#1321
Checklist