Skip to content

gh-157194: Read the opcode atomically in the specializer - #157195

Open
iamsharduld wants to merge 1 commit into
python:mainfrom
iamsharduld:fix-specialize-opcode-race
Open

gh-157194: Read the opcode atomically in the specializer#157195
iamsharduld wants to merge 1 commit into
python:mainfrom
iamsharduld:fix-specialize-opcode-race

Conversation

@iamsharduld

@iamsharduld iamsharduld commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

set_opcode() writes instr->op.code with _Py_atomic_compare_exchange_uint8() and unspecialize() reads it with FT_ATOMIC_LOAD_UINT8_RELAXED(), the convention introduced with the (un)specialization helpers in gh-115999 (9ce4fa0). Two later comparisons read the same byte directly:

In a free-threaded build each thread normally gets its own copy of the bytecode, so specialization never races. With -X tlbc=0 the copies are disabled and the bytecode is shared, and those plain reads race with the atomic write. ThreadSanitizer reported exactly that on the CI job for test_thread_local_bytecode (report quoted in the issue): a read at specialize.c:2957 against a "previous atomic write" from set_opcode() via unspecialize().

The change makes both reads use the same relaxed atomic load. The logic is unchanged, and on the default build FT_ATOMIC_LOAD_UINT8_RELAXED expands to the plain access, so only the free-threaded build is affected.

Verification

On a --disable-gil --with-thread-sanitizer --with-pydebug build (clang 20, Linux aarch64), with a script that has several threads and the main thread call the same freshly created function repeatedly under -X tlbc=0:

data races reported
before 3, every one of them _Py_Specialize_Resume Python/specialize.c:2957:43 — the same file, line and column as the CI report
after 0, in three consecutive runs

test_thread_local_bytecode passes 10 out of 10 runs on that build, and test_opcache, test_capi, test_code, test_monitoring, test_sys_settrace and test_generators pass under TSan (2244 tests). On macOS, test_thread_local_bytecode, test_opcache, test_monitoring and test_capi pass on a free-threaded debug build, and test_opcache, test_dis, test_monitoring, test_generators, test_sys_settrace and test_capi pass on the default build.

set_opcode() writes instr->op.code with an atomic compare-exchange and
unspecialize() reads it with FT_ATOMIC_LOAD_UINT8_RELAXED, but two reads
added later compare it directly. In a free-threaded build with -X tlbc=0
the bytecode is shared between threads, so those plain reads race with
the atomic write, as ThreadSanitizer reported on the CI job for
test_thread_local_bytecode.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant