iOS Queueing, Thread-safety and Synchronisation Notes
Kartick Vaddadi
Tech Advisor to CXOs. I contributed to a million-dollar outcome for one my clients.
This is an advanced note, which builds on top of the basics on iOS. If you’re not familiar with the basics, read a basic tutorial before continuing.
Dispatch Queues
- They come in two varieties: serial and concurrent. A serial queue can execute only one task at once.
- Serial queues are FIFO.
- They have a QoS (quality of service) level that decides which queue gets priority when multiple queues are competing for limited CPU time.
- The QoSs are, in decreasing order: a) userInteractive: means the user is waiting for the result. b) userrInitiated: means the user is waiting for the result, but with lesser priority c) Utility: means the user expects it to complete later, like uploading a file d) Background: means it’s initiated by the app and the user doesn’t even know it’s happening, like upgrading a database to a different format.
- A serial queue is FIFO even if you enqueue tasks with different priorities.
- If you want a concurrent queue, the system already provides one at each QoS. Don’t create one yourself.
- The system also provides a default serial queue. For example, if you want to create a savingQueue to save photos, you can instead use this.
- You can use either sync or async to do work on a queue.
- Async asks the target queue to do the work but continues executing the calling queue without waiting.
- Sync asks the target queue to do the work and blocks the calling queue till it’s done.
- Calling sync on the current queue will deadlock, because the queue will wait till it receives a message saying it can continue, but it will never receive the message since the queue that sends the message — the same queue — is blocked.
- Calling async on the current queue is fine.
- Sync also deadlocks if a queue A blocks on B and B blocks on A.
- If you want to return a result, sync is the only option.
- DispatchQueues have names, but it’s only for debugging in Xcode and in crash logs. There’s no API to get the name of the current queue. The only way is to set a key on the queue, get the key of the current queue, and if it matches, it’s the same queue. There are two versions of the getter; make sure you invoke the class version, not the instance version, for the above logic to work.
- A DispatchQueue can have a target queue, which is another queue on which it executes its operations. If a and b are two serial queues, and you do a.setTarget(b), then an operation submitted to a can’t execute at the same time as an operation submitted to b.
- You can suspend a queue to temporarily stop execution.
- You can schedule something after a time period.
- This time period can be specific in wall time or CPU time. The wall time is as per a wall clock: if you schedule something after 1s wall time, it will run after 1s (assuming the queue isn’t running something else at that time). Whereas if you schedule something after 1s CPU time, but the CPU happens to sleep for 0.5s (because it has nothing to do), then the CPU clock will pause ticking, so the task will run after 1.5s. Use wall time for predictability.
- Instead of running a block, you can run a DispatchWorkItem, which is a wrapper around a block. This gives you extra abilities like canceling it, or blocking another queue waiting for it.
- Queues are implemented using threads, but don’t use threads directly.
- There’s a main thread, and a main queue. Work that’s executed on the main queue ends up executing on the main thread, but the main thread can also do work for other queues. So you want to verify that something is running on the main queue; verifying that something is running on the main thread is not enough.
Operation Queues
- Higher-level API than DispatchQueues.
- An operation queue can have an underlying dispatch queue which tells the operation queue to execute its work on the dispatch queue. The operation queue then becomes a wrapper over the dispatch queue.
- OperationQueue.main.underlyingQueue === DispatchQueue.main. That is, the main operation queue’s underlying queue is the main dispatch queue.
- This means if you enqueue a task on the main operation queue, and another on the main dispatch queue, they execute one after another.
- By default, the underlying queue is nil, which causes the operation queue to execute things itself without depending on a dispatch queue.
- Operation queues start out as concurrent but can be made serial by setting maxConcurrentOperationCount to 1. Unlike dispatch queues, which are configured as serial or concurrent at the time of creation and can’t be changed afterward, operation queues can change any time.
- Even a serial operation queue is not FIFO. Operations can have priorities (veryHigh, high, default, low, veryLow) and higher priority operations are executed first. So operation queue is a priority queue, while dispatch queue is an ordinary queue.
- Operations submitted on a queue can have dependencies. For example, you can say that C can execute only when A and B are both done, and D can execute when A is done. Dispatch queues don’t support these.
- Operation queues don’t let you add a task to be done after a delay, like dispatch queues do.
- Both operation and dispatch queues have some common features: canceling a specific task, sync vs async, the ability for another queue to wait till a specific task is completed, QoS, suspending a queue, telling a queue to execute its tasks on another queue, a name for debugging…
Thread-safety
Thread-safety in Swift boils down to a simple rule: two queues shouldn’t simultaneously access the same variable.
The simplest way to satisfy this rule is to ensure that a variable is used in only one queue.
We can have a:
- Write-write conflict, where two queues are simultaneously writing the same variable.
- Write-read conflict, where one queue is writing when another is reading.
- Read-read is not a conflict. It’s safe.
If you have a conflict, you can get a hang, crash, wrong value, or any other behavior. For example, a bool might up having a value other than true and false.
The following code illustrates various cases:
var i = 0 // Unsafe (write-write conflict): q1.async { i = 5 } q2.async { i = 10 } // The value of i might end up 5 or 10, depending on which // executes first. This is called a race condition. // // It might also end up a random value, like 18. // Unsafe (write-read conflict): q1.async { i = 5 } q2.async { NSLog(i) } // Safe (read-read is not a conflict): q1.async { NSLog(i) } q2.async { NSLog(i) } // q1 does both a read and write, both of which // conflict with q2’s write: q1.async { i += 1 } q2.async { i = 5 } // Safe (the first line completes before the second // starts): i = 5 q2.async { i = 10 } // Unsafe (both assignments might be executing at once) q1.async { i = 5 } i = 10 // Safe (sync blocks the calling queue till it’s done) q1.sync { i = 5 } i = 10 // Safe (the second assignment can execute only after the // first completes): q1.async { i = 5 q2.async { i = 10 } }
It all boils down to one thing: Is there a chance two queues are simultaneously accessing the variable, and at least one of them is a write? Keep this in mind and we won’t have race conditions.
See this example as well.
Should I use sync or async?
Think about the following factors to decide which one to use:
- sync can deadlock, if you call it on the queue you’re currently running in. Or if q1 calls q2.sync, and within the block, we call q1.sync again.
- async can cause race conditions or other crashes, as per the examples above and below.
- If the queue should not block, use async. For example, if it’s driving the UI, or is otherwise latency-sensitive.
- If the block should return a value to the caller, it should be sync.
- Sometimes it simplifies code to use sync. For example, the below code
func stopRecording() { if assetWriter == nil { return } self.assetWriter.finishRecording() self.assetWriter = nil }
works if called twice, because the first time assetWriter is nilled, and it checks for nil and returns the second time, without crashing.
Now suppose we make it asynchronous:
func stopRecording() { if assetWriter == nil { return } assetWriterQueue.async { self.assetWriter.finishRecording() self.assetWriter = nil } }
Now, if called twice, it crashes: the first call checks for nil, and enqueues an async block. The second call checks for nil, but because the async block hasn’t executed, it’s not nil, so it enqueues a second block. The first block eventually runs and nils the reference. The second block then unwraps nil and crashes.
So you can see how tricky asynchronous code is to write.
Converting the async to sync eliminates this bug, making it similar to the original straight-line code we started out with.