https://gcc.gnu.org/bugzilla/show_bug.cgi?id=127320
Bug ID: 127320
Summary: [15/16/17 Regression] coroutine frame refcount
(_Coro_frame_refcount) is updated non-atomically by
ramp and actor; races when the handle is resumed on
another thread from await_suspend
Product: gcc
Version: 15.3.0
Status: UNCONFIRMED
Keywords: wrong-code
Severity: normal
Priority: P3
Component: c++
Assignee: unassigned at gcc dot gnu.org
Reporter: saxonp at nvidia dot com
Target Milestone: ---
Target: x86_64-linux-gnu
Created attachment 65558
--> https://gcc.gnu.org/bugzilla/attachment.cgi?id=65558&action=edit
Recreate file
GCC 15.2 and later (including 16.x and trunk) give every coroutine frame a
16-bit _Coro_frame_refcount. The ramp and the actor both update this field
with plain, non-atomic read-modify-write operations. A conforming
callback-driven awaiter publishes its coroutine handle from await_suspend,
so the completing thread can resume the actor before the ramp has
returned. The resuming thread's decrement at the actor's
coro.delete.promise label then races the ramp's decrement on the same
two-byte field, with no happens-before edge between them: the user's
release-store of the handle is sequenced before the actor returns to the
ramp, so the resumer's acquire orders the actor's increment, but nothing
can order the ramp's later decrement.
This is not only a formal data race. If both sides read the same count and
write the same decremented value, one decrement is lost, the count never
reaches zero, and the frame leaks. Each side also re-reads the field for
its own "== 0" checks (frame delete, promise destructor, parameter-copy
destructors), so both threads can observe zero and both run the cleanups:
a double free. Both outcomes were observed without a sanitizer (see
"Consequence without a sanitizer" below). The same race is reachable when
another thread calls destroy() on the suspended handle, since destroy()
enters the same label.
AFFECTED VERSIONS
The field was introduced on trunk by r16-1564-g43e408f675f8e9 ("c++,
coroutines: CWG2563 promise lifetime extension [PR115908]", 2025-06-18,
also closing PR118074 and PR95615) and cherry-picked to the 15 branch as
b4da8ee3e613502b50501773f5ecb494d3a51c8b on 2025-07-29, so it first
shipped in 15.2.0. 15.1.0 does not have the field.
Known to fail: 15.2.0, 15.3.0, 16.1.0, 16.2.0. The refcount-touching
statements in gcc/cp/coroutines.cc are identical between releases/gcc-15
and master as of 2026-09-08, and the file uses no atomic built-in
anywhere. Two later changes touch the field without adding
synchronization: the PR121961 reordering (2026-04-15, in 15.3.0) and the
zero-initialisation of the ramp's counter for the promise-constructor-
throws path (trunk 2026-04-03, 15 branch 2026-07-10).
Known to work: 13.3.0, 14.4.0, 15.1.0 (checked with the reproducer below
on 2026-09-10: ThreadSanitizer clean in 3 of 3 runs, zero leaked frames,
no frame-offset-20 traffic in the generated code). Clang 17+ is also
clean.
WHERE IN THE SOURCE (releases/gcc-15 at 2026-09-08)
build_ramp_function: the ramp stores 1 into the field after the parameter
copies and the promise are constructed, calls the actor, and on return
runs scope cleanups: ~_Coro_gro, then "_Coro_frame_refcount =
_Coro_frame_refcount - 1", then three independently guarded cleanups
"if (_Coro_frame_refcount == 0)" for the promise destructor, each
parameter-copy destructor, and operator delete(frame), each re-loading the
field.
wrap_original_function_body: the actor increments the field before the
initial await.
build_actor_fn, label coro.delete.promise (reached when the final await
does not suspend, or from destroy() at any suspend point): "_Coro_frame_
refcount = _Coro_frame_refcount - 1; if (_Coro_frame_refcount != 0)
return;" followed by the promise/parameter destructors and the frame
delete.
All of these are ordinary MODIFY_EXPRs on short_unsigned_type_node built
with cp_build_modify_expr (PLUS_EXPR / MINUS_EXPR).
REPRODUCER
Attached: coroutine_frame_refcount_race.cpp. Standalone translation unit,
no external headers. It runs two loops through the same worker thread and
the same release-store/acquire-load pair. The control loop publishes its
handle only after the ramp has returned and is clean everywhere. The
second loop publishes from await_suspend and fails on affected compilers.
g++ -std=c++20 -O1 -g -pthread -fsanitize=thread -fno-omit-frame-pointer \
coroutine_frame_refcount_race.cpp -o probe
TSAN_OPTIONS=halt_on_error=1:exitcode=1:abort_on_error=0 ./probe
(If the ThreadSanitizer binary dies with a segmentation fault or "FATAL:
ThreadSanitizer: unexpected memory mapping" before printing anything, that
is a TSan/ASLR incompatibility of the host, seen in Docker and WSL2, not
this bug. Run it under "setarch x86_64 -R".)
EXPECTED
Both loops complete and the process exits 0, as it does with 13.3.0,
14.4.0, 15.1.0 and Clang 17+. Output ends with:
coroutine_frame_refcount_race: control (publish after the ramp) ok
coroutine_frame_refcount_race: ok
OBSERVED (g++ (GCC) 15.3.0, x86_64, docker.io/library/gcc:15; 3 of 3 runs)
coroutine_frame_refcount_race: control (publish after the ramp) ok
WARNING: ThreadSanitizer: data race (pid=15)
Read of size 2 at 0x7208000017f4 by thread T1:
#0 publishes_during_the_ramp coroutine_frame_refcount_race.cpp:121
#1 std::__n4861::coroutine_handle<void>::resume() const
/usr/local/include/c++/15.3.0/coroutine:142
#2 worker coroutine_frame_refcount_race.cpp:163
Previous write of size 2 at 0x7208000017f4 by main thread:
#0 publishes_during_the_ramp coroutine_frame_refcount_race.cpp:117
#1 main coroutine_frame_refcount_race.cpp:255
Location is heap block of size 32 at 0x7208000017e0 allocated by main
thread
SUMMARY: ThreadSanitizer: data race coroutine_frame_refcount_race.cpp:121
in publishes_during_the_ramp
The racing address is the frame heap block plus 20. DWARF for this frame
at -O1 -g: +16 _Coro_promise (2), +18 _Coro_resume_index (2),
+20 _Coro_frame_refcount (2), +22 _Coro_frame_needs_free (1),
+23 _Coro_initial_await_resume_called (1). The same was originally
observed with c++ (Ubuntu 15.2.0-16ubuntu1) 15.2.0.
GENERATED CODE
g++ -std=c++20 -O1 -pthread -S (15.3.0, x86_64) emits the refcount traffic
in both the ramp and the actor as plain loads and stores at frame offset
20, with no lock prefix or fence anywhere near them:
movzwl 20(%rdi), %eax
...
movw %ax, 20(%rdi)
addw $1, 20(%rdi)
The only locked instruction in the object is the program's own
std::atomic counter.
CONSEQUENCE WITHOUT A SANITIZER
Attached: refcount_leak_counter.cpp counts frame allocations against frees
through promise_type::operator new/delete, using the racy loop only.
g++ -std=c++20 -O0 -g -pthread refcount_leak_counter.cpp -o leak
./leak 200000
With 15.3.0 at -O0, three runs gave:
iterations=200000 frame_allocs=200000 frame_frees=199999 leaked=1
double free or corruption (fasttop) (then Aborted)
iterations=200000 frame_allocs=200000 frame_frees=200000 leaked=0
At -O1 the decremented value is kept in a register for the zero tests, so
the double-free mode disappears on that build and only the lost-update
leak remains possible (five runs of 200000 happened to show none). That is
an optimisation artefact, not a guarantee. 14.4.0 and 15.1.0 report
leaked=0 in every run at both levels.
NOTES
Searched before filing. These are related but not this bug: PR121961
(await_suspend destroys the just-suspended coroutine; deterministic,
single-threaded ordering, fixed 2026-04-15 without adding
synchronization), PR121219 (operator new heap-use-after-free, fixed
2025-07-25), PR115908 / PR118074 / PR95615 (the lifetime bugs the refcount
was introduced to fix), PR124594 (await_suspend resumes then throws),
PR125210 / PR125213 / PR126884 (promise lifetime when unhandled_exception
rethrows). No report describes the cross-thread race on the refcount
itself, and no patch touching it exists on trunk as of 2026-09-08.
A fix presumably needs the increments and decrements to be atomic
read-modify-writes (release on decrement, acquire before running the
cleanups), with the cleanups keyed off the returned pre-decrement value
rather than re-loading the field.