A Task is a gateway from synchronous world to asynchronous world.
// Synchronous function
func f (_ u: Int) -> Int? {
let t = Task <Int, Never> {
return await compute (u)
}
...
return nil
}
Currently, a synchronous function can only cancel aTask which it creates; it can't access the result produced by that Task. This limits the utility of Task in synchronous world.
If Task had a feature that made it possible for synch world to access its state, the utility of Task would be increased.
extension Task {
enum State {
case initial
case running
case cancelled
case finished (Success)
case error (Error)
}
func state () -> State
}
I am not familiar with the internals of the Swift's concurrency system. Therefore, could someone familiar with it provide some insight into why this feature was not made part of Task's interface?
Cancelled and initial are not distinct execution states, but otherwise yes, that’s theoretically implementable. We don’t have it because we don’t really want to encourage programs to just repeatedly synchronously poll task status, but Task could certainly support notifications or some similar mechanism.
Would that be much different from synchronous code launching another Task and awaiting the completion of the original task there? That would at least let you determine cancelled, succeeded, and failure.
Not sure if this is a good This is a bad idea, but it seems to work on my computer:
extension Task {
enum State {
case running
case cancelled
case finished(Success)
case error(Failure)
}
@available(*, noasync)
func getState() -> State {
let sema = DispatchSemaphore(value: 0)
var state = State.running
Task<Void, Never> {
do {
state = .finished(try await self.value)
} catch _ where isCancelled {
state = .cancelled
} catch {
state = .error(error as! Failure)
}
sema.signal()
}
sema.wait(timeout: .now() + .nanoseconds(1))
return state
}
}
For the record I think it's worth saying that it's a bad idea to block using a dispatch semaphore like that and you may well lead to thread exhaustion. The noasync is trying to prevent abuse but won't really help in a real all where such getState() may be called from a synchronous function but actually running on a Task... so you'd still use up a thread of the cooperative pool this way, potentially quickly leading to locking up the entire pool.
What could be implementable is a .valueIfReady -> Value? but as John said, that would encourage people to try to busy-spin-loop around getting the value which arguably may be even worse than semaphores because it'd actively burn up CPU cycles while trying to spin and keep querying that value with an never-ending "is it ready yet!?" Scala Futures actually have this API in the form of isCompleted: Bool and value: Option<Try<T>> but yeah, it's not something that should be used without careful consideration...
There may be a place for such operation but indeed it's a bit scary to expose this, as people could easily abuse it with spinning hot loops around those polling methods...
The upside is that it allows building things like "if I don't have to suspend and the value is already there... do X" or the inverse "if I'll have to suspend, do something first, and then do suspend" etc.
I think having this feature would eliminate the chasm between synchronous and asynchronous worlds. Just because it might be abused should not be the reason for not having it. for loops can be abused too!
Exactly!
func g () -> Int
func h (Int, Int) -> Int
func f () -> Int {
// Compute the magic number in the background
let t = Task <Int> {
return g ()
}
// Do other work while magic number is cooking
let w: Int = ...
// Need the magic number now
if case .finished (let v) == t.state () {
return h (v, w)
}
else {
// not finished
t.cancel ()
// cook it here
let u = h (g (), w)
return u
}
}
That's the kind of code I'm not very comfortable with -- you lost structured concurrency and therefore parent cancellation and structure in tools, all for the unlikely situation the concurrent processing may have finished by the time you hit t.state().
It's very unclear if the spawning off that task, to then not even use it and duplicate the work potentially (!) is actually paying off here. I'd be rather worried that this would most of the time actually compute the work twice, and the t.cancel() racing to try to prevent duplicate work unlikely to actually help in this.
So... it's not clear cut that this is a great solution at all to bring "get async value from sync code"; instead we indeed perhaps need to consider a form of blocking in synchronous code which would not loose structure, keep the code simple, and is what you want most of the time.
Are there advanced patterns you could pull off with the polling API? Yeah I'm sure there are... I wonder if adding such would lead to more abuse than coming up with a blocking wait API though.
TL;DR; I don't think it's clear cut that this is a great addition to solve this specific problem. We should consider it holistically with other alternatives.
While I agree that it could be abused I don't see it in practice. I looked at a fairly large code base with a lot of use of a Future-like type that supports immediate state retrieval, and I found less than 100 uses of the feature. Most of them are from tests and debug prints, and few are used in the pattern "if the result isn't ready yet, do something extra and wait for the result asynchronously" or when a suitable fallback is available. And not a single example of repetitive polling.
What bothers me is how well this could be composed with other language features. For example, if we could check a task's state, would we also want to check the state of an async let?
And if the Task type will ever support ~Copyable types, how will this valueIfReady be defined? Because in this case Task's value getter will likely be consuming and so should be valueIfReady, but having both of them consuming doesn't result in a great API.