Search code examples
iosswiftconcurrencytasksendable

Why are non-sendable properties mutable from Tasks in Swift


A lot of online resources say non-final classes are not Sendable by default. That means classes and their properties are not thread-safe. So why is this code not raising an error:

class Counter {
    var count = 0
    func increment() {
        count += 1
    }
}

class Tester {
    var counter = Counter()
    
    func mutate() {
        Task {
            counter.increment()
        }
    }
}

I was expecting an error to be raised at counter.increment() since counter isn't Sendable.

Can anyone tell why this code isn't raising an error?


Solution

  • You are not getting an error/warning because, as of Xcode 14.3, at least, the compiler will only perform “minimal” checks re Sendable. So, set the “Strict Concurrency Checking” setting to “Complete”:

    enter image description here

    Next, the first problem with your code snippet at this point is really that Tester is not Sendable:

    enter image description here

    But let’s assume that you get past that, either by making Tester a Sendable type or simply capture the Counter, rather than Tester. Then the compiler will report the Sendable problem, this time regarding Counter:

    enter image description here


    So, you asked:

    online resources say non-final classes are not Sendable by default

    Yes, classes are not Sendable by default.

    That means classes and their properties are not thread-safe.

    To be more precise, it just means that you have not informed the Swift concurrency system whether it is thread-safe or not. It might be. It might not be. (In this case, it is not.) You simply do not get Sendable inference for free, even if it was internally thread-safe, as you would with a struct or actor.

    Specifically, if a class is final and has no mutable properties, it is generally thread-safe, but is not automatically Sendable. But you can just add Sendable conformance to let the compiler know that it really is. However, if the class has some mutable properties and you want to mark it as Sendable, you have to manually make it thread-safe with some manual synchronization mechanism for its mutable properties and, then, when you are done, you can mark this class as @unchecked Sendable to let the compiler know that although the compiler cannot reasonable verify whether it is really Sendable or not, but that you are vouching for it.

    But, in short, your online resources are correct and classes are not Sendable by default. You just need to bump up the “Strict Concurrency Checking” setting in order to see all these Sendable-related warnings.


    While I suspect you know this, for the sake of other readers, we can demonstrate the lack of thread-safety with a unit test:

    import XCTest
    
    class Counter {
        var count = 0
    
        func increment() {
            count += 1
        }
    }
    
    final class MyAppTests: XCTestCase {
        func testThreadSafety() async throws {
            let iterations = 10_000_000
            let counter = Counter()
    
            await withTaskGroup(of: Void.self) { group in
                for _ in 0 ..< iterations {
                    group.addTask { counter.increment() }
                }
            }
    
            let count = counter.count
            XCTAssertEqual(count, iterations)     // testThreadSafety(): XCTAssertEqual failed: ("9957759") is not equal to ("10000000")
        }
    }
    

    Or turn on TSAN, and it will produce:

    ==================
    WARNING: ThreadSanitizer: Swift access race (pid=70115)
      Modifying access of Swift variable at 0x00010619a690 by thread T4:
        #0 (1) suspend resume partial function for closure #1 @Sendable () async -> () in closure #1 (inout Swift.TaskGroup<()>) async -> () in MyAppTests.MyAppTests.testThreadSafety() async throws -> () <null> (MyApp2Tests:arm64+0x1fc0) (BuildId: b3c1129a1404328b97d6c213879bfa2832000000200000000100000000010d00)
        #1 swift::runJobInEstablishedExecutorContext(swift::Job*) <null> (libswift_Concurrency.dylib:arm64e+0x404c0) (BuildId: a4a1be62f3953ce897049a710004921e32000000200000000100000000000b00)
    
      Previous write of size 1 at 0x00010619a690 by thread T1:
        #0 (1) suspend resume partial function for closure #1 @Sendable () async -> () in closure #1 (inout Swift.TaskGroup<()>) async -> () in MyApp2Tests.MyAppTests.testThreadSafety() async throws -> () <null> (MyApp2Tests:arm64+0x1fec) (BuildId: b3c1129a1404328b97d6c213879bfa2832000000200000000100000000010d00)
        #1 swift::runJobInEstablishedExecutorContext(swift::Job*) <null> (libswift_Concurrency.dylib:arm64e+0x404c0) (BuildId: a4a1be62f3953ce897049a710004921e32000000200000000100000000000b00)
    
      Location is heap block of size 24 at 0x00010619a680 allocated by thread T5:
        #0 __sanitizer_mz_malloc <null> (libclang_rt.tsan_osx_dynamic.dylib:arm64+0x5e244) (BuildId: b4e4be85d92a32d7a3e9edbe68feb69132000000200000000100000000000b00)
        #1 _malloc_zone_malloc_instrumented_or_legacy <null> (libsystem_malloc.dylib:arm64e+0x20c1c) (BuildId: fa535b0545933a7893d77bcff7431df632000000200000000100000000020d00)
        #2 swift::runJobInEstablishedExecutorContext(swift::Job*) <null> (libswift_Concurrency.dylib:arm64e+0x404c0) (BuildId: a4a1be62f3953ce897049a710004921e32000000200000000100000000000b00)
    
      Thread T4 (tid=18485890, running) is a GCD worker thread
    
      Thread T1 (tid=18485880, running) is a GCD worker thread
    
      Thread T5 (tid=18485893, running) is a GCD worker thread
    
    SUMMARY: ThreadSanitizer: Swift access race (/…/MyAppTests:arm64+0x1fc0) (BuildId: b3c1129a1404328b97d6c213879bfa2832000000200000000100000000010d00) in (1) suspend resume partial function for closure #1 @Sendable () async -> () in closure #1 (inout Swift.TaskGroup<()>) async -> () in MyAppTests.MyAppTests.testThreadSafety() async throws -> ()+0x60
    ==================
    

    But if you either add manual synchronization of the mutable variable, count, in Counter, or just make it an actor, the data race is resolved and it is thread-safe. E.g.:

    actor Counter {
        var count = 0
    
        func increment() {
            count += 1
        }
    }
    
    final class MyAppTests: XCTestCase {
        func testThreadSafety() async throws {
            let iterations = 10_000_000
            let counter = Counter()
    
            await withTaskGroup(of: Void.self) { group in
                for _ in 0 ..< iterations {
                    group.addTask { await counter.increment() }
                }
            }
    
            let count = await counter.count
            XCTAssertEqual(count, iterations)     // success!
        }
    }