[
https://issues.apache.org/jira/browse/GROOVY-12457?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18131646#comment-18131646
]
ASF GitHub Bot commented on GROOVY-12457:
-----------------------------------------
daniellansun commented on PR #2986:
URL: https://github.com/apache/groovy/pull/2986#issuecomment-6392508175
@paulk-asert Thank you for the reproduction and for splitting the two
halves. The stall is real, and the tables are convincing: `minimumRunnable`
alone and `managedBlock` alone do not fix it, and together they do. They also
show the cost. I have taken the suggestion and removed that compensation rather
than shipping it.
`Pool.fixed(n)` is a `ForkJoinPool` of parallelism `n` again (`new
ForkJoinPool(n)`, and the same for `Pool.cpu()`). Parallel collection callbacks
on a fork-join pool are ordinary stream tasks. They are not wrapped in
`managedBlock`, so a CPU-bound body does not recruit spare workers, and the
common pool is not enlarged. On the compensated build, JDK 25, `Pool.fixed(2)`
over a CPU `forEach` grew to 6 threads; after the removal the same shape stays
at 2. Users who pick `fixed(n)` to cap CPU use keep that cap. `maximumPoolSize`
and `minimumRunnable` are the JDK defaults again, including for async
`Pool.fixed` / `Pool.cpu` callers. The JIRA description still talks about the
earlier queue-check split and does not mention this pool-constructor change;
that text needs a human update once the approach is settled. I have not edited
the ticket.
Blocking on a fork-join pool can still stall when every worker blocks with a
sibling queued. The flaky tests now do what you suggested:
`ParallelScope.withPool(parties + 1)`, with the pool created outside the scope
and closed afterwards. They assert `parallelism == threads` and `poolSize <=
threads`, and an upper bound on worker names. Twenty rounds are a concurrency
check. They are not a statistical proof at one stall in 15k–80k rounds, and
they no longer treat pool growth as expected. A separate CPU test locks the
cap. I could not reproduce the original stall on JDK 25 either. I did not
re-run your JDK 17 and 21 matrices, so those results stand.
For a blocking workload the opt-in is `Pool.virtual()`, and `Pool.io()`
where that is the virtual pool. `asForkJoinPool()` still throws for those
pools. Parallel collection methods catch that `UnsupportedOperationException`
and submit one task per element to that executor. They do not fall back to the
common pool. Any other exception from `asForkJoinPool()` propagates. On JDKs
without virtual threads, `Pool.io()` remains
`fixed(ConcurrentConfig.defaultParallelism())` and the spare-worker rule
applies. I confirmed that JDK 17 branch by reading `DefaultPool`, not by
running it: this machine is JDK 25, and the `Pool.io()` test took the
virtual-thread path. The docs no longer say that a pool of size `n` can host
`n` concurrent blockers. They say a fork-join pool's parallelism is a hard cap,
and that waiting work belongs on `virtual()` / `io()`.
Two smaller notes from the review are fixed with the same change. There is
one executor wait path, and it restores the interrupt flag on every wait; the
old path restored it only when compensation ran. A synchronous executor runs
the task on the caller and leaves a caller interrupt set. A pool thread drops a
cancellation interrupt left by the previous task, and the worker slot and the
interrupt are taken under one lock so that interrupt cannot land on the next
task that reused the thread. `grepParallel` builds the `isCase` predicate
outside the task. `collectManyParallel` applies the transform inside the stream
or flat-map task. They are no longer wrapped in different places.
Nested parallel calls stay on the current pool. A fork-join leaf does not
inherit the submitter's binding, so each callback rebinds that pool on the
worker that runs it. On a bounded executor that does not grow, a nested call
runs on the worker already executing the outer element, which keeps that worker
from waiting on a task queued behind it. `Pool.virtual()` still runs each
element as its own task.
A null element, a null reduction, and a null group key now behave the same
on a fork-join pool and on `Pool.virtual()`. `findParallel` and
`findAnyParallel` no longer finish through `Optional.of`, which threw when the
match was null. `minParallel`, `maxParallel`, and `sumParallel` fold in
encounter order and can return null; a one-element collection does not call the
comparator or operator. `groupByParallel` keeps a null key, and each group
stays in encounter order, on both paths.
I would be glad of a look at the revised contract, especially if CI was on a
JDK other than 25.
> 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
> Priority: Major
>
> 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
(v10.3.26#10030026)