Free-threading: concurrent send() on a shared aclose() awaitable can resume the async generator instead of closing it
維護者通常 1 天內回覆
還沒有人認領這個 Issue。
評估
研究方向
Start in Objects/genobject.c at async_gen_athrow_send(), especially the INIT-to-ITER transition, ag_running_async claim, and aclose _gen_throw() path described in the report. Compare the shared aclose and athrow paths, then run test_free_threading.test_async_generators.test_concurrent_shared_aclose and the related shared athrow test on a free-threaded debug build. Done when concurrent sends reliably close or throw into the generator and its cleanup runs.
由索引模型根據 Issue 內容生成。
描述
Bug report
Bug description:
On the free-threaded build (--disable-gil --with-pydebug), test_free_threading.test_async_generators.test_concurrent_shared_aclose fails intermittently:
File "Lib/test/test_free_threading/test_async_generators.py", line 171, in test_concurrent_shared_aclose
self.assertEqual(cleanups, [1])
AssertionError: Lists differ: [] != [1]
Both send() calls on the shared aclose() awaitable raise (the test ignores RuntimeError/ValueError), and the generator's finally block never runs.
I think the cause is this window in async_gen_athrow_send():
- Thread T1 moves
agt_statefrom INIT to ITER (L2434-L2435), claimsag_running_async(L2440) and setsag_closed(L2464), but has not yet called_gen_throw(GeneratorExit)(L2466-L2469). - Thread T2 sees ITER and takes the
do_sendpath (L2427, L2488), which callsgen_send(gen, None)without claimingag_running_async. The generator is resumed withNoneinstead ofGeneratorExit, runs on and yields again, and T2 getsRuntimeError: async generator ignored GeneratorExit. - Meanwhile, T1's
_gen_throw()finds the frame executing and fails withValueError: async generator already executing.
With the GIL there is no switch point between the state change and _gen_throw(), so this cannot happen there. The agt_state CAS and the separate ag_running_async claim were added in gh-120321 (61818b6087); the ITER → gen_send() path itself is older.
Deterministic reproducer: widen the window with a temporary delay before the _gen_throw() call in aclose mode (debug only, not a fix):
--- a/Objects/genobject.c
+++ b/Objects/genobject.c
@@ -3,6 +3,7 @@
#define _PY_INTERPRETER
#include "Python.h"
+#include <unistd.h> // TEMPORARY: usleep()
#include "pycore_call.h" // _PyObject_CallNoArgs()
#include "pycore_ceval.h" // _PyEval_EvalFrame()
#include "pycore_frame.h" // _PyInterpreterFrame
@@ -2462,6 +2463,9 @@
if (o->agt_typ == NULL) {
/* aclose() mode */
FT_ATOMIC_STORE_INT8_RELAXED(o->agt_gen->ag_closed, 1);
+ if (getenv("PY_ACLOSE_DELAY_US")) { /* TEMPORARY: widen race window */
+ usleep((useconds_t)atoi(getenv("PY_ACLOSE_DELAY_US")));
+ }
retval = _gen_throw((PyGenObject *)gen,
0, /* Do not close generator when
then run the following. The time.sleep(0.3) keeps T2 inside the generator while T1 calls _gen_throw():
# Two threads call send(None) on ONE shared aclose() awaitable, 20 ms apart.
import threading, time
def make(cleanups):
async def agen():
try:
while True:
yield 1
time.sleep(0.3) # reached only if resumed by send(None), not by aclose()
finally:
cleanups.append(1)
return agen()
for trial in range(5):
cleanups = []
ag = make(cleanups)
try:
ag.asend(None).send(None) # advance to the first yield
except StopIteration:
pass
aw = ag.aclose()
res = {}
def worker(name):
try:
aw.send(None)
res[name] = "returned"
except BaseException as e:
res[name] = f"{type(e).__name__}: {e}"
t1 = threading.Thread(target=worker, args=("T1",))
t2 = threading.Thread(target=worker, args=("T2",))
t1.start(); time.sleep(0.02); t2.start(); t1.join(); t2.join()
print(trial, res, "cleanups:", cleanups)
With PY_ACLOSE_DELAY_US=100000 every trial fails:
0 {'T1': 'ValueError: async generator already executing', 'T2': 'RuntimeError: async generator ignored GeneratorExit'} cleanups: []
Without the variable all trials pass (cleanups: [1]).
Without any patch, running test_concurrent_shared_aclose in a loop in 8 parallel processes failed 12 times in 800,000 runs on main (1705e512b30), and 25 times in 800,000 on 5d887642d12 (just before gh-158304, which also touched async generators), so it is not a new regression. In the same setup test_concurrent_shared_athrow failed 17 times in 400,000 runs with StopIteration: 1 from the asend() check at line 142, so the generator was still alive: it was resumed instead of receiving the thrown ValueError. It goes through the same function, so I expect it is the same window. test_concurrent_shared_asend did not fail in 400,000 runs.
CPython versions tested on:
CPython main branch
Operating systems tested on:
macOS
- 主要語言
- Python
- 星號
- 77.7k
- 分支
- 38.3k
- 平均合併
- 14 小時 29 分鐘
- 30 天內合併 PR
- 699
環境準備
在瀏覽器裡用你自己的 GitHub 帳號啟動這個專案的開發容器。
- 沒有 Dockerfile 或 Docker Compose 檔案
- 有 Pull Request 範本
- 閱讀貢獻指南
從這裡開始
- 先讀完整個 Issue,再讀專案的貢獻指南。
- 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
- Fork 儲存庫,在一個分支上完成修改。
- 送出 Pull Request,並在描述裡引用這個 Issue 編號。
python/cpython 的其他 Issue
-
topic-socket type-bug
難度 2/5 1-3 小時 新手友好度 72/100
python/cpython#160561 · 7 則留言 ·
維護者通常 1 天內回覆
-
tests topic-profiling type-bug
難度 2/5 1-3 小時 新手友好度 78/100
python/cpython#160334 · 1 則留言 ·
維護者通常 1 天內回覆
-
extension-modules type-bug
難度 2/5 1-3 小時 新手友好度 84/100
維護者通常 1 天內回覆
-
interpreter-core type-feature
難度 1/5 1 小時以內 新手友好度 78/100
python/cpython#160109 · 1 則留言 ·
維護者通常 1 天內回覆
-
extension-modules type-bug
難度 2/5 1-3 小時 新手友好度 72/100
python/cpython#160001 · 3 則留言 ·
維護者通常 1 天內回覆
相似的 Issue
-
The changelog workflow excludes otelbot but not dependabot, so every Dependabot pull request fails the changelog check可能已有人在做 @ocelotl 今天認領。 未關閉
難度 1/5 1 小時以內 新手友好度 82/100
open-telemetry/opentelemetry-python#5825 ·
維護者通常 2 天內回覆
-
難度 2/5 1-3 小時 新手友好度 72/100
cuga-project/cuga-agent#1020 ·
維護者通常 2 天內回覆
-
難度 1/5 1 小時以內 新手友好度 70/100
microsoft/playwright#43640 ·
維護者通常 1 天內回覆
-
難度 2/5 1-3 小時 新手友好度 84/100
stac-utils/stac-fastapi-elasticsearch-opensearch#965 ·
維護者通常 2 天內回覆
-
難度 2/5 1-3 小時 新手友好度 70/100
維護者通常 1 天內回覆