Use queue and semaphore for concurrency and property wrapper?

FWIW, there are many synchronization techniques that offer better performance than semaphores.

For example, you can use the reader-writer pattern with concurrent queue, where reads are done synchronously, but are allowed to run concurrently with respect to other reads, but writes are done asynchronously, but with a barrier (i.e. not concurrently with respect to any other reads or writes):

@propertyWrapper
class Atomic<Value> {
    private var value: Value
    private let queue = DispatchQueue(label: "com.domain.app.readerwriter", attributes: .concurrent)

    var wrappedValue: Value {
        get { queue.sync { value } }
        set { queue.async(flags: .barrier) { self.value = newValue } }
    }

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

That is a little faster than the semaphore technique.

For more dramatic performance improvement, consider NSLock:

@propertyWrapper
class Atomic<Value> {
    private var value: Value
    private var lock = NSLock()

    var wrappedValue: Value {
        get { lock.withLock { value } }
        set { lock.withLock { value = newValue } }
    }

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

Where:

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

Or you can use unfair locks. In iOS 16+ and macOS 13.0+, you can do this with OSAllocatedUnfairLock:

@propertyWrapper
class Atomic<Value> {
    private var value: Value
    private var lock = OSAllocatedUnfairLock()

    var wrappedValue: Value {
        get { lock.withLock { value } }
        set { lock.withLock { value = newValue } }
    }

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

For what it is worth, historically I have always found unfair locks to be significantly faster than NSLock, in my most recent tests on contemporary OS and hardware, NSLock was actually faster. You should benchmark on your target hardware, OS, and specific use-case to verify.

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

@propertyWrapper
class Atomic<Value> {
    private var value: Value
    private var lock = UnfairLock()

    var wrappedValue: Value {
        get { lock.withLock { value } }
        set { lock.withLock { value = newValue } }
    }

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

Where:

// 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 {
    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()
    }

    func withLock<T>(block: () throws -> T) rethrows -> T {
        os_unfair_lock_lock(unfairLock)
        defer { os_unfair_lock_unlock(unfairLock) }
        return try block()
    }
}

We should recognize that while these, and yours, offers atomicity, you have to be careful because, depending upon how you use it, it may not be thread-safe.

Consider this simple experiment, where we increment an integer a 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 won’t 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)
    }
}

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

A few thoughts:

  • I’d suggest doing far more than 1,000 iterations. You want to do enough iterations that the results are measured in seconds, not milliseconds. I used ten million iterations in my example.

  • The unit testing framework is ideal at both testing for correctness as well as measuring performance using the measure method (which repeats the performance test 10 times for each unit test and the results will be captured by the unit test reports):

    test results

    So, create a project with a unit test target (or add a unit test target to existing project if you want) and then create unit tests, and execute them with command+u.

  • If you edit the scheme for your target, make sure parallel execution of tests is off (especially not to put multi-threaded synchronizations at an artificial disadvantage) and you may want to to randomize the order of your tests, to make sure the order in which they execute doesn’t affect the performance:

    I would also make the test target use a release build to make sure you are testing an optimized build.

  • Needless to say, while I am stress testing the locks by running 10m iterations, incrementing by one for each iteration, that is horribly inefficient. There simply is not enough work on each thread to justify the overhead of the thread handling. One would generally stride through the data set and do more iterations per thread, and reducing the number of synchronizations. (As you can see in my results above, clever striding can have a far more dramatic impact than changing synchronization methods.)

    The practical implication of this, is that in well designed parallel algorithm, where you are doing enough work to justify the multiple threads, you are reducing the number of synchronizations that are taking place. Thus, the minor variances in the different synchronization techniques are unobservable. If the synchronization mechanism is having an observable performance difference, this probably suggests a deeper problem in the parallelization algorithm. Focus on reducing synchronizations, not making synchronizations faster.

  • Probably needless to say, nowadays, with Swift concurrency and asyncawait, we generally use actors for synchronization. See WWDC 2021 videos Protect mutable state with Swift actors and Meet async/await in Swift.

Leave a Comment