Can't use Atomics for synchronization between two tasks in Swift 6 mode

import Synchronization

@main
struct Application {
    static func main() async {
        let counter = Atomic<Int>(0)

        await withTaskGroup(of: Void.self) { group in
            group.addTask {
                for _ in 0..<100 {
                    counter.add(1, ordering: .relaxed)
                }
            }

            group.addTask {
                for _ in 0..<100 {
                    counter.subtract(1, ordering: .relaxed)
                }
            }
        }
    }
}

Main actor-isolated value of type '() async -> Void' passed as a strongly transferred parameter; later accesses could race

I fail to see how the code is invalid/unsafe/non-deterministic?

1 Like

The error message in this case is misleading. The issue is actually that `Atomic is non-copyable.

My understanding of what is happening is as follows, hopefully someone can correct me if I'm wrong.

TaskGroup.addTask has to take an escaping closure. The design of task groups ensures that the task closures end before withTaskGroup returns but currently there is no way to specify lifetimes for @escaping closures so the compiler has to assume the closures passed to addTask could live indefinitely.

Since counter is a local variable, this means it would need to be copied to be used from multiple @escaping closures to ensure it still exists when the original goes out of scope.
But since Atomic is non-copyable that isn't possible so this doesn't compile.

If counter is defined as a global, this compiles as then counter has the same ("static") lifetime as escaping closures so the compiler can instead borrow counter instead of having to copy it.
If the lifetime of the task closures was bound to the local TaskGroup then the same would be allowed for the local variable but that isn't currently possible to express.

Your best solution is likely to box counter in a class to make it copyable.

import Synchronization

final class Wrapper: Sendable {
    let counter = Atomic<Int>(0)
}

@main
struct Application {
    static func main() async {
        let wrapper = Wrapper()
        
        await withTaskGroup(of: Void.self) { group in
            group.addTask {
                for _ in 0..<100 {
                    wrapper.counter.add(1, ordering: .relaxed)
                }
            }
            
            group.addTask {
                for _ in 0..<100 {
                    wrapper.counter.subtract(1, ordering: .relaxed)
                }
            }
        }
    }
}

Depending on your use case you probably want to add the functionality operating on the atomic value to the class as well or add the atomic to an existing class.

3 Likes

Playing with this some more, the compiler actually auto-boxes non-copyable values when used with @escaping closures, as long as the value is never consumed.

This also works with @Sendable @escaping closures when the non-copyable value is itself Sendable so the compiler correctly deduces that the box and closure capturing the box can be Sendable if the boxed value is.

However, it doesn't seem to work for sending @escaping closures (like TaskGroup.addTask).
That might be a compiler limitation since, I think, Sendable values should always be able to be sent / fulfill a sending requirement.

If this restriction were lifted, I think your original example should be able to compile successfully.

4 Likes