I agree that it is valuable to be able to call this from a context that happens to be dynamically isolated and still have it start synchronously. We may still want a warning for situations where this is statically impossible (or at least would only happen in improbable scenarios like two actors synchronizing), but we can let experience suggest whether that's actually important to diagnose. The feature strongly discourages this by using @_inheritActorContext to align the isolations, so in theory it requires the programmer to use a non-closure as the task function (or give the closure an explicitly different isolation) in order to see surprising behavior.
That said, I do think there's a real problem here with the current @_inheritActorContext, because @_inheritActorContextdoesn't inherit isolation if the outer context is isolated to a non-global actor which the closure doesn't capture. If I'm understanding correctly, this means that the closure will be inferred as non-isolated and therefore will never start immediately. We've talked elsewhere about whether this capture behavior is reasonable, but in this case I think it's clearly not, and we really ought to be forcing a capture so that we can inherit isolation properly.
So the signature in the proposal is specifically about the original pitched idea here: the "don't ever allow not synchronous execution", which is now being considered to change into "try to run synchronously". I was working out yesterday what the signature will have to become, and it seems it would be as follows:
(which uncovered a but in interface printing, so we're working to fix that, while at it). We're not relying on the @_inheritActorContext here, but on comparing executor of current and target and doing the synchronous run if able to.
In practice this would be these specific situations where we definitely change isolation:
@SomeActor
func test() {
Task.<immediately?> { @OtherActor in } // always enqueues
}
actor X {
func test() {
Task.<immediately?> { @OtherActor in } // always enqueues
}
}
Here we statically know these must enqueue and won't be "immediate" / "start synchronous" ever. So I guess we could warn about it... I would not want to warn about situations where we're not 100% certain.
There's two parts to talk about here:
One is that we can actually "start synchronously if not actually closed over any specific isolation" but it's a bit of an implementation hack:
Basically, if we use @isolated(any) and the closure is not isolated, its executor will be generic, then we check if the "target" isolation is generic, and if so, we just start synchronously immediately on the caller thread. The subsequent isolation / executor of that closure though, e.g. after we suspend and resume would be the generic executor then though. So we'd have the same semantics as Task{} but we would manage to start synchronously anyway. It's a bit of a hack since we interpret the "no specific isolation" as "ok to start on caller", but given that there's no way to enforce a closure to be nonisolated this actually kinda works I think.
I worked that out yesterday actually - you can check the impl here (pending a bug in sending checking).
The second topic is if that "forgot to close over isolated value / actor so therefore suddenly we execute on the global pool" is reasonable or not. Personally, I see a lot of people struggling with this and I'm more than sure that most developers don't realize this is the rule at all, so I'd love to fix it not only here but also for Task{} that "when created from context that is isolated, inherit that isolation".
IMHO, an ideal outcome would be that Task{} and this API would consistently just inherit the enclosing isolation and we'd be better off with less confusing language rule here overall.
Overall just relying on setting a specific executor like this does effectively "execute using this <...>" can be nice, however Swift also has to take into account compile-time safety through isolation guarantees which Kotlin does not.
It would be very difficult to deal with e.g. an "options" parameter, where we'd get some opaque set of options passed to a function, then passed into Task(options) {} - we don't know what these options mean for isolation. Are we going to be caller isolated, just on global pool, or on some specific other actor..? It's impossible to know without checking at runtime what the options passed in were. That's why our design differs a bit from the usual just passing some options to methods - we also need to understand how such would change the execution semantics (and thus isolation) of the created task.
I am not disagreeing, but I don't see why the pattern of
add a module-wide compiler setting to change the default behavior
have it opt-in for Swift 6
add explicit factory functions for both versions (with less pressure to have concise naming of the future default)
(maybe) one day change the default (have it opt-out) in a major version break
would not be possible here too.
The only tricky bit is how to conditionalize which specific Task-factory method to call based on a compiler setting - but messier problems have been solved before.
I do understand that the runtime-behavior changes here are vastly more ... let's say noticeable ... compared to isolation inheritance, but there are benefits on the other side of the equation too, eg:
More deterministic transitions from sync to async code (people have resorted to wild things in testing setups to get that back)
Possibility of a runtime that executes non-suspending async code almost without overhead
Possibly better performance in general (it feels there is simply one less thing to do for most new Tasks)
I think there are things we could do to make this feasible, e.g. we could allow upcoming features to be written in availability attributes so a new Task.init with the startSynchronously semantics is available when the feature is enabled and the old Task.init is unavailable. We could preserve the old behavior with a differently-named method, e.g. Task.enqueue and provide migration tooling that replaces all uses of Task.init with Task.enqueue to preserve semantics when enabling the upcoming feature, etc.
However, I don't think we should change the semantics of Task.init -- probably the most commonly used APIs in the concurrency library -- because I do not believe the current behavior of Task.init is actively harmful in the same way that the behavior of async functions is, and asking people to re-learn the behavior of Task.init has real consequences that I do not think are worth the benefits. I think the behavior of Task.init is more predictable because the enqueueing behavior is not based on a dynamic property, and like John mentioned, you have to be a bit more careful when using the Task.startSynchronously API. And the benefits you mention:
Prioritize performance over ease-of-use, which is not the right default for Task.init.
I think Task.startSynchronously will be a very commonly used API. I do not think it will be more common than Task.init with its current semantics.