Home > database >  Task @Sendable operation
Task @Sendable operation

Time:10-27

Writing a simple code:

class App {
    private var value = 0
    
    func start() async throws {
        await withTaskGroup(of: Void.self) { group in
            for _ in 1...100 {
                group.addTask(operation: self.increment) // 1
                
                group.addTask {
                    await self.increment() // 2
                }
                
                group.addTask {
                    self.value  = 1 // 3
                }
            }
        }
    }
    
    @Sendable private func increment() async {
        self.value  = 1 // 4
    }
}

I got compile time warnings at lines 2, 3: Capture of 'self' with non-sendable type 'App' in a @Sendable closure

However with enabled Thread Sanitizer, and removed lines 2 and 3, I got ThreadSanitizer runtime warning at line 4: Swift access race in (1) suspend resume partial function for asyncTest.App.increment@Sendable () async -> () at 0x106003c80

So I have questions:

  1. What is the difference between using these 3 .addTask ways?
  2. What does @Sendable attribute does?
  3. How can I make increment() function thread safe (data race free)?

CodePudding user response:

For illustration of how to achieve thread-safety, consider:

class Counter {
    private var value = 0

    func incrementManyTimes() async {
        await withTaskGroup(of: Void.self) { group in
            for _ in 1...1_000_000 {
                group.addTask {
                    self.increment()                                  // no `await` as this is not `async` method
                }
            }

            await group.waitForAll()

            if value != 1_000_000 {
                print("not thread-safe apparently; value =", value)   // not thread-safe apparently; value = 994098
            } else {
                print("ok")
            }
        }
    }

    private func increment() {                                        // note, this isn't `async` (as there is no `await` suspension point in here)
        value  = 1
    }
}

That illustrates that it is not thread-safe, validating the warning from TSAN. (Note, I bumped the iteration count to make it easier to manifest the symptoms of non-thread-safe code.)

So, how would you make it thread-safe? Use an actor:

actor Counter {
    private var value = 0

    func incrementManyTimes() async {
        await withTaskGroup(of: Void.self) { group in
            for _ in 1...1_000_000 {
                group.addTask {
                    await self.increment()
                }
            }

            await group.waitForAll()

            if value != 1_000_000 {
                print("not thread-safe apparently; value =", value)
            } else {
                print("ok")                                           // ok
            }
        }
    }

    private func increment() {                                        // note, this still isn't `async`
        value  = 1
    }
}

If you really want to use a class, add your own old-school synchronization. Here I am using a lock, but you could use a serial GCD queue, or whatever you want.

class Counter {
    private var value = 0
    let lock = NSLock()

    func incrementManyTimes() async {
        await withTaskGroup(of: Void.self) { group in
            for _ in 1...1_000_000 {
                group.addTask {
                    self.increment()
                }
            }

            await group.waitForAll()

            if value != 1_000_000 {
                print("not thread-safe apparently; value =", value)
            } else {
                print("ok")                                           // ok
            }
        }
    }

    private func increment() {
        lock.synchronize {
            value  = 1
        }
    }
}

extension NSLocking {
    func synchronize<T>(block: () throws -> T) rethrows -> T {
        lock()
        defer { unlock() }
        return try block()
    }
}

Or, if you want to make Counter a Sendable type, too, confident that you are properly doing the synchronization yourself, like above, you can declare it as final and declare that it is Sendable, admittedly @unchecked by the compiler:

final class Counter: @unchecked Sendable {
    private var value = 0
    let lock = NSLock()

    func incrementManyTimes() async {
        // same as above
    }

    private func increment() {
        lock.synchronize {
            value  = 1
        }
    }
}

But because the compiler cannot possibly reason about the actual “sendability” itself, you have to designate this as a @unchecked Sendable to let it know that you personally have verified it is really Sendable.

But actor is the preferred mechanism for ensuring thread-safety, eliminating the need for this custom synchronization logic.


For more information, see WWDC 2021 video Protect mutable state with Swift actors or 2022’s Eliminate data races using Swift Concurrency.

See Sendable documentation for a discussion of what the @Sendable attribute does.


BTW, I suspect you know this, but for the sake of future readers, while increment is fine for illustrative purposes, it is not a good candidate for parallelism. This parallelized rendition is actually slower than a simple, single-threaded solution. To achieve performance gains of parallelism, you need to have enough work on each thread to justify the modest overhead that parallelism entails.

Also, when testing parallelism, be wary of using the simulator, which significantly/artificially constrains the cooperative thread pool used by Swift concurrency. Test on macOS target, or a physical device. Neither the simulator nor a playground is a good testbed for this sort of parallelism exercise.

  • Related