Prepitch: `cancel` operator

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

  1. explicitly await on its result on an unreachable code path after creating it, or
  2. 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

what do you think?

3 Likes

Isn't an async let task cancelled once the enclosing function returns? If so, shouldn't consume be able to do this?

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.

cancel service1
cancel service2

await service1
await service2
1 Like

Yes please, I would love something like this and also:

  • explicit cancellation for [throwing][Discarding]TaskGroup child tasks
  • and an async var which can override the binding or unbind it, or even bind it out of line

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.

2 Likes

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
}
4 Likes

Though, now that I’m thinking about it, the generated code would probably look a bit different since we’re talking about structured concurrency.

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. :thinking:

Some reasons are outlined in the proposal https://github.com/apple/swift-evolution/blob/main/proposals/0317-async-let.md#property-wrappers-instead-of-async-let.

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).

2 Likes

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.

2 Likes

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.

1 Like

Slightly contrived, but it is possible:

struct Problematic {
    @Binding var foo: String

    func bar() async -> Binding<String> {
        async let foo = ...
        return $foo
    }
}
1 Like

Oh how I love good old shadowing.

But yes, I don’t know if we need to wait until Swift 6, or if we can generate a warning in Swift 5 when accessing the async-let $foo.