I'm open to the hard reality that the suggestion I spent roughly one hour considering before posting is bad. But I'm also going to take a little bit of issue with "meaningless" real quick, mostly for fun.
Going back to async let, it's my understanding that its behavior may be completely replicated using Tasks, which could also be reduced to single lines if you didn't break for curlys. So then is async let not also meaningless sugar?
(I kid here. It's ofc a much more justified feature than mine. Just having some fun.)
Moving on, if the issue is that I chose a name that implies structured concurrency, that's a great callout. And I am of course completely open to a different name for this hypothetical feature/keyword.
I'd love to keep with 5 character words that begin with "a". Maybe avoid?
No, if you mean the Task type specifically when you say "using Tasks". async let is a structured concurrency primitive, while Task isn't. async let will spawn a (by definition structured) child task that will propagate errors and cancellation, while Task will spawn an unstructured task that has none of that.
This is actually something I started to do initially as a one-liner for my use case, without thinking about what could be happening under the hood. I just assumed, like @crontab, that if I never await, then my context will exit without it.
Even before I read more into it, I ended up switching back to an unstructured task because it felt like misuse of async let and it was definitely less obvious what the intent of the code was. But it got me wondering how long I may have used the pattern thinking I was avoiding the await.
Would you mind clarifying why it felt like a misuse of async let? IMO that unstructured nature of Task hides the intent and has many more pitfalls. In the vast majority of cases, why would one want to spawn an unstructured task and ignore errors thrown by it or make it ignore cancellation requests?
try await someImportantOperation()
// success!
async let _ = someAsyncSideEffect()
// carry on without caring about that second task
Which, as established, doesn't do what I initially expected it to (the context would still wait for that second task even if I didn't). But even if it did, given my intent was to ignore it, I didn't feel like that was as well-expressed as through an unstructured Task {}. So I switched back.
If my intent was to stick to strict structure, then of course async let would express that better, not least because I presumably wouldn't be dropping the variable.
I just removed the use of detached here […] it doesn't really have any affect.
I thought detached guaranteed a global executor (though I'm not finding this in the documentation now). Or are you saying that using a global executor is irrelevant in this case since the only operation being performed is already isolated to itself (and will therefore immediately switch again)?
…there is yet another proposal pending that could influence this further
Also very much looking forward to that implementation. Implicit isolation inheritance has been one of the rougher topics to follow, and I've only recently even come to be aware of it. (Anecdote: I had a Task {} that I expected would inherit the main actor and it didn't, but if it did I would have had a deadlock.)
With that in place, you could remove only isolation. […] I'm not sure this is actually useful for async functions, but I think it could be for synchronous ones.
Could you elaborate on what you mean for synchronous functions here?
Now, in my opinion, using Task here is the best option. First, users of concurrency have to understand it regardless.
I actually think that Task is so simple to use that learners can easily avoid understanding it. I certainly did (see prior anecdote). But that's poor justification for giving them something else that's just as simple to use, I know.
Finally, the concurrency system is already complex. Introducing a new keyword here would need a lot of justification.
For sure. And I expected the history (if there was any) would boil down to this. I think if there was some way that the keyword/feature could be implemented to help learners adhere to (or at least be aware of) strict concurrency sooner, that could make it more appealing. But I have no ideas there right now (since my intent was to avoid it).
It is guaranteed to be non-isolated and therefore on the global executor. But, yes the second point is what I was getting at. The static isolation defined for the Task's closure probably* doesn't matter because you'll then just switch according to whatever the callee has defined.
it would matter, however, if the callee used an isolated parameter with #isolation default, which gaining popularity right now.
Right! So while the isolation of the Task's closure doesn't matter (ish) for it's asynchronous calls, it still most definitely does matter for its synchronous
Thinking about this occupies a considerable amount of my time. I also don't have any good/concrete ideas, but I really like the idea of discussing it because you never know where we'll hit on something!
But, as has been discussed, I'm not sure fire-and-forget is a common enough pattern to warrant attention, especially given how many other aspects of concurrency have been confusing to people. For example, I'd estimate that just non-isolated+async = global executor averages one new thread per day on this forum.
It cannot. Or at least, I'm not aware of any way that it could be, using currently existing language features.
First and foremost, due to the lack of async defer and async deinit, which is being discussed in a separate thread. You'd basically have to manually add the cancel and await logic to every possible exit point of the scope, including every single try. And you'd need to constantly maintain it every time a new try or return is added to the scope.
Second, because, as mentioned in a different thread yet again, while you can transfer cancellation signals to a manually created task, the same is not so simple with regards to task-local values.
And mind you that, despite all of this, I actually think async let was mistake. If we had async deinit, and some way of transferring task locals, then async letcould have been implemented as a class/struct instead, with such extra features as being able to return it to a caller, or have it as a member variable of another class, or even adding it to an array allowing it to cover TaskGroup as well. And my opinion is that if it could have, then it should have.
Actually, the default behavior for an async let that is not awaited is to first cancel it, and only then await. Which is probably the farthest thing from what you want to happen, as it means that as soon as your side-effect function waits on anything cancellable, it will actually stop running.
I completely agree with the need to add a "fire and forget" call to async function akin to dispatchQueue.async {} where you don't want to wait for the response. Not sure if I agree with the suggested syntax though.
The intention is to have a one-liner shortcut for Task {...} (or the detached one?). I like shortcuts but this one is problematic in many ways unfortunately.
Here's why a Task { ... } wrapper isn't really a solution to the OP problem (if I understand the OP problem; if not, this is an issue I have been mulling on a while):
// nonisolated;
// eg: bridging from non-Swift code;
// eg: members on an actor bridging some non-async protocol.
nonisolated func startAllThings() {
Task { await thing.start() }
}
nonisolated func stopAllThings() {
Task { await thing.stop() }
}
startAllThings()
stopAllThings()
// "works on my computer", but good luck!
A fire-and-forget operation would guarantee that the receiver got a chance to -start- the job without requiring the caller to block for the whole job to finish.
I think I want to write it like this:
nonisolated func startAllThings() { // still not async
fire_forget_await thing.start()
}
.. where it's ok to block/suspend briefly (?) up until the point that 'thing' is guaranteed to run this "job" next (or even up to the point it would itself suspend?).
The external interactions now sequence correctly -- and are usable from non-async contexts. Of course, "Thing" still has to handle its own reentrancy issues, but this at least is a building block to making that easier.
I know this is not fully baked. This kind of bridge code is really hard to write correctly and I don't like any of the ways I've solved this so far.
To make it deterministic, we can do something like this:
func hibernate (seconds: Int) async {
try? await Task.sleep (until: .now + .seconds (seconds))
}
actor System {
enum State {
case initial
case starting
case started
case stopping
case stopped
case error (Error)
}
private var _state: State = .initial
func set (state: State) {
self._state = state
}
func state () -> State {
return _state
}
}
nonisolated func startAllThings (_ s: System) {
Task {
await s.set (state: .starting)
// await thing.start()
await hibernate (seconds: 7)
// ready
await s.set (state: .started)
}
}
nonisolated func stopAllThings(_ s: System) {
Task {
var ttl = 7
while true {
let state = await s.state()
if case .started = state {
// all up, stop them
await s.set (state: .stopping)
// await thing.stop()
await hibernate (seconds: 3)
await s.set (state: .stopped)
break
}
else {
if ttl == 0 {
fatalError ("took too long")
}
else {
ttl -= 1
// try again after one second until ttl is zero
await hibernate (seconds: 1)
}
}
}
}
}
The keyword here is "next" (i.e. you look for a strict ordering). There is no built-in tool for that in the standard library, but there exist libraries. See for example mattmassicotte/Queue:
let queue = AsyncQueue()
// Start is guaranteed to run before stop.
queue.addOperation { await thing.start() }
queue.addOperation { await thing.stop() }