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?
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”:
Next, the first problem with your code snippet at this point is really that Tester
is not Sendable
:
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
:
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!
}
}