Confused with actor isolation with ThrowingTaskGroup in Swift 6

I'm trying to simulate DiscardingThrowingTaskgGroup using ThrowingTaskgGroup and actor isolation.

And I end up with Concurrency Compiler error that looks odd to me.

This code does compile and ThrowingTaskGroup is protected by actor isolation.

/// Empty actor to isolate `ThrowingTaskGroup` to simulate DiscardingTaskGroup
@usableFromInline
actor SafetyRegion {
    
    private var isFinished = false
    private var continuation: UnsafeContinuation<Void,Never>? = nil
    
    @usableFromInline
    init() {
        
    }
    
    @usableFromInline
    func markDone() {
        guard !isFinished else { return }
        isFinished = true
        continuation?.resume()
        continuation = nil
    }
    
    @usableFromInline
    func hold() async {
        await withUnsafeContinuation {
            if isFinished {
                $0.resume()
            } else {
                if let old = self.continuation {
                    assertionFailure("received suspend more than once!")
                    old.resume()
                }
                self.continuation = $0
            }
        }
    }
    
}


extension ThrowingTaskGroup where ChildTaskResult == Void, Failure == any Error {
    
    /// work around for simulating Discarding TaskGroup
    ///
    ///  TaskGroup is protected by the actor isolation
    ///  - important: always call TaskGroup api while holding isolation
    @usableFromInline
    internal mutating func simulateDiscarding(
        isolation actor: isolated (SafetyRegion),
        body: (isolated SafetyRegion, inout Self) async throws -> Void
    ) async throws {
        addTask {
            /// keep at least one child task alive
            /// so that subTask won't return
            await actor.hold()
        }
        /// drain all the finished or failed Task
        async let subTask:Void = {
            while let _ = try await next(isolation: actor) {
            }
        }()
        // wrap with do block so that `defer` pops before waiting subTask
        do {
            /// release suspending Task
            defer { actor.markDone() }
            /// wrap the mutable TaskGroup with actor isolation
            try await body(actor, &self)
        }
        try await subTask
    }
    
}

However, below two code are not compiled on swift 6.

func simulateDiscardingThrowingTaskGroup<T:Actor>(
    isolation actor: isolated T,
    body: (isolated T, inout ThrowingTaskGroup<Void, any Error>) async throws -> Void
) async throws {
    try await withThrowingTaskGroup(of: Void.self) { group in
        let holder = SafetyRegion()
        group.addTask {
            await holder.hold()
        }
        async let drainTask:Void = {
/// Error:Sending 'group' risks causing data races
///'actor'-isolated 'group' is captured by a actor-isolated closure. actor-isolated uses in closure may race against later nonisolated uses
            while let _ = try await group.next(isolation: actor) {
                
            }
        }()
        do {
///Error:Sending 'body' risks causing data races
/// description: 'actor'-isolated 'body' is captured by a actor-isolated closure. actor-isolated uses in closure may race against later nonisolated uses
///Error: Sending 'group' risks causing data races
/// description: Sending 'actor'-isolated 'group' to nonisolated callee risks causing data races between nonisolated and 'actor'-isolated uses
            try await body(actor, &group)
            await holder.markDone()
        } catch {
            await holder.markDone()
            throw error
        }
        try await drainTask
    }
}

//Same error like above

extension Actor {
    
    func simuateDiscardingThrowingTaskGroup(
        _ body: (isolated Self, inout ThrowingTaskGroup<Void, any Error>) async throws -> Void
    ) async throws {
        try await withThrowingTaskGroup(of: Void.self) { group in
            let holder = SafetyRegion()
            group.addTask {
                await holder.hold()
            }
            async let drainTask:Void = {
/// Error:Sending 'group' risks causing data races
///'actor'-isolated 'group' is captured by a actor-isolated closure. actor-isolated uses in closure may race against later nonisolated uses
                while let _ = try await group.next(isolation: self) {
                    
                }
            }()
            do {
///Error:Sending 'body' risks causing data races
/// description: 'actor'-isolated 'body' is captured by a actor-isolated closure. actor-isolated uses in closure may race against later nonisolated uses
///Error: Sending 'group' risks causing data races
/// description: Sending 'actor'-isolated 'group' to nonisolated callee risks causing data races between nonisolated and 'actor'-isolated uses
                try await body(self, &group)
                await holder.markDone()
            } catch {
                await holder.markDone()
                throw error
            }
            try await drainTask
        }
    }
    
    
}

Are these code blocks, actually different?

  • Why does writing extension on ThrowignTaskGroup does not require Sendable body closure but, others does?
  • What is the possible data race problem that the compiler is detecting?

You can't do this:

try await withThrowingTaskGroup(of: Void.self) { group in
    async let drainTask:Void = {
/// Error:Sending 'group' risks causing data races
///'actor'-isolated 'group' is captured by a actor-isolated closure. actor-isolated uses in closure may race against later nonisolated uses
    while let _ = try await group.next(isolation: self) {}
}

the compiler is right to stop you. The task group is not Sendable, so you're escaping it into a child task that would run concurrently here -- and that's not okey.

The error is right... but the message actually seems a bit weird to me:

'actor'-isolated 'group' is captured by a actor-isolated closure. actor-isolated uses in closure may race against later nonisolated uses

edit: okey it seems on main we've fixed this and it properly says 'self'-isolated in the beginning, the 'actor'-isolated threw me off a bit. The warning is correct though, FYI @Michael_Gottesman but I think the message was already improved it seems.

The group is not intended to be consumed from multiple tasks. While it may "happen to work" (maybe? I hope it doesn't but it may by accident work) you should not write code which relies on this. But anyway, the compiler is properly preventing you from consuming the group from multiple tasks -- if you "sent" it to one, (the child task), you cannot be consuming it from the outside anymore so that's unsafe.

This is why the discarding group was developed -- because there's no "end user only" workaround for this. You either have to consume tasks in "lock step" with adding work, or do other sub-optimal consume patterns.

1 Like

oops, second block warning message is my mistake of pasting same text.

This is why the discarding group was developed -- because there's no "end user only" workaround for this. You either have to consume tasks in "lock step" with adding work, or do other sub-optimal consume patterns.

discarding group api is best choice but, it need iOS 17 which is quite recent version.
And thanks to new ThrowingTaskGroup back deployed API on swift 6, I think user level work around is possible (but not an ideal solution).

Binding ThrowingTaskgroup to the actor isolation. My original Post contains the work around with actor declaration (I think you missed this)
Anyway full implementation and interface is like below.

// interface to the userlevel workaround
func simuateDiscardingTaskGroup<T:Actor,TaskResult>(
    isolation actor: isolated T,
    body: @Sendable (isolated T, inout ThrowingTaskGroup<Void, any Error>) async throws -> sending TaskResult
) async throws -> TaskResult {
    try await withThrowingTaskGroup(of: Void.self, returning: TaskResult.self) {
        try await $0.simulateDiscarding(isolation: actor, body: body)
    }
}

// implementation of user level workaround
extension ThrowingTaskGroup where ChildTaskResult == Void, Failure == any Error {
    
    /// work around for simulating Discarding TaskGroup
    ///
    ///  TaskGroup is protected by the actor isolation
    ///  - important: always call TaskGroup api while holding isolation
    fileprivate mutating func simulateDiscarding<T:Actor, V>(
        isolation actor: isolated T,
        body: (isolated T, inout Self) async throws -> sending V
    ) async throws -> V {
        let holder: SafetyRegion = actor as? SafetyRegion ?? .init()
        if await holder.isFinished {
            preconditionFailure("SafetyRegion is already used!")
        }
        addTask {
            /// keep at least one child task alive
            /// so that subTask won't return
            await holder.hold()
        }
        /// drain all the finished or failed Task
        async let subTask:Void = {
            while let _ = try await next(isolation: actor) {
                if await holder.isFinished {
                    break
                }
            }
        }()
        let value:V
        do {
            /// wrap the mutable TaskGroup with actor isolation
            value = try await body(actor, &self)
            /// release suspending Task
            await holder.markDone()
        } catch {
            /// release suspending Task
            await holder.markDone()
            throw error
        }
        try await subTask
        return value
    }
    
}

Above code block is compiled and run successfully.
For my opinion, this implementation looks same as the other code blocks.
I just want to know the differences between all three code blocks.

//Edited: fix incorrect code

if await holder.isFinished {
            preconditionFailure("SafetyRegion is already used!")
}