swiftconcurrencygrand-central-dispatchsemaphore

Use queue and semaphore for concurrency and property wrapper?


I'm trying to create a thread-safe property wrapper. I could only think of GCD queues and semaphores as being the most Swifty and reliable way. Are semaphore's just more performant (if that's true), or is there another reason to use one over the other for concurrency?

Below are two variants of atomic property wrappers:

@propertyWrapper
struct Atomic<Value> {
    private var value: Value
    private let queue = DispatchQueue(label: "Atomic serial queue")

    var wrappedValue: Value {
        get { queue.sync { value } }
        set { queue.sync { value = newValue } }
    }

    init(wrappedValue value: Value) {
        self.value = value
    }
}

@propertyWrapper
struct Atomic2<Value> {
    private var value: Value
    private var semaphore = DispatchSemaphore(value: 1)

    var wrappedValue: Value {
        get {
            semaphore.wait()
            let temp = value
            semaphore.signal()
            return temp
        }

        set {
            semaphore.wait()
            value = newValue
            semaphore.signal()
        }
    }

    init(wrappedValue value: Value) {
        self.value = value
    }
}

struct MyStruct {
    @Atomic var counter = 0
    @Atomic2 var counter2 = 0
}

func test() {
    var myStruct = MyStruct()

    DispatchQueue.concurrentPerform(iterations: 1000) {
        myStruct.counter += $0
        myStruct.counter2 += $0
   }
}

How can they be properly tested and measured to see the difference between the two implementations and if they even work?


Solution

  • You asked:

    How can they be properly tested and measured to see the difference between the two implementations and if they even work?

    A few thoughts:


    A few observations regarding different synchronization techniques:

    1. FWIW, the GCD approach will offer better performance than the semaphore approach. Consider:

      @propertyWrapper
      class Atomic<Value> {
          private var value: Value
          private let queue = DispatchQueue(label: (Bundle.main.bundleIdentifier ?? "Atomic") + ".synchronize")
      
          var wrappedValue: Value {
              get { queue.sync  { value } }
              set { queue.async { self.value = newValue } }
          }
      
          init(wrappedValue value: Value) {
              self.value = value
          }
      }
      

      That is faster than the semaphore technique.

    2. Even better, consider NSLock:

      @propertyWrapper
      class Atomic<Value> {
          private var value: Value
          private let lock = NSLock()
      
          var wrappedValue: Value {
              get { lock.withLock { value } }
              set { lock.withLock { value = newValue } }
          }
      
          init(wrappedValue value: Value) {
              self.value = value
          }
      }
      
    3. Or you can use unfair locks. In iOS 16+ and macOS 13.0+, you can do this with OSAllocatedUnfairLock:

      import os.lock
      
      @propertyWrapper
      class Atomic<Value> {
          private let lock: OSAllocatedUnfairLock<Value>
      
          var wrappedValue: Value {
              get { lock.withLock { $0 } }
              set { lock.withLock { $0 = newValue } }
          }
      
          init(wrappedValue value: Value) {
              lock = OSAllocatedUnfairLock(initialState: value)
          }
      }
      

      For what it is worth, historically, unfair locks were significantly faster than NSLock, in my most recent tests, this advantage is eliminated. You should benchmark optimized builds on your target hardware, OS, and specific use-case, to verify.

    4. Also, to use unfair locks in earlier OS versions, rather than OSAllocatedUnfairLock, you have to write your own UnfairLock wrapper:

      // One should not use `os_unfair_lock` directly in Swift (because Swift
      // can move `struct` types), so we'll wrap it in a `UnsafeMutablePointer`.
      // See https://github.com/apple/swift/blob/88b093e9d77d6201935a2c2fb13f27d961836777/stdlib/public/Darwin/Foundation/Publishers%2BLocking.swift#L18
      // for stdlib example of this pattern.
      
      final class UnfairLock: NSLocking {
          private let unfairLock: UnsafeMutablePointer<os_unfair_lock> = {
              let pointer = UnsafeMutablePointer<os_unfair_lock>.allocate(capacity: 1)
              pointer.initialize(to: os_unfair_lock())
              return pointer
          }()
      
          deinit {
              unfairLock.deinitialize(count: 1)
              unfairLock.deallocate()
          }
      
          @inlinable
          func lock() {
              os_unfair_lock_lock(unfairLock)
          }
      
          @inlinable
          func tryLock() -> Bool {
              os_unfair_lock_trylock(unfairLock)
          }
      
          @inlinable
          func unlock() {
              os_unfair_lock_unlock(unfairLock)
          }
      }
      

      And then you could use that in a property wrapper:

      @propertyWrapper
      class Atomic<Value> {
          private var value: Value
          private let lock = UnfairLock()
      
          var wrappedValue: Value {
              get { lock.withLock { value } }
              set { lock.withLock { value = newValue } }
          }
      
          init(wrappedValue value: Value) {
              self.value = value
          }
      }
      
    5. Note, none of these will pass muster with the “Strict concurrency checking” build setting of “Complete” if the wrapped type is not Sendable. (If it was Sendable, then none of this would be necessary, anyway.) For example, consider this example with a non-Sendable type, Foo:

      @Atomic var foo = Foo()
      

      … will produce a warning:

      Stored property '_foo' of 'Sendable'-conforming class 'Bar' is mutable; this is an error in the Swift 6 language mode

      If you try to get around that by making the wrappedValue property nonisolated, that will produce the following warning/error:

      'nonisolated' is not supported on properties with property wrappers; this is an error in the Swift 6 language mode

      You may have to abandon the property wrapper pattern, and just do something like:

      class Atomic<Value>: @unchecked Sendable {
          private var value: Value
          private let lock = NSLock()
      
          var wrappedValue: Value {
              get { lock.withLock { value } }
              set { lock.withLock { value = newValue } }
          }
      
          init(wrappedValue value: Value) {
              self.value = value
          }
      
          func synchronized(_ body: (inout Value) throws -> Void) rethrows {
              try lock.withLock { try body(&value) }
          }
      }
      

      Which you can then use like so:

      let foo = AtomicWrapper(wrappedValue: Foo())
      
      foo.synchronized { $0.value = 42 }
      

      It is not as elegant as the property wrapper approach, but is a worst-case scenario for adapting legacy codebases with non-Sendable types, and you want to synchronize your interaction with it to make it thread-safe. Just make sure you do not access the Foo instance outside of the wrapper.

    6. While I have preserved your terminology in the above, I personally would hesitate to call this “atomic”, because it practically invites the use of non-atomic operations. Consider this simple experiment, where we increment an integer ten million times:

      func threadSafetyExperiment() {
          @Atomic var foo = 0
      
          DispatchQueue.global().async {
              DispatchQueue.concurrentPerform(iterations: 10_000_000) { _ in
                  foo += 1
              }
              print(foo)
          }
      }
      

      You’d expect foo to be equal to 10,000,000, but it will not be. That is because the whole interaction of “retrieve the value and increment it and save it” needs to be wrapped in a single synchronization mechanism.

      But you can add an atomic increment method:

      extension Atomic where Value: Numeric {
          func increment(by increment: Value) {
              lock.withLock { value += increment }
          }
      }
      

      And then this works fine:

      func threadSafetyExperiment() {
          @Atomic var foo = 0
      
          DispatchQueue.global().async {
              DispatchQueue.concurrentPerform(iterations: iterations) { _ in
                  _foo.increment(by: 1)
              }
              print(foo)
          }
      }
      

      In short, be wary of accessor-level property wrappers: That is generally the wrong layer of abstraction to perform the synchronization.

      You might consider using a package, such as Swift Atomics which better handles this. But, even its “Proceed at Your Own Risk” warns us:

      The Atomics package provides carefully considered API for atomic operations that follows established design principles for Swift APIs. However, the underlying operations work on a very low level of abstraction. Atomics – even more than other low-level concurrency constructs – are notoriously difficult to use correctly.