From 9c62e3f9dcf12f595cce07e206ad9175116f26ce Mon Sep 17 00:00:00 2001 From: Alexander Hansen Date: Thu, 20 Aug 2026 18:11:54 +0200 Subject: [PATCH 1/2] test: reproducer run loop finish thread starvation ``` cmake -S . -B build -GNinja \ -DCMAKE_BUILD_TYPE=Debug \ -DCMAKE_CXX_STANDARD=20 \ -DCMAKE_CXX_EXTENSIONS=OFF \ -DSTDEXEC_BUILD_TESTS=ON cmake --build build --target test.run_loop_finish_repro -v ``` running it under valgrind easily results in 2+ minutes of runtime. The results are unreliable, sometimes it is faster, sometimes slower. valgrind --fair-sched=no ./build/test/test.run_loop_finish_repro but with fair scheduling it takes around 2 seconds reliably. valgrind --fair-sched=yes ./build/test/test.run_loop_finish_repro This indicates the implementation may depend on specific scheduling behavior which creates at least 60x difference in runtime. The issue i am trying to reproduce here is related to unit tests running under valgrind in other projects which rely on stdexec. Large unpredictable runtime differences on finishing the run loop can make their tests flaky since sometimes timeouts are exceeded. Next patch contains a fix. Co-Authored-By: GPT 5.6 Sol Signed-off-by: Alexander Hansen --- .github/workflows/ci.cpu.yml | 35 ++++++++++++++++++++++++++++++++++ test/CMakeLists.txt | 9 +++++++++ test/run_loop_finish_repro.cpp | 31 ++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 test/run_loop_finish_repro.cpp diff --git a/.github/workflows/ci.cpu.yml b/.github/workflows/ci.cpu.yml index b4b17d312..10b9488d4 100644 --- a/.github/workflows/ci.cpu.yml +++ b/.github/workflows/ci.cpu.yml @@ -142,11 +142,46 @@ jobs: path: /tmp/sccache*.log compression-level: 9 + valgrind-run-loop: + runs-on: ubuntu-latest + name: CPU (gcc 14, Valgrind run_loop) + container: + options: -u root + image: rapidsai/devcontainers:26.08-cpp-gcc14-cuda12.9 + steps: + - name: Checkout stdexec + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install Valgrind + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends valgrind + + - name: Build run_loop reproducer + run: | + cmake -S . -B build -GNinja \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_CXX_STANDARD=20 \ + -DCMAKE_CXX_EXTENSIONS=OFF \ + -DSTDEXEC_BUILD_TESTS=ON + cmake --build build --target test.run_loop_finish_repro -v + + - name: Run run_loop reproducer under Valgrind + run: | + timeout 60s valgrind \ + --fair-sched=no \ + --error-exitcode=1 \ + --quiet \ + ./build/test/test.run_loop_finish_repro + ci-cpu: runs-on: ubuntu-latest name: CI (CPU) needs: - build-cpu + - valgrind-run-loop steps: - run: echo "CI (CPU) success" diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 2725a8fba..78909f15f 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -120,6 +120,15 @@ target_compile_options( $<$:-Wno-gnu-line-marker>) target_link_libraries(common_test_settings INTERFACE $) + +add_executable(test.run_loop_finish_repro EXCLUDE_FROM_ALL + run_loop_finish_repro.cpp) +add_compile_diagnostics(test.run_loop_finish_repro) +target_link_libraries( + test.run_loop_finish_repro + PUBLIC STDEXEC::stdexec stdexec_executable_flags + PRIVATE common_test_settings) + # target_compile_definitions( common_test_settings INTERFACE # $<$,$>>:STDEXEC_ENABLE_EXTRA_TYPE_CHECKING>) diff --git a/test/run_loop_finish_repro.cpp b/test/run_loop_finish_repro.cpp new file mode 100644 index 000000000..51bc7cf26 --- /dev/null +++ b/test/run_loop_finish_repro.cpp @@ -0,0 +1,31 @@ +#include + +#include + +namespace ex = STDEXEC_NAMESPACE; + +void run_once() +{ + ex::run_loop loop; + + std::thread worker([&loop] { loop.run(); }); + + // Ensure run() is actively servicing the loop before initiating + // shutdown from this thread. + if (!ex::sync_wait(ex::schedule(loop.get_scheduler()) | ex::then([]() {}))) + { + loop.finish(); + worker.join(); + } + + loop.finish(); + worker.join(); +} + +int main() +{ + for (int i = 0; i < 1000; ++i) + { + run_once(); + } +} From 8897e22c511d1142eefee11a2d68db929a15b661 Mon Sep 17 00:00:00 2001 From: Alexander Hansen Date: Fri, 14 Aug 2026 17:56:45 +0200 Subject: [PATCH 2/2] fix run_loop yield while wait for in-flight tasks The run loop drains queued work after finish() has been requested and also waits for __task_count_ to reach zero. If the queue becomes empty while the count is still non-zero, the current implementation busy-spins by repeatedly calling __execute_all(). This can starve another thread that still needs to make progress in order to decrement __task_count_. One concrete sequence is finish() incrementing the count by two, setting __finishing_, and queueing the noop task. The worker can then execute the noop, leaving the count at one, and immediately enter the tight drain loop. The caller still needs to execute its final fetch_sub() to bring the count to zero, but an unfair scheduler may keep running the spinning worker instead. This was reproducible under Valgrind's default scheduler as an intermittent hang during run_loop shutdown. Using Valgrind's fair scheduler avoided the hang, which pointed at a forward-progress issue rather than missing work. Yielding when there is no queued work but tasks are still in flight allows the thread responsible for completing those tasks to run. With this change, the reproducer completed 700 consecutive runs under Valgrind without --fair-sched=yes. Co-Authored-By: GPT 5.6 Sol Signed-off-by: Alexander Hansen --- include/stdexec/__detail/__run_loop.hpp | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/include/stdexec/__detail/__run_loop.hpp b/include/stdexec/__detail/__run_loop.hpp index cf806ab10..d51e8bd41 100644 --- a/include/stdexec/__detail/__run_loop.hpp +++ b/include/stdexec/__detail/__run_loop.hpp @@ -69,8 +69,21 @@ namespace STDEXEC } // drain the queue, taking care to execute any tasks that get added while // executing the remaining tasks (also wait for other tasks that might still be in flight): - while (__execute_all() || __task_count_.load(__std::memory_order_acquire) > 0) - ; + while (true) + { + if (__execute_all()) + { + continue; + } + + if (__task_count_.load(__std::memory_order_acquire) == 0) + { + break; + } + + // Another thread still has work in flight. Let it make progress. + std::this_thread::yield(); + } } STDEXEC_ATTRIBUTE(host, device)