[ 
https://issues.apache.org/jira/browse/GROOVY-12457?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18133279#comment-18133279
 ] 

ASF GitHub Bot commented on GROOVY-12457:
-----------------------------------------

paulk-asert commented on PR #2986:
URL: https://github.com/apache/groovy/pull/2986#issuecomment-6455666379

   Thanks for the rework. It addresses the earlier review: `fixed(n)` and 
`cpu()` are back to plain capped fork-join pools, the stall is documented 
rather than hidden behind compensation, `Pool.virtual()`/`io()` no longer fall 
back to the common pool, and nested calls stay on the current pool. CI is green 
and the branch merges cleanly with master. I read through 
`ParallelExecutorTasks` and found no correctness problems. A few things before 
merge:
   
   **1. `grepParallel`: fix now, or split it off.** There are two problems:
   
   - **Regression:** `[1, null, 2].grepParallel(null)` now throws NPE because 
`require(self, filter)` null-checks the filter. On master it returns `[null]`, 
the same as sequential `grep(null)`. Null-checking only `self` fixes this.
   - **Present before this PR:** it keeps an element unless `isCase` returns 
exactly `Boolean.FALSE`. Sequential `grep` uses Groovy truth (`castToBoolean`). 
With a filter whose `isCase` returns `x % 2`, `grep` gives `[1, 3]` but 
`grepParallel` gives `[1, 2, 3, 4]`. With one whose `isCase` returns `null`, 
`grep` gives `[]` but `grepParallel` returns everything. Closure, Pattern and 
Class filters are unaffected because they return real booleans.
   
   Both are one-line changes to the same method, so I'd fix them here. If you'd 
rather keep this PR focused, split them into a follow-up PR together with item 
2, but that follow-up needs to land before the next release so the regression 
doesn't ship.
   
   **2. `maxParallel` and nulls.** The shared min/max helper passes every 
element to the comparator. Sequential `max(Comparator)` skips nulls, so `[null, 
1].maxParallel(Comparator.naturalOrder())` throws where `[null, 
1].max(Comparator.naturalOrder())` returns `1`. On `[null, null]`, sequential 
returns `null` without calling the comparator, while the PR calls 
`compare(null, null)`. Sequential `min(Comparator)` does pass nulls through, so 
`minParallel` already matches. Sequential is asymmetric here, so either match 
each sequential method exactly or keep the symmetric rule and document how it 
differs from `max`. Either is fine; it just shouldn't diverge silently. This 
goes with item 1 if you split.
   
   **3. Per-element `Pool.withCurrent` rebinding.** Every callback inside a 
`withPool`/`fixed`/`cpu` scope is now wrapped so that nested calls see the 
pool. I emulated the wrapper on master: `Pool.fixed(12)`, 2M elements, a 
trivial `map(i + 1)`. It goes from 2 ms to 31–32 ms on JDK 17/21 and to 14 ms 
on JDK 25. A real `collectParallel { it + 1 }` on master takes 3–4 ms, so for 
light callbacks the wrapper would cost more than the work. A cheaper option 
would be a worker thread factory whose threads carry their owning `Pool`, with 
`Pool.current()` falling back to the current worker's pool. That keeps nested 
calls on the pool without rebinding per element. If you'd rather keep the 
current approach, I'd like to see a JMH number for a light workload first.
   
   **4. Interrupt semantics on the executor path.** When `find`/`any`/`every` 
short-circuits, or a sibling task fails, `ParallelExecutorTasks` interrupts 
callbacks that are still running. The fork-join path never does this. 
Interrupting a thread blocked on an `InterruptibleChannel` (for example a 
`FileChannel`) closes the channel, which users won't expect. Only internal 
Javadoc mentions it; could you add a sentence to 
`core-parallel-collections.adoc`?
   
   **5. Small nit:** `groupInOrder` already inserts in encounter order. If it 
used a `LinkedHashMap` instead of a `HashMap`, the keys would come out in 
first-seen order, like sequential `groupBy`.
   
   **Housekeeping:** the JIRA description still describes the dropped 
compensation design and should be updated to match what the PR does now. We 
also need to decide whether this goes to 6.0.x. If it does, the null-handling 
changes (null matches in `find*`, null elements in `min`/`max`/`sum`, null 
`groupBy` keys) need a release-notes entry.
   
   The rebinding numbers come from emulating the wrapper on master, not from 
running the PR, and the reply says so. If you'd rather not ask for the JMH 
number in item 3, delete that sentence.




> 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)

Reply via email to