Thread Synchronization Mechanisms in iOS and Concurrency Pitfalls

When multiple threads access shared resources—such as the same object, variable, or file—data corruption and race conditions can easily occur. This fundamental concurrency problem is illustrated by classic examples like bank transactions and ticket selling.

Analyzing the Race Condition

Consider an accountBalance property modified by concurrent save and withdraw operations. The following Swift example uses GCD to simulate the issue:

class BankDemo {
    var balance: Int = 100

    func save() {
        let temp = balance
        Thread.sleep(forTimeInterval: 0.5)
        balance = temp + 50
        print("Deposited 50, balance: \(balance) - \(Thread.current)")
    }

    func withdraw() {
        let temp = balance
        Thread.sleep(forTimeInterval: 0.5)
        balance = temp - 20
        print("Withdrew 20, balance: \(balance) - \(Thread.current)")
    }

    func run() {
        let queue = DispatchQueue.global()
        for _ in 0..<10 {
            queue.async { self.save() }
            queue.async { self.withdraw() }
        }
    }
}

Without synchronization, the final balance is unpredictable because read‑modify‑write sequences interleave.

Thread Synchronization in iOS

To enforce ordered access, iOS offers several synchronization primitives. The core idea is to protect critical sections with locks or serial execution.

Mechanism Notes
os_unfair_lock Replacement for OSSpinLock; waiting threads sleep instead of busy‑waiting.
OSSpinLock Deprecated – prone to priority inversion; not recommended.
pthread_mutex POSIX mutex; supports normal, recursive, and error‑checking types.
NSLock Objective‑C wrapper around a normal mutex.
NSRecursiveLock Wrapper around a recursive mutex; allows the same thread to lock multiple times.
NSCondition Wraps a mutex and a condition variable; anables signaling between threads.
NSConditionLock Extends NSCondition with a condition value.
@synchronized Mutual exclusion using an object as a lock; based on a recursive pthread_mutex.
dispatch_semaphore Controls access count; a semaphore with value 1 acts as a mutex.
dispatch_queue (serial) Serial queues synchronize tasks by executing them one at a time.

Using os_unfair_lock

import os.lock

class LockDemo {
    private var lock = os_unfair_lock_s()
    private var balance = 100

    func save() {
        os_unfair_lock_lock(&lock)
        let temp = balance
        Thread.sleep(forTimeInterval: 0.5)
        balance = temp + 50
        print("Deposited, balance: \(balance)")
        os_unfair_lock_unlock(&lock)
    }

    func withdraw() {
        os_unfair_lock_lock(&lock)
        let temp = balance
        Thread.sleep(forTimeInterval: 0.5)
        balance = temp - 20
        print("Withdrew, balance: \(balance)")
        os_unfair_lock_unlock(&lock)
    }
}

Using pthread_mutex

import Foundation

class MutexDemo {
    private var mutex = pthread_mutex_t()
    private var balance = 100

    init() {
        var attr = pthread_mutexattr_t()
        pthread_mutexattr_init(&attr)
        pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL)
        pthread_mutex_init(&mutex, &attr)
        pthread_mutexattr_destroy(&attr)
    }

    deinit {
        pthread_mutex_destroy(&mutex)
    }

    func save() {
        pthread_mutex_lock(&mutex)
        let temp = balance
        Thread.sleep(forTimeInterval: 0.5)
        balance = temp + 50
        pthread_mutex_unlock(&mutex)
    }
    // withdraw similar
}

Using NSLock

class NSLockDemo {
    private let moneyLock = NSLock()
    private var balance = 100

    func save() {
        moneyLock.lock()
        let temp = balance
        Thread.sleep(forTimeInterval: 0.5)
        balance = temp + 50
        moneyLock.unlock()
    }
}

Using a Serial Dispatch Queue

class SerialQueueDemo {
    private let queue = DispatchQueue(label: "com.example.money")
    private var balance = 100

    func save() {
        queue.sync {
            let temp = balance
            Thread.sleep(forTimeInterval: 0.5)
            balance = temp + 50
        }
    }

    func withdraw() {
        queue.sync {
            let temp = balance
            Thread.sleep(forTimeInterval: 0.5)
            balance = temp - 20
        }
    }
}

Using dispatch_semaphore

class SemaphoreDemo {
    private let semaphore = DispatchSemaphore(value: 1)
    private var balance = 100

    func save() {
        semaphore.wait()
        let temp = balance
        balance = temp + 50
        semaphore.signal()
    }
}

Using @synchronized

class SynchronizedDemo {
    private var balance = 100

    func save() {
        objc_sync_enter(self)
        let temp = balance
        balance = temp + 50
        objc_sync_exit(self)
    }
}

Performance Ranking

From highest to lowest performance (approximate):

  1. os_unfair_lock
  2. OSSpinLock (deprecated)
  3. dispatch_semaphore
  4. pthread_mutex
  5. dispatch_queue (serial)
  6. NSLock
  7. NSCondition
  8. pthread_mutex (recursive)
  9. NSRecursiveLock
  10. NSConditionLock
  11. @synchronized

Spinlock vs. Mutex

Spinlocks (busy‑wait) are suitable when the wait time is expected to be very short, the critical section is small, and CPU resources are abundant. Mutexes (sleep‑wait) are better when wait times may be long, on single‑core processors, or when the critical section involves I/O or complex logic.

Atomic Properties

Declaring a property as atomic guarantees that its getter and setter are atomic, but it does not make the usage of that property thread‑safe. Because atomic setter/getter use a spinlock internally, they incur a performance cost and are rarely used in modern iOS development.

Read‑Write Safety (Multiple Readers, Single Writer)

For scenarios like file I/O, we often need concurrent reads but exclusive writes. iOS provides two main solutions:

pthread_rwlock

import Darwin.POSIX

class RWLockDemo {
    private var rwlock = pthread_rwlock_t()

    init() {
        pthread_rwlock_init(&rwlock, nil)
    }

    deinit {
        pthread_rwlock_destroy(&rwlock)
    }

    func read() {
        pthread_rwlock_rdlock(&rwlock)
        // perform read
        pthread_rwlock_unlock(&rwlock)
    }

    func write() {
        pthread_rwlock_wrlock(&rwlock)
        // perform write
        pthread_rwlock_unlock(&rwlock)
    }
}

dispatch_barrier_async

class BarrierDemo {
    private let queue = DispatchQueue(label: "rw.queue", attributes: .concurrent)

    func read() {
        queue.async {
            // concurrent read
        }
    }

    func write() {
        queue.async(flags: .barrier) {
            // exclusive write, all pending reads finish first
        }
    }
}

Note: dispatch_barrier_async must be used with a custom concurrent queue.

NSMutableArray and Thread Safety

NSMutableArray is not thread‑safe. Mutations (add, remove, replace) must be protected by a lock. Even a read operation (count, iteration) can observe inconsistent state if a concurrent mutation is in progress, so reads also require synchronization, often using a read‑write lock or barrier for optimal performance.

Deadlock

A deadlock occurs when processes are stuck waiting for each other’s resources. Four conditions must hold simultaneously:

  • Mutual exclusion – resources cannot be shared.
  • Hold and wait – a process holds resources while waiting for others.
  • No preemption – resources cannot be forcibly taken away.
  • Circular wait – a circular chain of processes exists, each waiting for a resource held by the next.

Breaking any of these conditions can resolve or prevent deadlocks. Common recovery methods include resource preemption or terminating processes.

Tags: iOS Concurrency Thread Safety os_unfair_lock pthread_mutex

Posted on Mon, 06 Jul 2026 16:21:12 +0000 by TobesC