I think I love the structured concurrency and I've been having a good play (and have largely converted a personal app over already). That said in some experimentation I'm being stymied and I'm not sure if it is understanding (possibly documentation) gaps or bugs in current implementation.
I've been experimenting with creating my own simple AsyncSequences in ways that actually cut across a lot of the structured concurrency features. I know AsyncStream should make this simpler but I wanted to implement manually to see how it really worked (and I also couldn't find AsyncStream so maybe it isn't available yet).
[Edit: I think I've found the issue, something frequently goes wrong when an continuation is resumed from an actor context. I think I have a solution where the actor manages the continuation but returns it to be called from outside the actor instead of calling it directly itself. Raised as: SR-14875]
I think in particular I'm not seeing the reentrancy behaviour I expect. For example this doesn't work (or at least works only occasionally - maybe I'm missing something obvious):
AsyncTimerActorSequence
@available(macOS 12.0, iOS 15.0, *)
public struct AsyncTimerActorSequence : AsyncSequence {
public typealias AsyncIterator = Iterator
public typealias Element = Void
let interval: TimeInterval
public init(interval: TimeInterval) {
self.interval = interval
}
public func makeAsyncIterator() -> Iterator {
let itr = Iterator()
Task {
await itr.start(interval: interval)
}
return itr
}
public actor Iterator : AsyncIteratorProtocol {
private var timer: Timer?
private var continuation: CheckedContinuation<(), Never>?
fileprivate init() {}
fileprivate func start(interval: TimeInterval) {
let t = Timer(fire: .now, interval: interval, repeats: true) { [weak self] _ in
guard let s = self else { return }
Task.detached {
await s.fireContinuation()
}
}
timer = t
RunLoop.main.add(t, forMode: .default)
}
private func fireContinuation() {
continuation?.resume()
continuation = nil
}
public func next() async throws -> ()? {
await withCheckedContinuation { continuation in
self.continuation = continuation
}
return ()
}
deinit {
timer?.invalidate()
}
}
}
When I use a class instead it seems to work well although I worry about races setting and firing the continuation which is why I leant on the actor. I thought the reentrancy at suspension points meant this would work.
It might be that there is some information that I'm so far missing, possibly in relation to limitations of reentrancy in Actors or the interaction between Actors and continuations.
"AsyncTimerSequence (class based iterator - works but I worry about races setting/getting/clearing continuation
@available(macOS 12.0, iOS 15.0, *)
public struct AsyncTimerSequence : AsyncSequence {
public typealias AsyncIterator = Iterator
public typealias Element = Void
let interval: TimeInterval
public init(interval: TimeInterval) {
self.interval = interval
}
public func makeAsyncIterator() -> Iterator {
Iterator(interval: interval)
}
public final class Iterator : AsyncIteratorProtocol {
private var timer: Timer?
private var continuation: CheckedContinuation<(), Never>?
init(interval: TimeInterval) {
let t = Timer(fire: .now, interval: interval, repeats: true) { [weak self] _ in
if let continuation = self?.continuation {
// I worry about a race condition here but I think it is safe if we don't resume the continuation until it has been cleared.
self?.continuation = nil
continuation.resume()
}
}
timer = t
RunLoop.main.add(t, forMode: .default)
}
public func next() async throws -> ()? {
await withCheckedContinuation { continuation in
self.continuation = continuation
}
}
deinit {
timer?.invalidate()
}
}
}
Note that these versions are only robust to the situation where next() is not called multiple times concurrently (I have a version with an array as a queue - need to look up the state of dequeue) but I don't think I've seen anything in the documentation disallowing concurrent calls to next(). I think it is easy to write sequences which don't handle that properly and it may be a source of bugs. Clearly in the normal for try await let foo in sequence {
you won't get concurrent calls but if people start unrolling or async let
ing a number of items I can see it happening.
An additional albeit temporary complication is differences between builds currently available as the features and syntax evolves and also various compiler flags that affect the concurrency behaviour. Are the 5.5 snapshots the right ones to be using or should I be using the trunk snapshots or whichever happens to be the newest build on a given day? Is there a good way to track what the changes are between snapshots or compared with Xcode releases?