iOS Concurrency with GCD & Operations

Sep 12 2023 · Swift 5.8, macOS 13, iOS 16, Xcode 14.3

Part 2: Concurrency Problems & Solutions

11. Concurrency Problems

Episode complete

Play next episode

Next
About this episode

Leave a rating/review

See forum comments
Cinema mode Mark complete Download course materials
Previous episode: 10. Use a DispatchSemaphore Next episode: 12. Explore Priority Inversion

Get immediate access to this and 4,000+ other videos and books.

Take your career further with a Kodeco Personal Plan. With unlimited access to over 40+ books and 4,000+ professional videos in a single subscription, it's simply the best investment you can make in your development career.

Learn more Already a subscriber? Sign in.

Transcript: 11. Concurrency Problems

Concurrent tasks don’t run in the same order, for the same duration, every time, so concurrent programming can create problems that are hard to debug. In this video, you’ll learn what you can do to prevent them. The three potential problems are priority inversion, race conditions and deadlocks. Race conditions are also called data races. GCD and Operations help you avoid the first two, but you have to keep your wits about you to avoid deadlocks. I mention a few tips throughout this course.

The first potential concurrency problem is priority inversion: A lower priority task runs ahead of a higher priority task, often because the high priority task needs a resource that a lower priority task has locked. In this diagram, the vertical axis is low to high priority, the horizontal axis is time, and below that is a shared resource. A low priority task is happily chugging along.

Now the low priority task needs to access the shared resource, so it locks it (to prevent a race condition) and continues working. Then a medium priority task comes along: It’s more important, so the processor starts working on the medium task, and puts the low priority task to sleep. This sleeping task still locks the shared resource.

Now a high-priority task comes along, sending the medium task to sleep. After doing some work, this high priority task needs access to the shared resource, so tries to acquire a lock.

But the low priority task still locks the resource, so the high priority task goes to sleep while it waits for the resource to become available. And now the medium priority task resumes running: It doesn’t need the shared resource. This is priority inversion: The medium priority task is running ahead of the high-priority task. After the medium task finishes, the low priority task resumes: priority inversion is still happening, with the low priority task running while the high priority task sleeps.

When the low priority task finishes and releases the lock, the high priority task can acquire it and resume. You’ll explore priority inversion in the next exercise, and learn how Apple tries to solve this concurrency problem.

In part 1 of this course, you created a simple data race by printing a value on the current thread, immediately after dispatching a task to update that value. You solved it by dispatching the task synchronously, so the current thread had to wait for the write to finish before reading the value to print it. It’s easy to see here but, in a large app, the problem could be hidden in different files.

Another situation that’s easy to miss is accessing a lazy variable from different files. Here again, you can dispatch synchronously to an internal serial queue to create the variable and set a Boolean property to flag that it exists.

A data race can happen when two threads try to access the same value. The outcome depends on when the threads start, sleep or resume, relative to each other. This diagram shows two auto teller machines accessing the same bank account. The top row shows the account balance as time passes.

  • ATM1 reads the balance: $1200, then ATM2 reads the balance: also $1200.
  • ATM1 checks $1200 > $1000, so withdraws $1000, leaving a $200 balance.
  • ATM2 checks $1200 > $800, so withdraws $800, leaving a -$600 balance.

If ATM2 had checked the balance after ATM1 withdrew $1000, it would have cancelled the $800 withdraw transaction, because $200 is not more than $800.

This apparently really happened to a bank. The bank’s software engineer wrote an article about it, proposing several solutions, including checking the balance twice, as well as the solution you’re about to learn.

A traditional thread-based solution to data races is to lock the resource while you write to it, but locks are very hard to get right and can cause deadlock. In Part 1, you saw one GCD solution: using a serial queue to restrict access to the resource. At the time, I said this could be too restrictive: If you could make sure a write task can’t sneak in to change the value, why not let multiple read tasks run at the same time? And I promised you’d learn how to use a dispatch barrier to do exactly this. Well, here we are!

This is a concurrent queue with read tasks running on 4 threads.

And then a write task enters the queue as a barrier task: From that point, the queue behaves like a serial queue. Tasks already running continue to run concurrently, but tasks arriving after the barrier task have to wait. After all the currently running tasks complete, the barrier task runs.

When the barrier task completes, the waiting read tasks can run concurrently. Race conditions depend on the timing between threads, which is hardly ever the same. So race conditions are really hard to debug.

Xcode 8 introduced a thread sanitizer tool TSan, to help you find potential race conditions in your code. You’ll soon get lots of practice using TSan to check apps for data races, then creating dispatch barriers to prevent them.

The final potential concurrency problem is deadlock. This is where two threads are each waiting for the other to release a shared resource or do something necessary for continuing. Here are two threads working away, when …

  • Thread 1 requests and acquires a lock on resource 1. No problem.
  • Then thread 2 requests and acquires a lock on resource 2. Still no problem.
  • Then Thread 1 requests a lock on resource 2, which it cannot acquire, so it sleeps. Still no problem: once thread 2 releases its lock on resource 2, then thread 1 can acquire a lock and execution will continue.

But then, thread 2 needs resource 1, which is locked by thread 1, which is asleep.

Thread 2 can’t get a lock on resource 1, so it also sleeps. So thread 1 is waiting for thread 2 to release resource 2 and thread 2 is waiting for thread 1 to release resource 1, and both threads are sleeping. Neither of these threads will ever be able to continue: you’ve reached deadlock!

Locking shared resources is an old solution for the data race problem, which can also be solved with serial queues. And that will fix this deadlock scenario, too. But more subtle deadlock situations can arise with OperationQueues, as you’ll see in Part 3.

Continue to the next video to learn more about priority inversion.