Skip to content

Commit b7c6476

Browse files
perf: replace ref cells with mutable in ofSeq, tryWith, tryFinally enumerators
Each call to AsyncSeq.ofSeq, or any async CE block using try...with, try...finally, or use expressions, previously heap-allocated a Ref<T> wrapper object to hold the enumerator's state machine field. Converting from: let state = ref (SomeState.NotStarted inp) ... state.Value <- SomeState.Next ... to: let mutable state = SomeState.NotStarted inp ... state <- SomeState.Next ... eliminates the Ref<T> heap allocation because the mutable local is promoted to a direct field in the compiler-generated object-expression class (the same pattern already used by collectSeq and takeWhileInclusive since 4.11.0/4.12.0). Also eliminates two short-lived Ref<Choice<_,_>> locals per tryWith invocation (used to bridge synchronous try...with inside the async block). 422/422 tests pass. Co-authored-by: Copilot <[email protected]>
1 parent 58695ca commit b7c6476

3 files changed

Lines changed: 66 additions & 25 deletions

File tree

RELEASE_NOTES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
### 4.16.0
2+
3+
* Performance: Replaced `ref` cells with `mutable` locals in the `ofSeq`, `tryWith`, and `tryFinally` enumerator state machines. Each call to `ofSeq` (or any async CE block using `try...with` / `try...finally` / `use`) previously heap-allocated a `Ref<T>` wrapper object per enumerator; it now uses a direct mutable field in the generated class, reducing GC pressure. The change is equivalent to the `mutable`-for-`ref` improvement introduced in 4.11.0 for other enumerators.
4+
15
### 4.15.0
26

37
* Bug fix: `AsyncSeq.removeAt` and `AsyncSeq.updateAt` now raise `ArgumentException` when the index is greater than or equal to the sequence length, consistent with `List.removeAt`, `Array.removeAt`, and `AsyncSeq.insertAt`. Previously they silently returned the sequence unchanged.

src/FSharp.Control.AsyncSeq/AsyncSeq.fs

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -548,35 +548,35 @@ module AsyncSeq =
548548
let tryWith (inp: AsyncSeq<'T>) (handler : exn -> AsyncSeq<'T>) : AsyncSeq<'T> =
549549
// Note: this is put outside the object deliberately, so the object doesn't permanently capture inp1 and inp2
550550
AsyncSeqImpl(fun () ->
551-
let state = ref (TryWithState.NotStarted inp)
551+
let mutable state = TryWithState.NotStarted inp
552552
{ new IAsyncSeqEnumerator<'T> with
553553
member x.MoveNext() =
554-
async { match state.Value with
554+
async { match state with
555555
| TryWithState.NotStarted inp ->
556-
let res = ref Unchecked.defaultof<_>
556+
let mutable res = Unchecked.defaultof<_>
557557
try
558-
res.Value <- Choice1Of2 (inp.GetEnumerator())
558+
res <- Choice1Of2 (inp.GetEnumerator())
559559
with exn ->
560-
res.Value <- Choice2Of2 exn
561-
match res.Value with
560+
res <- Choice2Of2 exn
561+
match res with
562562
| Choice1Of2 r ->
563563
return!
564-
(state.Value <- TryWithState.HaveBodyEnumerator r
564+
(state <- TryWithState.HaveBodyEnumerator r
565565
x.MoveNext())
566566
| Choice2Of2 exn ->
567567
return!
568568
(x.Dispose()
569569
let enum = (handler exn).GetEnumerator()
570-
state.Value <- TryWithState.HaveHandlerEnumerator enum
570+
state <- TryWithState.HaveHandlerEnumerator enum
571571
x.MoveNext())
572572
| TryWithState.HaveBodyEnumerator e ->
573-
let res = ref Unchecked.defaultof<_>
573+
let mutable res = Unchecked.defaultof<_>
574574
try
575575
let! r = e.MoveNext()
576-
res.Value <- Choice1Of2 r
576+
res <- Choice1Of2 r
577577
with exn ->
578-
res.Value <- Choice2Of2 exn
579-
match res.Value with
578+
res <- Choice2Of2 exn
579+
match res with
580580
| Choice1Of2 res ->
581581
return
582582
(match res with
@@ -587,7 +587,7 @@ module AsyncSeq =
587587
return!
588588
(x.Dispose()
589589
let e = (handler exn).GetEnumerator()
590-
state.Value <- TryWithState.HaveHandlerEnumerator e
590+
state <- TryWithState.HaveHandlerEnumerator e
591591
x.MoveNext())
592592
| TryWithState.HaveHandlerEnumerator e ->
593593
let! res = e.MoveNext()
@@ -597,9 +597,9 @@ module AsyncSeq =
597597
| _ ->
598598
return None }
599599
member x.Dispose() =
600-
match state.Value with
600+
match state with
601601
| TryWithState.HaveBodyEnumerator e | TryWithState.HaveHandlerEnumerator e ->
602-
state.Value <- TryWithState.Finished
602+
state <- TryWithState.Finished
603603
dispose e
604604
| _ -> () }) :> AsyncSeq<'T>
605605

@@ -614,14 +614,14 @@ module AsyncSeq =
614614
// The (synchronous) compensation is run when the Dispose() is called
615615
let tryFinally (inp: AsyncSeq<'T>) (compensation : unit -> unit) : AsyncSeq<'T> =
616616
AsyncSeqImpl(fun () ->
617-
let state = ref (TryFinallyState.NotStarted inp)
617+
let mutable state = TryFinallyState.NotStarted inp
618618
{ new IAsyncSeqEnumerator<'T> with
619619
member x.MoveNext() =
620-
async { match state.Value with
620+
async { match state with
621621
| TryFinallyState.NotStarted inp ->
622622
return!
623623
(let e = inp.GetEnumerator()
624-
state.Value <- TryFinallyState.HaveBodyEnumerator e
624+
state <- TryFinallyState.HaveBodyEnumerator e
625625
x.MoveNext())
626626
| TryFinallyState.HaveBodyEnumerator e ->
627627
let! res = e.MoveNext()
@@ -633,9 +633,9 @@ module AsyncSeq =
633633
| _ ->
634634
return None }
635635
member x.Dispose() =
636-
match state.Value with
636+
match state with
637637
| TryFinallyState.HaveBodyEnumerator e->
638-
state.Value <- TryFinallyState.Finished
638+
state <- TryFinallyState.Finished
639639
dispose e
640640
compensation()
641641
| _ -> () }) :> AsyncSeq<'T>
@@ -767,13 +767,13 @@ module AsyncSeq =
767767

768768
let ofSeq (inp: seq<'T>) : AsyncSeq<'T> =
769769
AsyncSeqImpl(fun () ->
770-
let state = ref (MapState.NotStarted inp)
770+
let mutable state = MapState.NotStarted inp
771771
{ new IAsyncSeqEnumerator<'T> with
772772
member x.MoveNext() =
773-
async { match state.Value with
773+
async { match state with
774774
| MapState.NotStarted inp ->
775775
let e = inp.GetEnumerator()
776-
state.Value <- MapState.HaveEnumerator e
776+
state <- MapState.HaveEnumerator e
777777
return! x.MoveNext()
778778
| MapState.HaveEnumerator e ->
779779
return
@@ -784,9 +784,9 @@ module AsyncSeq =
784784
None)
785785
| _ -> return None }
786786
member x.Dispose() =
787-
match state.Value with
787+
match state with
788788
| MapState.HaveEnumerator e ->
789-
state.Value <- MapState.Finished
789+
state <- MapState.Finished
790790
dispose e
791791
| _ -> () }) :> AsyncSeq<'T>
792792

tests/FSharp.Control.AsyncSeq.Tests/AsyncSeqTests.fs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4585,3 +4585,40 @@ let ``AsyncSeq.cycle on singleton repeats single element`` () =
45854585
|> AsyncSeq.toArrayAsync
45864586
|> Async.RunSynchronously
45874587
Assert.AreEqual([| 42; 42; 42; 42; 42 |], result)
4588+
4589+
// ===== ofSeq: re-enumeration and empty-sequence edge cases =====
4590+
4591+
[<Test>]
4592+
let ``AsyncSeq.ofSeq empty returns empty`` () =
4593+
let result = AsyncSeq.ofSeq Seq.empty<int> |> AsyncSeq.toArrayAsync |> Async.RunSynchronously
4594+
Assert.AreEqual([||], result)
4595+
4596+
[<Test>]
4597+
let ``AsyncSeq.ofSeq can be enumerated multiple times`` () =
4598+
let s = AsyncSeq.ofSeq [1; 2; 3]
4599+
let r1 = s |> AsyncSeq.toArrayAsync |> Async.RunSynchronously
4600+
let r2 = s |> AsyncSeq.toArrayAsync |> Async.RunSynchronously
4601+
Assert.AreEqual([| 1; 2; 3 |], r1)
4602+
Assert.AreEqual([| 1; 2; 3 |], r2)
4603+
4604+
// ===== tryFinally: compensation runs even when downstream stops early =====
4605+
4606+
[<Test>]
4607+
let ``asyncSeq use releases resource on early termination`` () =
4608+
let disposed = ref false
4609+
let resource = { new System.IDisposable with member _.Dispose() = disposed := true }
4610+
let s = asyncSeq {
4611+
use _r = resource
4612+
yield 1; yield 2; yield 3 }
4613+
s |> AsyncSeq.take 1 |> AsyncSeq.toArrayAsync |> Async.RunSynchronously |> ignore
4614+
Assert.IsTrue(disposed.Value)
4615+
4616+
// ===== tryWith: handler receives exception and yields elements =====
4617+
4618+
[<Test>]
4619+
let ``asyncSeq try-with handler can yield elements`` () =
4620+
let s = asyncSeq {
4621+
try failwith "boom"
4622+
with _ -> yield 42 }
4623+
let result = s |> AsyncSeq.toArrayAsync |> Async.RunSynchronously
4624+
Assert.AreEqual([| 42 |], result)

0 commit comments

Comments
 (0)