In general we expect sensible behaviour for sub-sequences of AsyncSeq objects (those returned by tail, skip etc.)
When I look at the definition of something like ofObservableUsingAgent
I see an AsyncSeq which has internal resources - an agent - and, if you replayed the_tail_ of the sequence multiple times, or if you didn't iterate all the way through the sequence, then you'd get some odd behaviour - each time you're replying it you're communicating with the agent and presumably getting different replies. The agent allocated at the start represents shared state, shared between all the various sub-sequences returned. Also, the agent only gets collected if the sequence gets iterated all the way to the end.
let internal ofObservableUsingAgent (input : System.IObservable<_>) f =
asyncSeq {
use agent = AutoCancelAgent.Start(f)
...
yield! loop() }
All this stems from the fact that the definition of AsyncSeq is currently much like a lazy list, just asynchronous (indeed it's name could almost be AsyncList). The "tail" operation drops the head, and a "cons" would put a different head on etc. Attempts to use shared mutable resources in LazyList-like combinators usually results them being shared in all "tail" instances, with odd results.
Basically, LazyList-like structures can't hold shared-mutable or disposable resources during iteration. That's one advantage of IEnumerable<'T>
over LazyList<'T>
, because IEnumerable separates iteration from data, and iterators can hold state.
To give another example, the current "algebraic" definition is problematic w.r.t. the behaviour of "use" . As things stand today, when you use "use" in an asyncSeq { ... }
(a) The compensation functions are only executed if the consuming iteration completes to the end of the sequence - and not if an early exit occurs. (In contrast, IEnumerator objects can be disposed at any point during the iteration.)
(b) The compensation functions will be executed every time the "tail" of an async seq is iterated to its end. (In contrast, a separate IEnumerator object is created for each iteration.)
Basically, it's probably not a good idea to try have both an algebraic definition of AsyncSeq and support using/try-finally.
All these problems leads to the possible alternative definition of AsyncSeq as an async version of iterators:
type IAsyncEnumerator<'T> =
abstract MoveNext : Async<bool>
abstract Current : 'T
abstract Dispose : unit -> unit (* or Async<unit> *)
type IAsyncEnumerable<'T> =
abstract GetEnumerator : unit -> IAsyncEnumerator<'T>
type AsyncSeq<'T> = IAsyncEnumerable<'T>
I actually think we need to do a comparison between the current "algebraic" definition of AsyncSeq and alternative "iterator" definitions. The iterator definition is in many ways the "natural" definition of asynchronous sequences for F#, at least if you take "sequence" = "iterable".
In particular, the "iterator" definition allows a sensible notion of "Dispose" on the iterators, which allows them to have resource - very similarly to the case above.
Note that APIs can often have problems like this without them being observed as a significant problem in practice because in most cases AsyncSeq objects are one-shot - they are only ever iterated once, or iterations of the outermost object are independent as for Seq.
Cheers
Don