Actor isolation and non-sendable closure (use higher-order function inside Actor)

404 views Asked by At

How to adjust forEachAsync for use within TestActor without a warning? It should stay reusable in other places.

I need TestActor to pass around non-sendable stuff within it. And I want to use higher-order convenience functions like forEachAsync within it. Execution is sequential on purpose.

Build settings -> Strict Concurrency Checking: Complete

class NotSendable { }

actor TestActor {
    func foo() async {
        // No problem
        for _ in 1...3 {
            await bar(NotSendable())
        }
        
        // Warning: Passing argument of non-sendable type '(Int) async -> ()'
        // outside of actor-isolated context may introduce data races
        await (1...3).forEachAsync { _ in
            await bar(NotSendable())
        }
    }
    
    func bar(_: NotSendable) async { }
}

extension Sequence {
    func forEachAsync(_ operation: (Element) async -> Void) async {
        for element in self {
            await operation(element)
        }
    }
}

2

There are 2 answers

5
Rob On

As of Swift 5.7, async functions not isolated to any actor will “formally run on a generic executor associated with no actor.” The rationale for this is outlined in SE-0338 – Clarify the Execution of Non-Actor-Isolated Async Functions.

So, in this case, the non-isolated async function, forEachAsync, will not run on TestActor, but rather on a generic executor, thus introducing a switch in concurrency context. The aforementioned Swift Evolution proposal acknowledges that the old behavior (of staying on the current actor, if any) was effective in “minimizing switches between executors”, but they concluded it was worth this cost for the reasons outlined in SE-0338.

Technically, you can achieve what you want with global actors, but it is ugly and largely defeats the purpose of the simple extension. You might be able to write a macro that does this expansion for you, but it is likely not worth it.

I know this is not the answer you are looking for, but the simplest solution is to just stick with the for-in loop. Or, if practical, revisit whether you might just make the object Sendable (and add the missing @Sendable qualifier to the closure parameter of that function).

5
John On

Update

SE-0420 – Inheritance of actor isolation has been accepted and implemented in Swift 6. I think it will work like this:

extension Sequence {
    func forEachAsync(
        isolation: isolated (any Actor)? = #isolation,
        _ operation: (Element) async -> Void
    ) async {
        for element in self {
            await operation(element)
        }
    }
}

Original Answer

I found this solution:

actor TestActor {
    func foo() async {
        await (1...3).forEachAsync(self) { _ in
            await bar(NotSendable())
        }
    }
    
    func bar(_: NotSendable) async { }
}

extension Sequence {
    func forEachAsync(
        _: isolated Actor,
        _ operation: (Element) async -> Void
    ) async where Element: Sendable {
        for element in self {
            await operation(element)
        }
    }
}

This gets rid of the warning. I'm not sure if it's actually safe. Unfortunately, the Element: Sendable requirement limits its usefulness.

Here's a better challenge that it doesn't pass:

func foo() async {
    let nonSendables = [NotSendable(), NotSendable()]

    // No problem
    for notSendable in nonSendables {
        await bar(notSendable)
    }

    // Warning: Type 'NotSendable' does not conform to the 'Sendable' protocol
    await nonSendables.forEachAsync(self) { notSendable in
        await bar(notSendable)
    }
}

By the way, I found this directly related Swift evolution proposal:
SE-0420 – Inheritance of actor isolation