You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@groovy.apache.org by "Daniel Sun (Jira)" <ji...@apache.org> on 2026/10/18 08:53:00 UTC

[jira] [Created] (GROOVY-12457) Blocking parallel-collection callbacks can fail to run a queued sibling task

Daniel Sun created GROOVY-12457:
-----------------------------------

             Summary: Blocking parallel-collection callbacks can fail to run a queued sibling task
                 Key: GROOVY-12457
                 URL: https://issues.apache.org/jira/browse/GROOVY-12457
             Project: Groovy
          Issue Type: Bug
            Reporter: Daniel Sun


h2. Context

{{{}eachParallel{}}}, {{{}collectParallel{}}}, and the other parallel collection methods run their callback on a {{{}ForkJoinPool{}}}: the pool bound by {{{}ParallelScope.withPool{}}}, or {{ForkJoinPool.commonPool()}} when no pool is bound. {{@Parallel}} {{for-in}} loops are rewritten to call {{{}eachParallel{}}}. The methods arrived with GROOVY-11952 ([GEP-18|https://groovy.apache.org/wiki/GEP-18.html]).

A pool of size {{n}} is the intended way to run {{n}} callbacks that block at the same time.
h2. Problem

This rendezvous fails intermittently, including when the pool size equals the number of parties:
{code:groovy}
import groovy.concurrent.ParallelScope
import java.util.concurrent.CyclicBarrier
import java.util.concurrent.TimeUnit

int parties = 2
ParallelScope.withPool(parties) { scope ->
    def barrier = new CyclicBarrier(parties)
    (1..parties).eachParallel {
        barrier.await(5, TimeUnit.SECONDS)
    }
}
{code}
{{CyclicBarrier.await}} throws {{TimeoutException}} because the second element never starts. The failure is reported as a nested {{{}UndeclaredThrowableException{}}}:
||Layer||What the caller sees||
|Closure|{{TimeoutException}} from {{CyclicBarrier.await}}|
|{{Consumer}} proxy|{{{}UndeclaredThrowableException{}}}, because {{accept}} does not declare checked exceptions|
|{{ForkJoinTask.getThrowableException}}|A second {{{}UndeclaredThrowableException{}}}, rebuilt on the joining thread|

The same shape fails for {{@Parallel}} {{{}for (item in 1..n){}}}. It shows up more often once the pool's workers have already run and parked. A freshly created pool can fail as well.
h2. Cause

{{parallelStream()}} forks the sibling leaf onto the current worker's queue, then runs one leaf on that same worker. {{CyclicBarrier.await}} is not a {{{}ForkJoinPool.ManagedBlocker{}}}, so the pool does not release or replace the blocked worker. If the other worker has already scanned and parked, the fork signal is not always delivered again. The sibling stays on the queue until the barrier times out.

Sizing the pool to exactly {{n}} threads does not close the race. The idle thread is there; it is parked.
h2. Change

The proposed change runs the user callback inside {{ForkJoinPool.managedBlock}} when the current worker still has queued tasks. That releases a parked worker, or creates a spare, before the callback can block. A leaf whose queue is empty is unchanged.
 * JDK 20 and later read {{{}ForkJoinWorkerThread.getQueuedTaskCount(){}}}.
 * JDK 17 does not have that method, so the check uses the pool-wide queued-task estimate.

The wrapper covers every parallel-collection callback, including {{eachWithIndexParallel}} and the {{eachParallel}} call emitted for {{{}@Parallel{}}}.

Compensation runs only when a sibling is already queued. A callback that blocks before any sibling has been forked can still stall, and so can a pool that refuses another thread. Work that spends most of its time waiting still fits {{Pool.io()}} or {{Pool.virtual()}} better than a fixed {{{}ForkJoinPool{}}}.
h2. Tests
 * {{ParallelCollectionTest}} and {{ParallelAnnotationTest}} repeat the two-party barrier on one pool, so later rounds run against workers parked by the first round. The barrier must trip.
 * The thread-count assertion allows a spare ({{{}size() >= parties{}}}), because {{managedBlock}} may add one while a worker is blocked.
 * The full two test classes passed on JDK 25. The three barrier tests also passed on JDK 17, which exercises the queue-length fallback.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)