Hello everyone,
Continuing the trend of a few proposals improving the usability of Swift concurrency, especially focusing on task execution semantics...
The proposed API allows for creating an unstructured task (or TaskGroup child task) such that it immediately starts running on the calling thread/actor/executor, and only executed asynchronously once it hits an actual suspension. This allows for initiating a call to asynchronous code from the context of a synchronous function, and more. A simple example might look like this (but please do read the whole proposal for a nuanced explanation):
For what it's worth I'm not so sure about the naming of the methods in this proposal, so if someone has good ideas especially about how to better name TaskGroup/startSynchronously and Task/startSynchronouslyDetached I'd love to hear them.
This is not yet implemented, however we'll be working on it shortly and will provide an early build to play around with as soon as possible.
Please leave any typo or editorial notes on the pull request itself, and use the forums thread for any review and detailed discussion of the proposal. Thank you!
I can't help but ask if we're giving an extra point of flexibility in the mechanism for running asynchronous tasks, why not extend the interface of the Job object (or something close to it) so it can be run synchronously? That is, essentially move __swift_run_job into the interface, and shift the responsibility for synchronous launch or scheduling to a TaskExecutor (and SerialExecutor) implementation. And then provide a wrapper implementation of TaskExecutor that runs the first Job synchronously, and schedules the rest to the underlying TaskExecutor.
The high level question here is reasonable -- e.g. in Akka and Scala this is done by a "parasitic execution context" (it sounds nasty to prevent people from using it... long story). So... Can we just make this an executor? Scala provides no compile time safety or trying to bind isolation guarantees to what parameter was passed somewhere.
It would have to be a specific serial executor, and the isolation of the closure would have to be the same as the passed executor... So... this is kindof asking for the future direction that we say is too complex to implement currently:
Regarding the Job idea -- I don't see this changing anything here...? a) Job is not user API, so that's off the table for that reason to begin with. b) You can write an executor which just runs the first job inline, in fact, we'll basically have to write such thing to service this job.
If we were to pursue this idea we'd need... have this work only with passing a specific actor, like (some ancient discussions may have seen that mentioned somewhere): Task(startingOn: actor) but that:
a) boils down to the same operations really (and the @isolated(to:) need, which we can't do right now)
b) goes against the language direction of "specify the isolation of the closure" that closure isolation control is all about Closure isolation control
So we've been trying to lean into this "closure isolation control" proposal, and the fact that we are not able to express the API we'd actually want: @isolated(to: someParameter).
If you have a more specific writeup that could be useful, because I don't think I was able to fish out something actionable so far I could have missed your point perhaps as well, examples would be great - thank you!
edit: I wonder if the startingOn could make a comeback... maybe it would then be addTask(startingOn: #isolation) but we hit the @isolated(to:) problem sadly then...
If @isolated(to: isolation) is not yet available, how does compile-time checking even work? Is there some new underscored attribute?
Regarding this example:
actor Caplin {
var num: Int = 0
func check() {
Task.startSynchronously {
num += 1 // could be ok; we know we're synchronously executing on caller
try await Task.sleep(for: .seconds(1))
num += 1 // not ok anymore; we're not on the caller context anymore
}
num += 1 // always ok
}
}
Why is the second increment not ok? Aren't we hopping back to the actor after await?
Since this is in the "Future directions", does that mean that under current proposal even the first increment is not ok?
With the problem that doSomeSetup() is called synchronously in the original case, and therefore guaranteed to have happened before doSomethingThatReliesOnSetup(), whereas in the async case, there's no guarantee.
Been a while since I've come across it, but I think it's a good tool to have available.
This pitch is much appreciated! In order to make testing async code more reliable we have had to resort to workarounds of varying effectiveness and flakiness, like inserting Task.yields and sleeps, or instrumenting code with extra continuations, just to wait for an async unit of work to "begin" before proceeding.
Is it possible to write a helper that abstracts around Task.startSynchronously and Task.init with the isolation limitations of Task.startSynchronously? I'm wondering if it's possible to limit synchronous scheduling to some testing tools while allowing code to be scheduled normally outside of tests.
I've found myself wanting to reach for the SPI version of this primitive numerous times in the past (the previous thread has some great motivating examples) so I'm very excited to see this finally being pitched as an official API.
Regarding the name, my only concern is that using the term "synchronously" might make people think that the API blocks for the entire duration of task execution. Another option might be startImmediately but I'm not sure if that's as descriptive (though it's worth noting that the proposal draft does use the word "immediately" 6 times!)
That makes me think these could be referred to as "immediate tasks", from which the logical API names would be Task.immediate { ... } and Task.immediateDetached { ... }[1].
Not "immediately detached," mind you, but "immediate, detached." ↩︎
I love this! It’s something I’ve wanted from the very beginning of swift concurrency and I think the design you’ve laid out makes the most sense.
A couple of clarifying questions:
Will this work with async sequences? That’s been one of the biggest limitations moving from Combine is that with a publisher, if there’s a value available immediately sink will get called right away, but converting that to an async sequence will force you to create a task and delay the first event. I think this would work with the proposal but want to make sure.
How will nonisolated tasks and methods work? In theory a nonisolated task should be able to be started synchronously from any context, and a nonisolated async method should be able to run synchronously if it never suspends, but I wasn’t sure from the wording.
As for the name, Task.immediate makes more sense to me.
This would be an incredibly welcome addition! I have also reached for the SPI version of this several times, but having it generalized to start on the caller context is also welcome.
My one request: would we possibly be able to have a deprecated back-deployable version using the existing SPI for the main actor use case? This behavior exists for the main actor and I suspect even a main actor-specific version of this would be welcome.
the function from which you call Task.startSynchronously
since it's an unstructured task, if you never await task.value the outer task never suspends at all
the task inside Task.startSynchronously
if that task needs to suspend, that task will suspend but the outer function just continues synchronously running
at this point you may or may not hit point 1.1. from this list -- if you, yourself, suspend the outer task/function then yeah it'd suspend, but if you don't then it would not.
So this doesn't cause any suspension to the outer task.
It does in the TaskGroup version though, because the await on the results of a task group is always implicitly inserted. Although if you used a discarding task group... and at the end of a scope the group is empty, then again that would not cause an actual suspension.
Hope this helps a bit, it's all about following the details when a suspension is actually triggered and in which task.