Preventing regressions when conforming to `Sendable` with `@unchecked`

Hi @josephduffy, unfortunately writing a truly concurrency safe class is a bit harder than just locking each property. The class you have shared is "safe" to use from multiple threads in that it won't lead to data corruption, but it is not safe in the sense that it is free from race conditions. For example, a simple test where we increment a CustomClass object 10,000 times will fail:

@Test func raceCondition() async {
  let object = CustomClass()
  await withTaskGroup(of: Void.self) { group in 
    for _ in 1...10_000 {
      group.addTask { object.property += 1 }
    }
  }
  #expect(object.property == 10_000)  // ❌
}

This code compiles just fine in Swift 6 language mode, yet the test will not pass. This is because when doing object.property += 1 there are two steps (reading then setting), and each are individually locked. This allows multiple threads to interleave, causing a thread to set a new value in between another thread's read+set.

Locking on a per-property basis is a bit too granular. You should think of instead hiding the data in the class and only exposing more coarse operations that are each locked. For example, your class could have a withProperty method that gives one locked access to an inout integer so that you can do whatever operations you want with it all in a single locked region:

final class CustomClass: @unchecked Sendable {
  func withProperty(_ operation: (inout Int) -> Void) {
    propertyLock.lock()
    defer { propertyLock.unlock() }
    operation(&_property)
  }
  private var _property: Int = 0
  private var propertyLock = NSLock()
}

Now accessing the property can only be done in a single locked region, and so now this test will pass:

@Test func raceCondition() async {
  let object = CustomClass()
  await withTaskGroup(of: Void.self) { group in 
    for _ in 1...10_000 {
      group.addTask { object.withProperty { $0 += 1 } }
    }
  }
  #expect(object.property == 10_000)  // ✅
}

Swift 6 also now has a native Mutex type now that exemplifies this pattern (though it's only available on newer platforms). You can put a value in the mutex and the only way to retrieve the value is via a withLock { … } method. But, remember, simply wrapping each field of a class in a Mutex is not enough to make the class free from race conditions.

14 Likes