Irbisa · Cheatsheet

GCD & Threading

Queues, QoS, DispatchGroup and semaphores, the main.sync deadlock, Operation, bridging to async/await

Middle Developer · 18 questions

  1. 01

    What is Thread (NSThread), and why do iOS apps almost never create threads by hand?

    Thread is Foundation's wrapper around a POSIX thread: you create it, start it and own its lifetime.

  2. 02

    On which thread does a URLSession completion handler run, and how do you update the UI from it?

    On a background thread: the session calls completion handlers on its delegate queue, which for URLSession.shared is a private serial OperationQueue, not the main queue.

  3. 03

    How do you run code after a delay with GCD, and how do you cancel it before it runs?

    DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { … } schedules a closure, but a plain closure cannot be cancelled.

    final class SearchViewController: UIViewController {  private var pendingSearch: DispatchWorkItem?   func searchTextChanged(_ text: String) {    pendingSearch?.cancel()                 // drops the previous item if it has not started    let item = DispatchWorkItem { [weak self] in…
  4. 04

    What is Thread Sanitizer, and how is it different from the Main Thread Checker?

    Thread Sanitizer (TSan) is a runtime tool that reports data races: two threads touching the same memory without synchronisation, at least one of them writing.

  5. 05

    What does this code print when run() is called on the main thread, and on which thread does each block run?

    // run() is called on the main thread, for example from viewDidLoadfunc run() {  let serial = DispatchQueue(label: "com.app.serial")  DispatchQueue.main.async { print("A", Thread.isMainThread) }  serial.sync { print("B", Thread.isMainThread) }  DispatchQueue.global().sync { print("C", Thread.isMainThread) }  serial.async { print("D", Thread.isMainThread) }  serial.sync { print("E", Thread.isMainThread) }  print("F")}

    It prints B true, C true, D false, E true, F and finally A true.

  6. 06

    How does a dispatch barrier turn a concurrent queue into a reader-writer lock, and when does the barrier flag do nothing?

    A block submitted with .barrier waits until every block submitted before it has finished, runs alone, and only then lets later blocks start.

    final class SessionStore: @unchecked Sendable {  private var tokens: [String: String] = [:]  // right: a private concurrent queue. On DispatchQueue.global() the barrier flag is ignored  private let queue = DispatchQueue(label: "com.app.sessions", attributes: .concurrent)   func token(for user: String) -> String? {…
  7. 07

    Find the bug: this thread-safe cache crashes the first time image(for:orInsert:) is called.

    final class ImageCache: @unchecked Sendable {  private let queue = DispatchQueue(label: "com.app.image-cache")  private var storage: [URL: UIImage] = [:]  func image(for url: URL) -> UIImage? {    queue.sync { storage[url] }  }   func image(for url: URL, orInsert make: () -> UIImage) -> UIImage {    queue.sync {      if let cached = image(for: url) { return cached }      let image = make()      storage[url] = image      return image    }  }}

    image(for:orInsert:) runs inside queue.sync and then calls image(for:), which calls queue.sync on the same serial queue.

  8. 08

    Find the bug: loadAvatars sometimes never calls its completion.

    // api.fetchAvatar(id:completion:) is declared with a @MainActor completion, called on the main queue@MainActorfunc loadAvatars(ids: [String], completion: @escaping @MainActor ([UIImage]) -> Void) {  let group = DispatchGroup()  var images: [UIImage] = []  for id in ids {    group.enter()    api.fetchAvatar(id: id) { result in      guard case .success(let image) = result else { return }      images.append(image)      group.leave()    }  }  group.notify(queue: .main) { completion(images) }}

    On the failure path the callback returns from the guard before group.leave(), so the group's counter never reaches zero and notify never fires.

  9. 09

    Find the bug: this retry timer is scheduled from a background queue and never fires.

    final class UploadRetrier: @unchecked Sendable {  private let queue = DispatchQueue(label: "com.app.upload")  private var retryTimer: Timer?   // Called by the upload code when a request fails  func uploadDidFail() {    queue.async {      self.retryTimer?.invalidate()      self.retryTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in        self?.retry()      }    }  }   private func retry() { /* start the upload again */ }}

    Timer.scheduledTimer adds the timer to the run loop of the current thread, and here that is a GCD worker thread whose run loop nobody runs.

  10. 10

    What does DispatchQueue.concurrentPerform do, and why is it better than global().async in a loop for CPU-heavy work?

    concurrentPerform(iterations:execute:) runs a closure N times in parallel on about as many threads as there are cores, and returns only when every iteration has finished.

    import Synchronizationimport Dispatch func checksum(_ chunk: ArraySlice<UInt8>) -> Int { chunk.reduce(0) { $0 &+ Int($1) } } func parallelChecksum(of bytes: [UInt8]) -> Int {…
  11. 11

    Which locks do you use in Swift (NSLock, NSRecursiveLock, OSAllocatedUnfairLock, Mutex), and why not a raw os_unfair_lock?

    On iOS 18+ use Mutex, on iOS 16–17 OSAllocatedUnfairLock: both own the state they protect and are the fastest options.

    import Foundationimport os // wrong: Swift does not guarantee a stable address for a stored struct, so &lock may be a// temporary copy; it often works by accidentfinal class UnsafeCounter: @unchecked Sendable {…
  12. 12

    Which of these are thread-safe in Swift: a static let, a lazy var, an Array you append to, an Int counter?

    Only the static let: globals and static stored properties are initialised lazily exactly once, even when many threads reach them first at the same time.

  13. 13

    How do you chain work with Operation dependencies, and what happens to a dependent operation when its dependency is cancelled?

    b.addDependency(a) keeps b from becoming ready until a has finished, even across queues.

    import Foundation final class ReadFileOperation: Operation, @unchecked Sendable {  let url: URL  private(set) var data: Data?  init(url: URL) { self.url = url }…
  14. 14

    In a Swift 6 codebase built on async/await, when is GCD still the right tool?

    When an API asks for a queue, when work blocks a thread and cannot be made async, and for timers and event sources.

  15. 15

    How do you write an asynchronous Operation subclass, and why does the queue get stuck if isFinished is not KVO-compliant?

    Override start() instead of main(), report isExecuting and isFinished yourself, and post KVO notifications for both when they change.

    import Foundation class AsyncOperation: Operation, @unchecked Sendable {  private let lock = NSLock()  private var _executing = false  private var _finished = false…
  16. 16

    What QoS does a block submitted with async run at, and how does GCD resolve priority inversion (and when can it not)?

    By default async captures the submitting thread's QoS, and the block runs at it on a queue without its own QoS; a queue with a QoS uses its own.

  17. 17

    Instruments shows 60+ threads because every object has its own serial queue. Why does that happen, and how do target queues fix it?

    Independent serial queues can all run at the same time, each on its own worker thread, and custom serial queues may get a new thread even when every core is busy.

    // One serial queue per subsystem; every per-object queue funnels into itlet databaseQueue = DispatchQueue(label: "com.app.database", qos: .utility) final class Table: @unchecked Sendable {  private let queue: DispatchQueue  private var rows: [String] = []…
  18. 18

    Is DispatchQueue.main.async the same as running on @MainActor, and what changes when you migrate GCD code to Swift Concurrency?

    At runtime they meet in the same place: the main actor's executor is the main dispatch queue, so both run on the main thread.

    nonisolated func didFetch(_ name: String, into label: UILabel) {  // A closure passed literally to DispatchQueue.main.async is treated as @MainActor  DispatchQueue.main.async { label.text = name }   // Through a variable it is a plain @Sendable closure, and touching label is diagnosed:  // let queue = DispatchQueue.main; queue.async { label.text = name }…