Skip to content

feat(Task, Async): parallelLimit, sequential, startAsyncImmediate - #20294

Open
bartelink wants to merge 20 commits into
dotnet:mainfrom
bartelink:parallel-limit
Open

feat(Task, Async): parallelLimit, sequential, startAsyncImmediate#20294
bartelink wants to merge 20 commits into
dotnet:mainfrom
bartelink:parallel-limit

Conversation

@bartelink

@bartelinkbartelink commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Part of the helpers/signatures proposed fsharp/fslang-suggestions#1467

Resolvesfsprojects/FSharp.Control.TaskSeq#143

Adds parallelLimit and parallelDoLimit functions for module Async and module Task

Limit vs Throttled naming is/was discussed in the linked issues; any discussion on naming should happen there

cc @TheAngryByrd This replicates functionality in FsToolkit.ErrorHandling so reviews would be much appreciated (and/or feel free to @ in anyone relevant)

cc @xperiandriTask.parallelLimit has same sig as CancelableTask.whenAllThrottled in #20128 (I intend to take the enhancement and review comments from there into account too)

Checklist:

@github-actions

github-actionsBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

✅ No release notes required

@bartelink
bartelinkforce-pushed the parallel-limit branch 2 times, most recently from c7f4834 to 0279f13CompareAugust 19, 2026 14:04
@bartelink
bartelink marked this pull request as ready for review August 19, 2026 14:36
@bartelink
bartelink requested a review from a team as a code ownerAugust 19, 2026 14:36
CopilotAI lite review requested due to automatic review settings August 19, 2026 14:36

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds bounded-parallelism helpers to FSharp.Core’s Async and Task modules (parallelLimit / parallelDoLimit), along with unit tests, surface-area baselines, and release notes, to support controlled concurrency for async/task workflows.

Changes:

  • Add Async.parallelLimit / Async.parallelDoLimit wrappers over Async.Parallel with maxDegreeOfParallelism.
  • Add Task.parallelLimit / Task.parallelDoLimit implemented via SemaphoreSlim + Task.WhenAll, flowing a provided CancellationToken to task factories.
  • Add unit tests + surface area baseline updates + release note entry for the new APIs.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Control/TaskModuleFunctions.fsAdds unit tests for Task.parallelLimit / Task.parallelDoLimit (results + concurrency cap).
tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Control/AsyncModuleFunctions.fsAdds unit tests for Async.parallelLimit / Async.parallelDoLimit.
tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.release.bslSurface-area baseline update for new AsyncModule/TaskModule members (netstandard2.1, release).
tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.debug.bslSurface-area baseline update (netstandard2.1, debug).
tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.release.bslSurface-area baseline update (netstandard2.0, release).
tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.debug.bslSurface-area baseline update (netstandard2.0, debug).
src/FSharp.Core/tasks.fsiPublic API + docs for Task.parallelLimit / Task.parallelDoLimit.
src/FSharp.Core/tasks.fsImplementation of Task.parallelLimit / Task.parallelDoLimit.
src/FSharp.Core/async.fsiPublic API + docs for Async.parallelLimit / Async.parallelDoLimit.
src/FSharp.Core/async.fsImplementation of Async.parallelLimit / Async.parallelDoLimit.
docs/release-notes/.FSharp.Core/11.0.100.mdRelease note entry for the new bounded-parallelism helpers.
Suppressed comments (1)

src/FSharp.Core/tasks.fs:820

  • SemaphoreSlim disposal is wired up via a ContinueWith, but if enumerating computations throws while building the Task.WhenAll input array, the semaphore is leaked (the continuation is never attached). Using use sem = ... and dropping the continuation avoids the leak and simplifies the implementation.
 allTask.ContinueWith(
(fun (_: Task<'T[]>) -> sem.Dispose()),
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment threadsrc/FSharp.Core/tasks.fs Outdated
Comment threadsrc/FSharp.Core/tasks.fsi Outdated
@github-actionsgithub-actionsBot added the AI-Tooling-Check-Scanned-Clean Tooling check: diff analyzed, no interesting infrastructure files label Aug 19, 2026
Comment threadsrc/FSharp.Core/tasks.fs Outdated
@bartelinkbartelink changed the title feat(Task, Async): parallelLimit, parallelDoLimitfeat(Task, Async): parallelLimit, sequential, startAsyncImmediateAug 19, 2026
@bartelink
bartelink requested a balanced review from CopilotAugust 20, 2026 00:32

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comment threadsrc/FSharp.Core/tasks.fs Outdated
Comment on lines +809 to +812
[|
for f in computations ->
backgroundTask {
do! sem.WaitAsync ct

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(while this is true, the complexity ramps significantly too; Task.WhenAll internally doesn't get into this sort of thing either. My feeling is to keep it simple for now; if anyone want's to extend it, we'd likely want more extensive test scenarios on the consumption end to go with that at the same time. On the other hand if anyone wants to chuck an agent at it, I'm not entirely averse to going into the rabbit hole at this time...)

Comment threadsrc/FSharp.Core/tasks.fs Outdated

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actionsgithub-actionsBot added the ⚠️ Affects-Test-Tooling Tooling check: PR touches test framework infrastructure label Aug 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Tooling Safety Check — Affects-Test-Tooling
Affects-Test-Tooling: XunitSetup.fs adds TestClassAttribute/Fact4Attribute shims controlling test parallelization

Generated by PR Tooling Safety Check · opus46 2.9M ·

@bartelink

bartelink commented Aug 22, 2026

Copy link
Copy Markdown
ContributorAuthor

@T-Gro if you can chuck an AI or human review on this I'll attend to it
There are no ValueTask variants of any of the above even though I put one in fsharp/fslang-suggestions#1467; I've never personally needed them; should one be added for e.g. startAsyncImmediate ?

@T-Gro@TheAngryByrd In my musings in fsharp/fslang-suggestions#685 (comment) I noted that parallelLimit and friends should probably make an internal linked CT and cancel siblings where one of the computations faults in order to match the Async.Parallel semantics, thoughts? Assumed yes, applied the relevant cancellation semantics to the PR

@TheAngryByrd@T-Gro Any thoughts on whether we should add Async.StartChild(computation, externalCt) per fsharp/fslang-suggestions#685 into the mix?

@TheAngryByrd Are there any other common things that might belong in the modules that get heavy usage, e.g. ofUnitTask helpers or suchlike?

(Note I've merged in the RunSynchronously triage PR in on top so should not be merged first) trimmed that back out; hopefully this will build cleanly without it...

@bartelink
bartelinkforce-pushed the parallel-limit branch 2 times, most recently from 66b3900 to 95690f9CompareAugust 23, 2026 16:11
@bartelink
bartelinkforce-pushed the parallel-limit branch 3 times, most recently from 28f7eee to 67fbceeCompareAugust 23, 2026 22:57
# Conflicts:
#	src/FSharp.Core/async.fsi
#	src/FSharp.Core/tasks.fs
#	src/FSharp.Core/tasks.fsi
#	tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.debug.bsl
#	tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.release.bsl
#	tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.debug.bsl
#	tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.release.bsl
#	tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Control/AsyncModuleFunctions.fs
#	tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Control/TaskModuleFunctions.fs
Comment threadsrc/FSharp.Core/tasks.fs Outdated
Comment on lines +829 to +831
with e when interceptNonCancellationExn (fun () -> innerCts.Cancel()) e ->
// We'll never get here as the filter always returns false
return Unchecked.defaultof<_>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't say I like this way of using when clause to do logic.
Could it not do the Cts.Cancel() in the finally (by storing the Task instance locally perhaps?)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't either, but I need to trap/intercept both the CT -> Task throwing (i.e. the f CT bit), as well as the actual Task outcome

It seems that would mean 2x try/with ? my main aim here is to avoid doing a raise e as I don't want to alter the call stack, or wrap it (and reraise doesnt work in a task {)

The exception type filtering is not important.

I could make a task { and then ContinueWith OnlyOnFaulted to represent the side-effect instead, but it feels like it'd get messy too. Want me to try that, or any other ideas?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, a completed flag + finally is what I meant — no with, so the exception is untouched, and it covers both the f ct throw and the task fault:

let mutablecompleted=falsetrylet!r= f innerCt
completed <-truereturn r
finallyifnot completed then innerCts.Cancel()

(Sem release stays in the finally too.)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Far too elegant and clean for my convoluted mind! Thanks, will do!

@T-GroT-Gro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖🕵️ AI review — verify independently.

Comment threadsrc/FSharp.Core/async.fsi Outdated
/// do! Async.Sleep 500 // Or any other async activity
/// let! v1 = completor1
/// let! v2 = completor2
/// and! v2 = completor2

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖🕵️ Doc example doesn't compile — async {} defines no MergeSources/Bind2, so and! is a hard FS3343 (verified via dotnet fsi). Same at L382. Revert both and!let!.

async{let!v1= completor1
and! v2 = completor2 // error FS3343...}

@bartelinkbartelinkAug 24, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks.
Note the actual thing bothering me about the example is that if you needed to do things, you would still do the last one inline and only do one StartChild. While I'd never have used two StartChild calls in an example in the first instance, I can imagine not showing that explicitly as a possibility is a loss to some. Let me know if you think it should be edited down to one or whether having a fresh minimal example is useful (but assuming no)
but... should there be a MergeSources/Bind in the box? Is there a tracking issue?


match! Async.catch sut with
| Error (:? InvalidOperationException as e) ->
Assert.Equal("boom1", e.Message)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖🕵️ Flaky (~15% — measured boom2 winning 44/300). Async.parallelLimitAsync.Parallel surfaces the first-observed exception; both siblings are released simultaneously, so the winner is a race. Assert the invariant, not which one wins:

match! Async.catch sut with| Error (:? AggregateException)-> failwith "should be a single exception, not an AggregateException"| Error (:? InvalidOperationException)| Error (:? ArgumentException)->()// either sibling may win| x -> failwith $"unexpected %A{x}"

@bartelinkbartelinkAug 24, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done (also applied to clone for Task on assumption that same applies there).

xlmdoc for task version left it open

/// <p>Where multiple computations Fault, a single exception is propagated.</p>

xmldoc for Async.parallel[Do]Limit says only:

/// <remarks>While the result order matches the input order, the relative start and completion order of computations is arbitrary.</remarks>

Async.Parallel remarks says, among other things:

/// If any child computation raises an exception, then the overall computation will trigger an exception, and cancel the others.

Any suggestions for better wordings appreciated

Comment threadsrc/FSharp.Core/tasks.fs Outdated
return!
Task.WhenAll
[|
for f in computations ->

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖🕵️ A throwing input seq orphans already-started tasks; use sem/use innerCts then dispose out from under them → unobserved ObjectDisposedException (measured 2/2 against the built dll; process-fatal under ThrowUnobservedTaskExceptions). Materialize before starting any work (as Async.Parallel does):

letcomputations= Array.ofSeq computations // enumerate before any backgroundTask starts

Trigger:

seq{yieldfun _ ->task{do! Task.Delay 500;return1}yieldfun _ ->task{do! Task.Delay 500;return2}
failwith "boom"}// -> 2 unobserved ObjectDisposedException|> Task.parallelLimit 4 CancellationToken.None

@bartelinkbartelinkAug 24, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done (and special cased 0/1 tasks to avoid spinning up cts/sem/backgroundTask etc)

Comment threadsrc/FSharp.Core/tasks.fs Outdated
return!
Task.WhenAll
[|
for f in computations ->

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

backgroundTask under a non-null SynchronizationContext / non-default TaskScheduler.Current starts via Task.Run (tasks.fs:256-270). Since [| for f in computations -> backgroundTask {…} |] builds all n up front, every item queues a work item at creation — the semaphore never gets to gate. So maxDop bounds concurrency, not scheduling.

100k trivial factories, maxDop = 2, captured sync context:

work itemspeak queued
sequential00
parallelLimit 2~200k (≈2·n)~63k

Async.parallelLimit doesn't do this — Async.Parallel's bounded branch (async.fs:1667) runs maxDop worker loops, O(maxDop) live. Worth at least a doc note; the worker-loop shape also closes the "materialize before starting" thread.

worker-loop sketch
letitems= computations |> Seq.toArray // materialize once (also fixes the enum-throw leak)let mutablei=-1letworker()= backgroundTask {let mutablej= Interlocked.Increment &i
while j < items.Length dolet!r= items.[j] innerCt // → results.[j]
j <- Interlocked.Increment &i }
Task.WhenAll [|for_in1.. min maxDop items.Length -> worker ()|]

@bartelinkbartelinkAug 24, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@T-Gro Done-ish, but stuff is choking and I'v spent too long in a circle not to ask someone!

  1. | req when maxDegreeOfParallelism = 1 || req.Length = 1 -> sequential ct req

    errors as:

    tasks.fs(827,47): error FS0043: The type 'bool' does not support the operator '||'

    I can work around that by doing:

    | [| _ |] as req -> sequential ct req
    | req when maxDegreeOfParallelism = 1 -> sequential ct req
    
  2. Interlocked.Increment &i gives me

    FSharp.Core/tasks.fs(837,67): error FS0001: Type mismatch. Expecting a 'byref<'a>' but given a ''a' The types ''a' and 'byref<'a>' cannot be unified.
    FSharp.Core/tasks.fs(837,57): error FS0041: A unique overload for method 'Increment' could not be determined based on type information prior to this program point. A type annotation may be needed.Known type of argument: byref<'a>Candidates: - Interlocked.Increment(location: byref) : int64 - Interlocked.Increment(location: byref) : int - Interlocked.Increment(location: byref) : uint32 - Interlocked.Increment(location: byref) : uint64

    Assigning the result to a temp that's not mutable doesnt help
    (using a ref, moving stuff out of line, explicit markers on the mutable and many other things are not helping)

  3. the && in index < req.Length && not innerCts.IsCancellationRequested gives me

    tasks.fs(839,50): error FS0043: The type 'bool' does not support the operator '&&'

    trying to move the expression out of the while expr into the assignments doesnt help

    (I've definitely met clones of this before in TaskSeq, which also has fsi/fs file pairs?)


(Wondering if this is a fixed fantomas issue and the fantomas in this repo is behind, or whether it should be logged)

  1. Fantomas accepts items.[j] innerCt but take out the . and it renders items[j]innerCt

# This is the 1st commit message:
review: correct handling under exception in enumeration
# The commit message #2 will be skipped:
# f
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⚠️ Affects-Test-ToolingTooling check: PR touches test framework infrastructureAI-Tooling-Check-Scanned-CleanTooling check: diff analyzed, no interesting infrastructure files

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

TaskEx: parallelLimit

4 participants

@bartelink@TheAngryByrd@T-Gro