SE-0298: Async/Await: Sequences

Arguably, in your code (and mine before) you have published a self reference from deinit. When trying to print it (after it escaped deinit), you then hit a bad pointer (EXC_BAD_ACCESS on 0x1c) because it's a use after free. If you re-run your program in a loop, I wouldn't be surprised if the exact output changes.

As you said, we could (should!) catch this. The condition to catch isn't "retain a dying object" (that's legal) but that the reference count at the end of deinit is > 0 which clearly indicates that we've published self.

Said that, as Chris says ...

... this isn't specific to this proposal. The reason I bring it up is that I think relying on deinit may cause self publishing issues more frequently.

Example: [It hurts to type this]

class DBCursor: AsyncIteratorProtocol /* assuming no `cancel` at all */ {
    func tearDownCursor() async throws {
        // ...
    }
   
    deinit { // oops, and causes a UaF or a crash (once the Swift bug is fixed)
        _ = Task.runDetached {
            try? await self.tearDownCursor() // <- attempted self publish in `deinit`
       }
    }

Sure, the code is awful but could we really blame somebody for writing it if they're just given func tearDownCursor() async throws and deinit?


Yes, you are right. I would indeed the happiest with func cancel() async throws. If we can't get this for efficiency/complexity reasons, I am okay with having the synchronous func cancel(). At least I still know how to do my job being given a func cancel. Without any explicit cancel I really don't know if we can get everything to work.

Regarding the synchronous cancel: I have shown an example of async teardown with just a sync cancel at the end of this post ("Details" at the very end).

Also my bad example from above would totally work with a synchronous cancel. Yes, not how structured concurrency is meant (and likely inefficient). But hey, contrary to putting the code in deinit it would actually work:

class DBCursor: AsyncIterator /* with sync cancel, as proposed */ {
    func tearDownCursor() async throws {
        // ...
    }
   
    func cancel() { // works, not ideal (and allocates for the closure)
        _ = Task.runDetached {
            try? await self.tearDownCursor()
       }
    }

Obviously as you note, an async cancel would make everything ...

class DBCursor: AsyncIteratorProtocol /* with async cancel */ {
    func tearDownCursor() async throws {
        // ...
    }
   
    func cancel() async throws {
        try await self.tearDownCursor()
    }
}

... so much nicer indeed (but yes, does need somebody/something to call the cancel).


Yes, having the DBConnection own all the cursors' resources does work, we're essentially pushing the cleanup responsibility for the cursor up one layer (from cursor to connection).

There is one important ownership question though: DBCursor has to hold a strong reference to DBConnection because otherwise for row in makeDBConnection().request("SELECT foo FROM bar").makeCursor() wouldn't work because the DBConnection would no longer be directly referenced.

Essentially, the cursor has to retain the connection. But what do we do if the last cursor deinits? If we don't take any precautions, then DBConnection.deinit would now be invoked and also needs to finish synchronously (but still needs to communicate with the DB to tear down the cursor, which is async).

To work around this, we could have the connection create a (temporary) retain cycle with itself once it starts tearing down a cursor. And when it's done tearing down the cursor, it'd break that cycle [alternative: keep self alive in an async func]. That however leads to the next problem: If we're using also using ARC to manage the connection, the connection now needs to (asynchronously) tear itself down in the synchronous deinit. Yes, we can play the whole game again and say that the connection teardown is owned by a NetworkingSystem and DBConnection.deinit would just send a message to NetworkingSystem to tear the connection down. And then NetworkingSystem needs a tear down at some point... This feels like we're always pushing the responsibility for the cleanup up by one layer until we have no more layers.

Maybe we can make this "push the cleanup responsibility up" design work somehow but it seems very complex and error-prone. Also it does feel a little backwards to me given that DBCursor will need to be a class anyway (for the deinit if we don't have cancel).


Just in case I misunderstood your DBCursor sends message/sync calls DBConnection to tear down. In the "Details" I have a code sketch how i understood both options:

1. `DBConnection` as actor
class DBCursor {
    // assuming no `cancel`

    let connectionActor: DBConnectionActor
    let myCursorID = ... // need a unique ID here, can't use `self` or
                         // ObjectIdentifier(self) because needs to stay valid after
                         // `self` is deallocated. Likely provided in `init`
                         // from the `DBConnection`.

    deinit {
        // pull two variables out of `self` so we won't need `self` in
        // detached task.
        let connectionActor = self.connectionActor
        let myCursorID = self.myCursorID

        _ = Task.runDetached {
            try await connectionActor.finishCursor(myCursorID)
        }
    }
}
  1. as "internally synchronised" class
class DBCursor {
    // assuming no `cancel`

    let connection: DBConnection
    let myCursorID = ... // need a unique ID here, can't use `self` or
                         // ObjectIdentifier(self) because needs to stay valid after
                         // `self` is deallocated. Likely provided in `init`
                         // from the `DBConnection`.

    deinit {
        // problem here is: `DBConnection` needs to make sure it doesn't
        // get `deinit`ed straight after this call.
        self.connection.finishCursor(self.myCursorID)
    }
}

I've been following the conversation here (and elsewhere) about cancel and feel like a lot of good points have been raised. @Philippe_Hausler and I are going to discuss this and get back to this thread with a follow-up.

13 Likes

I've read the proposal and the discussion so far.

Essentially looks reasonable and whichever way it goes on cancel I can live with it.

The question I would have regarding the cancel debate is whether it needs to be in protocol or that those sequences which need a specific cancellation call if they are being interrupted can add it themselves (deciding whether or not it should be async or if there should be any options for how urgently the cancel should happen).

It may be that the requirement within certain projects are that every AsyncSequence should be written to conform to an additional protocol that includes a cancel of a certain form but there may be many projects and cases where it is unnecessary.

It seems like the alternatives are an async cancel() or leveraging deinit (and possibly accelerate deinit on structs.

However, it does appear that asynchronous tear-down may be needed. If we use deinit, would this require dispatching cleanup work to another actor (such as that which the iterator was created from)? Or would there be a way to do this with a detached Task?

Could you please rebuild the async-experimentation toolchain now that [SR-14046] Assertion failure: Forming a CanType out of a non-canonical type in createTuple · Issue #56437 · apple/swift · GitHub has been fixed?

Thank you so much everyone for your feedback on our proposal. We are grateful that you are all taking time to share your experiences and humbled that so many of you believe this is a great addition to the Swift language.

Based on the feedback here and elsewhere, we've updated the proposal to remove the explicit cancel call. There are two main reasons:

  1. Reducing the requirements of implementing AsyncIteratorProtocol makes it simpler to use and easier to understand. The rules about when cancel would be called, while straightforward, would nevertheless be one additional thing for Swift developers to learn.
  2. The structured concurrency proposal already includes a definition of cancellation that works well for AsyncSequence. We should consider the overall behavior of cancellation for asynchronous code as one concept.

We also took the opportunity to fix a few small naming issues in the proposal.

An additional thought, not included in the 'alternatives considered' because it is more speculative: the existence of Task.checkCancellation should also provide those that need async cleanup on cancellation or completion a path to do so (in async next). Those implementations that do not need async cleanup can still do so in next() or in deinit.

12 Likes

With removing the requirement for cancel there are a few interesting opportunities that we should consider:

extension Task.Group: AsyncSequence, AsyncIteratorProtocol {
    public typealias Element = TaskResult
    public func makeAsyncIterator() -> Task.Group<TaskResult> {
        return self
    }
}

To reference Doug's structured concurrency document.

func chopVegetables() async throws -> [Vegetable] {
  // Create a task group where each task produces (Int, Vegetable).
  try await Task.withGroup(resultType: (Int, Vegetable).self) { group in 
    var veggies: [Vegetable] = gatherRawVeggies()
    // Create a new child task for each vegetable that needs to be 
    // chopped.
    for i in veggies.indices {
      await group.add { 
        (i, veggies[i].chopped())
      }
    }
    // Wait for all of the chopping to complete, slotting each result
    // into its place in the array as it becomes available.
    for try await (index, choppedVeggie) in group {
      veggies[index] = choppedVeggie
    }
    return veggies
  }
}

func cookedVegetables() async throws -> [CookedVegetable] {
  // Create a task group where each task produces (CookedVegetable).
  try await Task.withGroup(resultType: (CookedVegetable).self) { group in 
    // Create a new child task for each vegetable that needs to be 
    // cooked.
    for veggie in gatherRawVeggies() {
      await group.add { 
        veggie.cooked()
      }
    }
    // Wait for all of the cooking to complete by using the extensions to AsyncSequence
    return try await group.collect()
  }
}
5 Likes

Hi all!

This proposal has been accepted with modifications. Please see the decision notes in the acceptance post for details on the modifications. Thank you!

Doug

4 Likes