Chapters

Hide chapters

Concurrency by Tutorials

Third Edition · iOS 16 · Swift 5.7 · Xcode 14

5. Concurrency Problems
Written by Scott Grosch

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

Unfortunately, for all the benefits provided by dispatch queues, they’re not a panacea for all performance issues. There are three well-known problems that you can run into when implementing concurrency in your app if you’re not careful:

  • Race conditions
  • Deadlock
  • Priority inversion

Race Conditions

Threads that share the same process, which also includes your app itself, share the same address space. What this means is that each thread is trying to read and write to the same shared resource. If you aren’t careful, you can run into race conditions in which multiple threads are trying to write to the same variable at the same time.

Consider the example where you have two threads executing, and they’re both trying to update your object’s count variable. Reads and writes are separate tasks that the computer cannot execute as a single operation. Computers work on clock cycles in which each tick of the clock allows a single operation to execute.

Note: Do not confuse a computer’s clock cycle with the clock on your watch. An iPhone 14 has a 3.23 GHz processor, meaning it can perform 3,230,000,000 clock cycles per second!

Thread 1 and thread 2 both want to update the count, and so you write some nice clean code like so:

count += 1

Seems pretty innocuous, right? Break that statement down into its component parts, add a bit of hand-waving, and what you end up with is something like this:

  1. Load value of variable count into memory.
  2. Increment value of count by one in memory.
  3. Write newly updated count back to disk.

value 1 2 2 1 Thread 1 +1 w2 r1 Thread 2 r1 +1 w2 Time Race Condition

The graphic shows:

  • Thread 1 kicked off a clock cycle before thread 2 and read the value 1 from count.
  • On the second clock cycle, thread 1 updates the in-memory value to 2 and thread 2 reads the value 1 from count.
  • On the third clock cycle, thread 1 now writes the value 2 back to the count variable. However, thread 2 is just now updating the in-memory value from 1 to 2.
  • On the fourth clock cycle, thread 2 now also writes the value 2 to count… except you expected to see the value 3 because two separate threads both updated the value.

This type of race condition leads to incredibly complicated debugging due to the non-deterministic nature of these scenarios.

If thread 1 had started just two clock cycles earlier you’d have the value 3 as expected, but don’t forget how many of these clock cycles happen per second.

You might run the program 20 times and get the correct result, then deploy it and start getting bug reports.

You can usually solve race conditions with a serial queue, as long as you know they are happening. If your program has a variable that needs to be accessed concurrently, you can wrap the reads and writes with a private queue, like this:

class ThreadableInt {
  private let queue = DispatchQueue(label: "...")
  private var _value = 0

  var value: Int {
    queue.sync { _value }
  }

  static func += (left: ThreadableInt, right: Int)  {
    left.increment(amount: right)
  }

  static func -= (left: ThreadableInt, right: Int)  {
    left.decrement(amount: right)
  }

  func increment(amount: Int = 1) -> Int {
    return queue.sync {
      _value += amount
      return _value
    }
  }

  func decrement(amount: Int = 1) -> Int {
    return queue.sync {
      _value -= amount
      return _value
    }
  }
}

Notice how each access of _value is wrapped with a dispatch to the queue, preventing race conditions. You can verify both the issue and the solution by pasting the following into a playground:

var threadableInt = ThreadableInt()
DispatchQueue.concurrentPerform(iterations: 10_000) { _ in
    threadableInt += 1
}

print(threadableInt.value)

var dangerous = 1
DispatchQueue.concurrentPerform(iterations: 10_000) { _ in
    dangerous += 1
}

print(dangerous)

The playground will increment a variable 10,000 times concurrently, first using your new class, then using a simple integer. The threadableInt will always print the expected value 10,000, whereas the dangerous variable will normally print a value in the high 9k range.

Because you’ve not stated otherwise, the queue is a serial queue.

While you’ve created a usable solution, there are still many factors you need to keep in mind. The way you use the value is important. Consider this simple code:

if (threadableInt.value > 10 && threadableInt.value < 20) {

You’ve just introduced another possible failure. While each lookup is thread safe, you’ve used the value multiple times, meaning another thread might change the value in between the two comparisons. The solution will depend on your specific needs. Does it make sense to assign a temporary variable and compare against that? Do you need to add a method to ThreadableInt that allow you to pass a block of code which protects the entire thing via the DispatchQueue?

Just because you’ve wrapped your value, that doesn’t solve every issue.

Note: You can implement the same private queue sync for lazy variables, which might be run against multiple threads. If you don’t, you could end up with two instances of the lazy variable initializer being run. Much like the variable assignment from before, the two threads could attempt to access the same lazy variable at nearly identical times. Once the second thread tries to access the lazy variable, it wasn’t initialized yet, but it is about to be created by the access of the first thread. A classic race condition.

Dispatch Barrier

Sometimes, your shared resource requires more complex logic than a simple variable modification. You’ll frequently see questions related to this online, and often they come with solutions related to locks and semaphores. Locking is very hard to implement properly. Instead, you can use Apple’s dispatch barrier solution from GCD.

class BarrierImages {
  // 1
  private let queue = DispatchQueue(label: "...", attributes: .concurrent)

  private var _images: [Image] = []

  // 2
  var images: [Image] {
    var copied: [Image] = []

    queue.sync {
      copied = _images
    }

    return copied
  }

  func add(image: Image) {
    // 3
    queue.sync(flags: .barrier) { [weak self] in
      self?._images.append(image)
    }
  }

  func remove(at index: Int) -> Image? {
    var removed: Image? = nil

    // 4
    queue.sync(flags: .barrier) { [weak self] in
      removed = self?._images.remove(at: index)
    }

    return removed
  }
}

Deadlock

Imagine you’re driving down a two-lane road on a bright sunny day and you arrive at your destination. Your destination is on the other side of the road, so you turn on the car’s turn signal. You wait as tons of traffic drives in the other direction.

Priority Inversion

Technically speaking, priority inversion occurs when a queue with a lower quality of service is given higher system priority than a queue with a higher quality of service, or QoS. If you’ve been playing around with submitting tasks to queues, you’ve probably noticed a constructor to async, which takes a qos parameter.

let high = DispatchQueue.global(qos: .userInteractive)
let medium = DispatchQueue.global(qos: .userInitiated)
let low = DispatchQueue.global(qos: .background)

let semaphore = DispatchSemaphore(value: 1)
high.async {
    // Wait 2 seconds just to be sure all the other tasks have enqueued
    Thread.sleep(forTimeInterval: 2)
    semaphore.wait()
    defer { semaphore.signal() }

    print("High priority task is now running")
}

for i in 1...10 {
    medium.async {
        let waitTime = Double(exactly: arc4random_uniform(7))!
        print("Running medium task \(i)")
        Thread.sleep(forTimeInterval: waitTime)
    }
}

low.async {
    semaphore.wait()
    defer { semaphore.signal() }

    print("Running long, lowest priority task")
    Thread.sleep(forTimeInterval: 5)
}
Running medium task 7
Running medium task 6
Running medium task 1
Running medium task 4
Running medium task 2
Running medium task 8
Running medium task 5
Running medium task 3
Running medium task 9
Running medium task 10
Running long, lowest priority task
High priority task is now running

Where to Go From Here?

Throughout this chapter, you explored some common ways in which concurrent code can go wrong. While deadlock and priority inversion are much less common on iOS than other platforms, race conditions are definitely a concern you should be ready for.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now