I do a lot of performance and instrumenting work and I’ve found Peter Steinberger’s post here very useful when comparing lock alternatives.

As I worked with async/await and actors more and more this summer, I thought it’d be nice to put together a short post offering some basic benchmarking of actors vs. the existing synchronization mechanisms.

Disclaimer

Benchmarking depends heavily on the system, temporary conditions, and more. As with any amateur benchmarks, take the numbers in this post with a grain of salt.

The Benchmark Setup

I’ve prepared a super-duper simple SwiftUI app that increments a counter 10,000 times. This is a very limited use case but since a counter is often the first example used to teach about synchronization β€” I’ll go with it.

The project source is available here: https://github.com/icanzilb/ActorBench.

This is my setup to build and run the benchmark:

  • Xcode Version 13.0 (13A233)
  • a Release configuration build
  • no debugger attached
  • on my iPhone SE device running iOS 15.0(19A346).

Running the Benchmarks

The app has a very simple UI that allows me to manually start each benchmark in turn:

App user interface with three buttons to run various benchmarks

Actor counter

My actor counter uses very minimal code:

1
2
3
4
5
6
7
actor ActorBench {
  private var counter = 0.0

  func increment() {
    counter += 1.2
  }
}

The actor serializes calls to increment() and protects counter from data races. Calling increment() 10,000 times on my device gives me an average of:

Duration: 0.132 seconds

DispatchQueue Counter

And now, the code for the same counter using a DispatchQueue:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Queue {
  private var counter = 0.0
  private let queue = DispatchQueue(label: "counter")

  func increment() {
    queue.sync {
      counter += 1.2
    }
  }
}

Here, I use a serial DispatchQueue to safely and synchronously increment the counter. The averaged result for 10,000 increments:

Duration: 0.016 seconds

Using Unfair Lock

Finally, here’s the same counter, but using os_unfair_lock_s which is the latest and greatest lock on Apple’s platforms:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Lock {
  private var lock = os_unfair_lock_s()
  private var counter = 0.0

  func increment() {
    os_unfair_lock_lock(&lock)
    counter += 1.2
    os_unfair_lock_unlock(&lock)
  }
}

To prevent data-races, this code locks and unlocks the lock for the duration it needs exclusive access to the counter. The averaged result on my device is:

Duration: 0.009 seconds

Results

The results are pretty straightforward, with os_unfair_lock being the clear winner for the given performance benchmark:

App user interface with three buttons to run various benchmarks

As mentioned earlier, take the results with a grain of salt. Actors do much more than being simple locks do. That’s why an actual lock was much more performant in this benchmark.

Actors offer safety and design clarity but, naturally, they aren’t the ultimate solution to all problems. As always, use the right tool for the task β€” for locking, the right tool is a lock πŸ”πŸ˜Š.

Thanks for reading and see you next time πŸ‘‹πŸ½

Where to go from here?

Interested in the new async/await Swift syntax? Hit me up on twitter at https://twitter.com/icanzilb.