So doesn't that mean that the examples in this proposal, namely await noSuspension()
in the Proposed Solution example, can change behavior regardless of whether the task is started immediately? If so, the proposal should really call out the ways in which this behavior isn't guaranteed.
We're being very clear about it IMHO, explicitly explaining "if" and "assume it did suspend" in the description of that snippet:
If a potential suspension point does not actually suspend, we still continue running on the calling context. For example, if potential suspension point
#1
did not suspend, we still continue running synchronously until we reach potential suspension point#2
which for the sake of discussion let's say does suspend. At this point the calling thread continues executing the scope that created the unstructured task.
I doubt that's the right way to put it. There is suspension. It's just that it's quick. I think your example shows that async
call causes way less delay than Task
scheduling does. However, the reason is probably not general, but specific to your example. I suspect there are a lot of UI operations performed by SwiftUI framework in MainActor
on app start-up. That's why it takes a noticeable delay before Task
body runs.
Yes, sorry, I got caught up in the example.
That leads me to exactly what @rayx just brought up; why does this fix the SwiftUI issue?
Given what I've learned above, I think @kabiroberai's reasoning is correct, though I don't think it can be guaranteed. Ultimately I think it's due to API
being @MainActor
, which is fairly unrealistic. In this case, startSynchronously
can take advantage of the async
call not immediately suspending, since it's staying on the main actor (seems like this would be nearly guaranteed, though it's technically not (yet?)). So really the only difference is that the Task
isn't enqueued, as the await'd work should be the same. So I think it's enough to say that running even one cycle of the runloop later will cause the view flash. In the more realistic case where API
isn't @MainActor
(until that's the default?), this likely wouldn't fix the issue, as the Task
would still have to be enqueued to handle the suspension. (Hopefully that's all correct.)
This all makes sense and is very helpful for you to call out, so thanks for writing out an explanation. A name like Task.startEagerly
or Task.runEagerly
would be more fitting than Task.startSynchronously
, in my opinion, given the described behavior.
Would it make sense for the proposed Task.startSynchronously
to still result in the new Task
running sooner than a regular Task { }
call would, even if it can't start synchronously? One could imagine Task.startSynchronously
making the new Task "jump the line" on a serial executor:
Given a serial, global actor Alpha
:
Alpha is currently running: A
Next tasks that Alpha
will run, in order: [B, C]
Code in a non-isolated method runs Task.startSynchronously { @Alpha in beta() }
Alpha is currently running, updated: A (unchanged)
Next tasks that Alpha will run, in order, updated: [D, B, C]
I think there is a case for this sort of "Synchronously if possible; ASAP otherwise" being the behavior for this method. Even if that's only best-effort, e.g. an actor using a DispatchQueue based executor probably won't support new tasks jumping the line.
In my opinion this is taking it too far and is not the goal of this proposal. We specifically want to avoid an enqueue entirely (if possible), and not allow callers to intrusively change execution of the target. It is not a goal to give tools to completely ignore task order and just make a random task jump the queue entirely. We have task priority for scheduling high priority tasks "earlier".
For example, just naively jumping in front of other tasks would violate priority ordering and be rather odd to just jump the line intrusively like this IMHO. Such task D should arguably NOT run before B if B was higher priority for example. So such semantics would have to be much more involved and take into account priority and how default actors process their queues as well.
And overall, I'm not convienced that giving callers such control ofer an actor's execution is a good idea; it seem rather backwards and could devolve to everyone just always using this because "MY" task is the important one. So if all get to jump the line... no-one does, since they're all preceeding eachother -- it just feels messy and a waste of reshuffling tasks.
To reiterate the goal of this proposal: it is to avoid scheduling overhead when possible, which happens to have the effect of running a portion of a task immediately.
I've read proposal and understand the issue and +1, but I can see where confusion coming with naming and etc.—it could be overwhelming for people to get in right context.
Was thinking about more elegant solution for over a week now, only came up with Task.inlineContext
and Task.startOnCaller
, don't like it though. We still have some time, maybe we can come up with something collectively, but so far like all other options less than Task.startSynchronously
.
I’d just like to throw in my +1 on this and make a naming suggestion, along the line of @kabiroberai’s earlier in the thread:
Our family of names for spawning unstructured tasks today are Task
and Task.detached
. These are both noun phrases, and I think based on the number of times the words immediate and immediately already appear in this thread, a good noun phrase to join this family would be Task.immediate
.
It's wordy, but IMO startSynchronouslyIfAble
or similar captures the semantics well. I do think that "start" and "synchronous" are both load-bearing parts of the name here. We're making a pretty subtle and technical promise about the execution semantics here and I think it's worth being precise with our language. If we really wanted to parallel Task.detached
then I suppose Task.startingSynchronouslyIfAble
or Task.startedSynchronouslyIfAble
would be fine, but it's already a bit unwieldy so I'm kind of reluctant to further contrive the grammar.
I suggested Task.immediate
in the pitch thread, and still think it'd be the best name for this.
This sounds about right per my understanding, and fwiw I intentionally marked API
as @MainActor
to keep the proof-of-concept simple. That said, I don't think the demo is as unrealistic as it may seem for two reasons:
- In a large number of cases, you'll probably be interacting with an Observable ViewModel, so if I were to rename
API
to@MainActor @Observable final class APIViewModel
it would look a lot more idiomatic and likely. - Even if we were using a non-MainActor
API
, we could achieve the same result by inheriting the caller's isolation. That is,
func loadName(isolation: isolated (any Actor)? = #isolation) async -> String
This is pretty verbose today but that's why I'm looking forward to SE-0461 which makes this the default behavior.
The one thing that gives me pause is this comment
@ktoso am I right in understanding that the two cases I mentioned (1. calling a @MainActor
async func from another @MainActor
async func, and 2. calling a function that inherits #isolation
) are guaranteed to elide the scheduling overhead? That's the understanding I've had so far, and if so it would make it possible to use this API in ways that provide deterministic behavioral guarantees. If this is not the case, I'm unsure what behaviors the programmer can rely on this API providing, if any, save for what's effectively a syntactic transformation of factoring out explicitly synchronous code from the prefix of a closure.
Since the proposal author has indicated that they are open to the requested behavior change from @hborla, and since both have acknowledged that the proposed name Task.startSynchronously
is not ideal to describe the new behavior if it were accepted, I'd like make an explicit call-out to solicit potential new names for this API (and some folks have already made suggestions above). If anyone else has suggestions, please post them and your reasonings behind them.
Note that this is not a statement of acceptance yet for any part of the proposal; the review period is still open for two more days (April 10). Rather, the Language Steering Group wishes to collect as much feedback and options as possible to drive the discussion should we need to rename this API.
Thanks,
—Tony Allevato
Review manager
Kotlin has a CoroutineStart.UNDISPATCHED
option on launch
that seems to do the same thing:
Perhaps it can serve as inspiration.
Yes both those are examples of “staying in the same isolation (actor)” and therefore no suspensions take place in such calls.
By the “in general” I meant the very general case of two arbitrary contexts, I should have specified more concretely that there exist specific cases where we can know for sure.
Also depending on the default nonisolated async func isolation mode — accepted in [Accepted with modifications and focused re-review] SE-0461: Run nonisolated async functions on the caller's actor by default — calling a nonisolated async function under the caller isolation mode would also not cause suspensions but just keep running on the caller. But that’s dependent on which execution semantics a function has
The isolation rules for the
startSynchronously
family of APIs need to account for this synchronous "first part" of the execution. We propose the following set of rules to make this API concurrency-safe:
- The operation closure is
sending
.- The operation closure may only specify an isolation (e.g.
{ @MainActor in }
), if and only if already statically contained within the same isolation context.
Are these special isolation rules described in the proposal encoded in the declaration of the proposed new methods?
Use of@_inheritActorContext
in the existing Task
initializers has been a source of significant confusion among folks I've worked with who are learning Swift concurrency, because the attribute is both 1) fundamental to the behavior of the APIs and 2) invisible in documentation (by virtue of being underscored). I'm wondering if these new methods will have the same challenge.
Writing a function which accepts a closure to be forwarded to an underlying task creation is an interesting exercise in this vein. Would the proposed isolation semantics allow composition in such a way that the below method is implementable as though the underlying Task
were created inline in the caller?
// What's the correct declaration for `work` to be invoked as though
// the underlying Task were created inline in the caller?
func scheduleWork(_ work: () -> Void) { // (Intentionally wrong)
// <do some setup first>
// ...
Task.startSynchronously {
work()
}
}
I also like using immediate/immediately as a name for the behavior proposed. I'm not opposed to synchronously, but immediate is more descriptive of the way I would use this as an app developer, and has less overhead of figuring out what synchronously means in this context.
However if this ends up pivoting to allow passing in isolation then I'm not in favor of immediate (or synchronous). There would bo longer be the guarantee that the first line runs immediately, which is what I would expect from those names.
Thought experiment:
Shouldn't this "run immediately" behavior be the default?
To me, it is not so dissimilar from the "nonisolated async func must always hop off an actor" OG idea that is now (thank goodness) amended with "inherit by default" in SE-0461.
So, what if,
Task {
// this stays in-line by default
// just like ".immedeately" or however it is spelled
await actualSuspension()
// now we are in "task-land"
}
is the default, and if you need to "defer" execution, there could be a
Task.defer { }
// or
Task.schedule { }
// or
Task.async { }
or something similar? Feels like an easier time naming to me.
One could argue "ship has sailed" - but looking at the behavior change that was pulled off with SE-0461, nothing seems impossible ; )
So, the question is: Is the current Task { }
behavior (of always off-loading/deferring) actually the correct default?
I would go as far as saying: maybe not?
Exactly my thoughts after reading the thread and SE-0461
Was thinking the same. I might be missing something obvious, but in a world of Task.startSynchronously
, in what situations is the default behavior still desirable?
I think there's an interesting discussion to be had about whether, in the abstract, it would have been a reasonable default for Task.init
to start running the new task synchronously. However, there's not really any point in having that discussion here, because there is no viable path for changing that behavior now. Making the new task start running synchronously would be very semantics-breaking. There are lots of ways that existing Swift programs might be relying on the new task being scheduled to run later. Doing so would create re-entrancy where none exists today, potentially leading to all sorts of surprising behavior and possibly even deadlocks.
SE-0461 does not have the same problem; everything still runs in the exact same order from the perspective of the task.