They have much the same purpose and semantics, yes.
No, there is no equivalent. If you need to update application state, do so by having your task hop over to the main actor (covered by the global actors pitch) to perform the updates in a coordinated way.
That's actually the way they are implemented, as two hidden parameter attributes, and I thought it would be more confusing to present them this way and I care more about the overall semantics of async
. The two hidden attributes are:
@_inheritsActorContext
: when a closure argument is provided to a parameter that has this attribute, the closure inherits the actor context in which the closure is created.@_implicitSelfCapture
:self
can be implicitly captured without requiringself.
.
We could do these attributes as separate things, now or some time in the future.
No, the closure just ends up being non-isolated.
Do we have use cases where the
async(_:)
call isn’t the last statement in the function? If not, I’d be tempted to support this use case with something like:@MainActor func saveResults() { view.startSavingSpinner() // executes on the main actor, immediately @detached await ioActor.save() // hop to ioActor to save view.stopSavingSpinner() // back on main actor to update UI }
Basically, in a non-
async
function,@detached await
is legal and plainawait
is legal if a@detached await
always executes first. This avoids having to create several ad-hoc closure rules for a specific function, since no closure is involved at all.
We went pretty far down this design path. The problem with it is that there may be multiple awaits, and it isn't clear which one is the one that performs the detach:
@MainActor func saveResults() {
view.startSavingSpinner() // executes on the main actor, immediately
if somethingChanged {
await ioActor.save() // hop to ioActor to save
}
if let backupServer = backupServer {
await backupServer.save() // hop to backupServer to save
}
view.stopSavingSpinner() // back on main actor to update UI
}
Which of those two awaits should be marked with @detached
? The first one? The second? Both? We might not even detach ever, depending on those if
conditions.
(Side note: it's also not actually a "detach", because we're inheriting priority, task locals, etc.)
Okay, if the only differences are the return type and that
async
makes some things implicit whichasyncDetached
requires to be explicit, then it seems clear that these are “really” (conceptually) the same function.
asyncDetached
cannot necessarily express all of the things that async
does. For example, it's not easy to capture all of the task-local values in the currently-executing task to copy them over, and there are things the OS might want to propagate that through async
that won't be expressed in the signature of asyncDetached
.
I don't think we can collapse async
and asyncDetached
into a single function. They are different functions that share a root, async
, because they are both initiating asynchronous code.
This is an important bit of functionality needed to complete the concurrency model. One thing I'm a bit nervous about is the implicit-self semantics. We already live in a world where
self
is implicit if it is explicitly captured by the closure or ifself
is astruct
orenum
, and now we would be adding "if the closure is the argument to theasync
function". It feels uncharacteristic of Swift to have such special-cased behavior. It feels like we should come up with a more formal semantic for whenself
is implicit, and until we do so explicitly capturing self{ [self] in … }
seems like a small price to pay.
The requirement to write self.
when self
has been captured in a closure has a specific purpose: it is to indicate that the developer might need to think about reference cycles. Where it is not likely to be indicative of a reference cycle, the self.
requirement creates visual noise, and that noise de-values the usefulness of self.
to indicate potential reference cycles. We've been chipping away at the self.
requirement over the years in places where reference cycles are unlikely, and this is following that precedent. It's unlikely that we'll get a reference cycle from a closure that is executed and is only held on to by that task.
Omitting the handle precludes cancellation, though. I'd rather keep the handle, if only to retain possibility of cancellation.
This is a reasonable point. We could return a Task.Handle<Void, Never>
, which would allow cancellation but would not communicate any information back on get()
that "it's done."
Doug