swiftswift-concurrencyswift6

Unexpected Concurrency Error in Swift TaskGroup


The Swift 6 compiler emits the following error

Sending value of non-Sendable type '() async throws -> ()' risks causing data races

when explicitly specifying an actor as the isolation for the closure in the addTask function of the task group:

func test() async throws {
    try await withThrowingTaskGroup { group in
        group.addTask { @MainActor in                    // <== Error
            try await Task.sleep(nanoseconds: 1_000_000)
        }
        try await group.waitForAll()
    }
}

I don't see why the closure should not be sendable. Why is the compiler considering the given closure as not sendable?

Info: the signature of addTask is

mutating func addTask(
    priority: TaskPriority? = nil,
    operation: sending @escaping @isolated(any) () async throws -> ChildTaskResult
)

What I'm trying to do is the following (where the compiler issues the same error):

@Sendable
func requiresIsolation(isolated: isolated any Actor = #isolation) async throws {}

(this function is sendable, so @Sendable is redundant and just for emphasising the fact)

and then:

func test() async throws {
    try await withThrowingTaskGroup { group in
        group.addTask { @SomeActor in
            try await requiresIsolation()
        }
        try await group.waitForAll()
    }
}

Update:

My suspicion is, that the closure needs an explicit @Sendable annotation.

The following code makes the compiler happy:

func test() async throws {
    try await withThrowingTaskGroup { group in
        group.addTask { @Sendable @MainActor in   // <== add `@Sendable`
            try await requiresIsolation()
        }
        try await group.waitForAll()
    }
}

Still, I'm a bit unsure whether this is correct.


Solution

  • Adding @Sendable is a correct solution to this. Swift simply fails to infer the sendability of the closure because the expected type is sending, not a @Sendable closure. See the SE proposal for when a closure is inferred to be @Sendable:

    A closure expression is inferred to be @Sendable if either:

    • it is used in a context that expects a @Sendable function type or
    • @Sendable is in the closure's in specification.

    addTask does not expect a @Sendable () async throws -> Void, but a sending () async throws -> Void.

    If addTask had been (and this is a stronger requirement than sending):

    func addTask(
        priority: TaskPriority? = nil,
        operation: @Sendable @escaping @isolated(any) () async throws -> Void
    )
    

    then the closure will be inferred to be Sendable.

    In fact, all actor-isolated async closures are Sendable, since the caller must await to call it, and so an actor hop can be performed.

    The problem with { @MainActor in try await ... } is that the compiler does not infer the type of the closure as a @MainActor () async throws -> Void, but just a value of type () async throws -> Void with its isolation region being the main actor. This isolation region is what's stopping you from sending it, as per region-based isolation rules.

    If you specify its type to be the desired actor-isolated type, it becomes sendable:

    let c: @MainActor () async throws -> Void = { /*@MainActor here is redundant*/
        try await Task.sleep(nanoseconds: 1_000_000)
    }
    let d: any Sendable = c // no error here!
    group.addTask(operation: c) // no error here!