despite having used swift concurrency every day for nearly two years, i still often feel like it is weirdly hard to actually use “structured concurrency”, and i’m still dropping down to unstructured Tasks more often than i would like.
in my mind, one of the biggest reasons why i am still using unstructured Tasks is because there is no way to cancel an async let task on purpose. the best we can do is either
explicitly await on its result on an unreachable code path after creating it, or
tolerate the unused variable compiler warning
and in either case, the task cannot be cancelled until scope exit, which is often much later than i would actually like to cancel the task.
i think the most straightforward solution to this problem would be to introduce a new operator cancel that can cancel an async let.
async
let service1:Void = a.start()
async
let service2:Void = b.start()
await self.subscribe(a, b)
cancel service1
cancel service2
we would not want to conflate cancellation of the producer with consume of the value. in particular, we may want to await on the result of the cancelled task.
W.r.t. to explicit cancellation of child task ins task groups I have been thinking if we could return a new ChildTask type from group.addTask() which we could use to cancel and await the value. However the ChildTask should probably not be sendable so that it doesn’t escape the task groups closure.
Instead of a new keyword I think we can make async let into a sort of property-wrapper like syntax. I’m not sure what the current implementation looks like but we could add a $myValue property to act as a handle.
func f() async -> Int {
async let myValue: Int = getAsyncValue()
$myValue.cancel()
return await myValue
}
// Becomes
func f() async -> Int {
// Compiler generated
let _value = AsyncBinding(getAsyncValue())
var $value: AsyncBinding.Handle<Int> { _value.projectedValue }
var value: async Int { await _value.wrappedValue }
defer { $value.cancel() }
// ————————————————————
$value.cancel()
return await value
}
I don't think it has to be that strict because it does not really matter if it can be captured and escape. The task group cannot resume until all child tasks are finished. After a child task is finished it will be only a Result like value. In other words a ChildTask can nearly have the identical API surface as an unstructured Task and it has a safe postcondition in regards to its parent group.
If there is any other technical or possibly some thread related issue, then sure we could make it non-sendable.
Structured concurrency isn't magic. Even structured concurrency tasks have to register some records under the hood. A property wrapper looks like a very convenient solution. I wonder if or why not it wasn't explored during the initial proposal.
However, the reason why what I showed above wouldn’t work is because of implicit awaiting. The defer block would have to somehow await after the cancellation to make sure the task is finished. If we were to create a property-wrapper implementation, we’d need other features, such as async deinitializers (to do the awaiting) and more advanced ownership (such as non-escapable types, to force a deinit at the end of scope).
I see. Now that I think about it. Several features that have PW like syntax are better suited with macros. Maybe this would be yet another great opportunity for macros to shine.
IIRC the child task for a task group also creates and registers a native object under the hood. It's not returned or used elsewhere, but it could be wrapped by some simple struct to expose some API surface for explicit use.
i think @filip-sakel was saying that property wrappers cannot be used to implement something like async let, because async let is implicitly awaited upon on scope exit.
I understand. I view async let just as a fancy task group like syntax sugar. If you dig through those APIs the exposure of something explicit does't seem that hard. The API surface is just not implemented. I'm all for this to happen ASAP. I had recently to nest another task group with an async channel to create a pseudo cancellable child task. It works, but it's really awkward.
The only problem with macros is that they only apply to the local syntax tree. In other words, they may be able to generate multiple properties (as property wrappers do) but the cancellation logic needs to go at the end of scope (which requires knowledge of the entire function’s syntax tree). That’s why I think ownership is still a promising way to offer a more streamlined implementation of this syntactic sugar.
However, coming back to async-let cancellation, it’s reasonable to simply synthesize a $-prefixed property (e.g. $myValue). This synthesis can be hardcoded into the compiler like the rest of the async let implementation. I also don’t see any source compatibility issues with this approach.