Sincronización en Swift: De NSLock a Actores y OSAllocatedUnfairLock

Sincronización en Swift: De NSLock a Actores y OSAllocatedUnfairLock


Cuando desarrollamos aplicaciones en iOS, la programación concurrente es inevitable. Sin embargo, cuando múltiples hilos intentan leer y modificar una misma variable al mismo tiempo, nos enfrentamos a las Condiciones de Carrera (Race Conditions): errores silenciosos que corrompen datos y son extremadamente difíciles de depurar.

En esta entrada analizaremos qué es NSLock, cómo funciona internamente, cuáles son las herramientas modernas que lo sustituyen y cómo probarlo en un ejemplo real con SwiftUI.


1. ¿Qué es NSLock y cuándo se utiliza?

NSLock es una clase perteneciente al framework Foundation (disponible desde iOS 2.0+ y macOS 10.0+) que implementa un mecanismo de Exclusión Mutua (Mutex) mediante hilos POSIX. Su objetivo principal es mediar el acceso a datos globales o proteger una sección crítica de código para que se ejecute de forma atómica.

Consideraciones críticas según la documentación de Apple

  • Mismo hilo para bloquear y desbloquear: La llamada a unlock() debe enviarse obligatoriamente desde el mismo hilo que llamó a lock(). Desbloquear desde un hilo diferente resulta en un comportamiento indefinido.
  • Prohibido en funciones recursivas: Intentar llamar a lock() dos veces seguidas en el mismo hilo provocará un Deadlock permanente. Para algoritmos recursivos debes utilizar NSRecursiveLock.
  • Desbloqueos inválidos: Intentar desbloquear un NSLock que no está bloqueado es considerado un error de programación y emitirá una advertencia explícita en la consola.

Métodos principales

  • lock(): Adquiere el bloqueo. Si otro hilo ya lo posee, el hilo actual se suspende (entra en sleep) hasta que el recurso sea liberado.
  • unlock(): Libera el bloqueo, permitiendo que el siguiente hilo en espera tome su turno.
  • try(): Intenta adquirir el bloqueo inmediatamente y devuelve un Bool indicando si tuvo éxito, sin pausar la ejecución del hilo.
  • lock(before:): Intenta adquirir el bloqueo con un límite de tiempo determinado (Date).
  • name: Propiedad opcional (String?) útil para identificar la instancia del lock durante procesos de depuración.
function readData() -> Data {
    lock.lock() // Acquires the lock; blocks other threads from entering
    defer{ lock.unlock() } // Releases the lock upon exiting function scope

    // Code implementation...
}

Limitaciones clave

  1. No es reentrante: No soporta reentrada en el mismo hilo (requiere NSRecursiveLock para estos casos).
  2. Inversión de Prioridad: Si un hilo con baja prioridad sostiene el lock y el hilo principal (Main Thread) intenta acceder a él, la interfaz puede congelarse.

Aplicación práctica en nuestro ejemplo

En el proyecto de prueba utilizamos NSLock para proteger la variable balance dentro de un entorno altamente concurrente:

final class NSLockAccount {
    private(set) var balance: Int = 0
    private let lock = NSLock()
    
    func deposit(completion: @escaping (Int) -> Void) {
        lock.lock() // Acquires the lock; blocks other threads from entering
        defer { lock.unlock() } // Releases the lock upon exiting function scope
        
        let current = balance
        Thread.sleep(forTimeInterval: 0.05) //Simulate work
        balance = current + 10
        completion(balance)
    }
}

Al envolver la lectura y escritura dentro del bloque lock.lock() y garantizar su liberación con defer { lock.unlock() }, logramos sincronización mutua y prevenimos la pérdida de datos. Esto se aprecia claramente en el código de ejemplo, donde las peticiones se encolan de forma ordenada para procesarse una a una sin perjudicar el resultado esperado.

Para revisar la implementación completa y el código del proyecto, puedes ir directamente a la Sección 3: Ejemplo Práctico e Interactivo donde encontrarás el enlace al repositorio de GitHub.


2. Alternativas en Swift Moderno

Aunque NSLock sigue vigente para código heredado u Objective-C, en el Swift actual preferimos alternativas más seguras, expresivas y nativas que se adapten a la versión y requisitos del proyecto.


A. Swift Concurrency (actor)

Introducido en Swift 5.5 (y reforzado con las comprobaciones de Strict Concurrency en Swift 6), el modelo de Actores aísla el estado mutable de forma automática. El propio compilador garantiza la seguridad de hilos (Thread Safety) sin necesidad de gestionar bloqueos manuales.

¿Qué es un Actor?

  • Tipo por referencia: Funciona de forma similar a una clase (class), pero diseñado explícitamente para programación concurrente segura.
  • Aislamiento de datos: Sus propiedades y métodos internos están protegidos contra accesos directos no autorizados desde el exterior.
  • Sin herencia: A diferencia de las clases, los actores no admiten herencia entre sí, aunque sí pueden conformar protocolos (como Sendable).

¿Cómo funcionan internamente?

  • Acceso serializado: Swift garantiza que solo una tarea (Task) pueda ejecutar código dentro de un actor a la vez.
  • Llamadas asíncronas: Para acceder a las propiedades o métodos de un actor desde fuera de su dominio de aislamiento, es obligatorio usar la palabra clave await.
  • Encolado automático: Si múltiples tareas intentan interactuar con el actor simultáneamente, el runtime de Swift las encola automáticamente.

Reentrancia en Actores (Actor Reentrancy)

  • Puntos de suspensión: Cuando el método de un actor alcanza un await, la tarea se suspende y libera el acceso al actor.
  • Intercalado (Interleaving): Mientras está suspendido, el actor puede procesar otras tareas entrantes para evitar deadlocks.
  • Verificación de estado: Como el estado mutable puede cambiar mientras una tarea está suspendida en un punto await, es crucial revalidar las suposiciones de código tras reanudar la ejecución.
/// Swift Actor automatically isolates mutable state at compile time.
actor ActorAccount {
    private(set) var balance: Int = 0
    
    func deposit() -> Int {
        let current = balance
        Thread.sleep(forTimeInterval: 0.05) //Simulate work
        balance = current + 10
        return balance
    }
}

Al implementar nuestra cuenta bancaria como un actor, delegamos la gestión de concurrencia directamente al compilador. En el código del proyecto, verás cómo las llamadas asíncronas dentro de un TaskGroup encolan las modificaciones de forma segura, evitando Race Conditions sin escribir un solo lock() o unlock().

Para ver cómo se integra este actor con el resto de métodos, puedes revisar directamente la Sección 3: Ejemplo Práctico e Interactivo.


B. Grand Central Dispatch (DispatchQueue)

Antes de la llegada de los actores, la forma idiómática y estándar en Swift para lograr sincronización mutua era utilizar Grand Central Dispatch (GCD).

Esta técnica consiste en crear una cola serial dedicada (DispatchQueue) que encola manualmente cada bloque de trabajo (lectura o escritura) para procesarlo de manera estrictamente secuencial.

Características clave

  • Disponibilidad amplia: Es compatible con versiones antiguas de la plataforma (iOS 4.0+ y ampliamente utilizado desde iOS 13.0+), lo que lo convierte en la alternativa directa a los actores en proyectos que no pueden adoptar Swift Concurrency.
  • Control manual: A diferencia de los actores, la sincronización no es automática; el desarrollador debe responsabilizarse de envolver manualmente cada acceso a la propiedad compartida dentro de queue.async o queue.sync.
  • Ejecución en hilos de fondo: Las tareas enviadas a una cola serial personalizada se ejecutan en hilos secundarios gestionados por el sistema, liberando al hilo principal.
/// Serial queue synchronization ensuring tasks are processed sequentially.
final class GCDAccount {
    private(set) var balance: Int = 0
    private let queue = DispatchQueue(label: "com.example.gcd")
    
    func deposit(completion: @escaping (Int) -> Void) {
        queue.async {
            let current = self.balance
            Thread.sleep(forTimeInterval: 0.05)
            self.balance = current + 10
            completion(self.balance)
        }
    }
}

Nota sobre async vs sync:
En nuestro ejemplo utilizamos queue.async para las operaciones de modificación (deposit). Esto permite enviar el trabajo a la cola serial de fondo de manera no bloqueante para el hilo llamante, manteniendo la aplicación fluida mientras la cola procesa cada incremento en estricto orden (FIFO). Para métodos de lectura donde necesitas devolver el valor de forma inmediata, se utiliza queue.sync, teniendo la precaución de no invocarlo dentro de la misma cola para evitar un Deadlock.


Aunque no ofrece las garantías en tiempo de compilación que brindan los actores, el uso de una cola serial con GCD sigue siendo una solución sólida y muy extendida para garantizar Thread Safety en bases de código tradicionales.


C. OSAllocatedUnfairLock (iOS 16+)

Introducido en iOS 16 y macOS 13, OSAllocatedUnfairLock es la alternativa moderna de bajo nivel que reemplaza directamente a NSLock y a las antiguas APIs C como os_unfair_lock. Pertenece al módulo os y está diseñado específicamente para entornos síncronos de alto rendimiento.

Características clave

  • Máxima velocidad y ligereza: Elimina el overhead de la jerarquía de clases de Objective-C de NSLock. Ofrece la latencia más baja posible para proteger secciones críticas breves.
  • Manejo explícito de estado: Permite encapsular el valor protegido dentro de la misma instancia del bloqueo (initialState), forzando a que las modificaciones se realicen dentro del bloque closure withLock.
  • API segura en Swift: A diferencia de os_unfair_lock (que requería manejo de punteros inmutables), OSAllocatedUnfairLock gestiona la asignación de memoria de forma segura.
final class UnfairLockAccount {
    private let lock = OSAllocatedUnfairLock(initialState: 0)
    
    var balance: Int {
        lock.withLock { $0 }
    }
    
    func deposit(completion: @escaping (Int) -> Void) {
        lock.withLock { state in
            let current = state
            Thread.sleep(forTimeInterval: 0.05)
            state = current + 10
            completion(state)
        }
    }
}

¿Por qué se llama “Unfair” (Injusto)?
Se denomina Unfair Lock porque no garantiza el orden de llegada (First In, First Out) para los hilos que compiten por el bloqueo. Si un hilo libera el bloqueo e intenta adquirirlo inmediatamente después, puede volver a ganarlo antes de que un hilo en espera despierte. Esta “injusticia” minimiza los cambios de contexto en la CPU (context switches) y maximiza el rendimiento global.


OSAllocatedUnfairLock es la mejor opción cuando necesitas exclusión mutua en código síncrono ultra rápido donde el modelo de async/await de los actores resulta innecesario o añade demasiada sobrecarga.


3. Ejemplo Práctico e Interactivo en SwiftUI

Para validar el comportamiento de cada mecanismo de sincronización en un escenario real, construimos una aplicación interactiva en SwiftUI que simula la ejecución simultánea de 50 hilos independientes.

Cada hilo intenta incrementar el saldo de una cuenta bancaria en $10. Al finalizar todas las ejecuciones, el resultado matemático esperado es de $500 (50 hilos × $10).


¿Qué observar durante la ejecución?

  1. Prueba Unsafe (Sin protección): Al presionar el botón rojo, verás que el saldo final termina drásticamente por debajo de 500 (frecuentemente $10 o $20). Esto ocurre porque múltiples hilos leen el mismo saldo inicial de forma simultánea antes de que los demás logren escribir la actualización, provocando una pérdida masiva de datos por Race Condition.
  2. Pruebas protegidas (NSLock, Actor, GCD, OSUnfairLock): Todos los métodos protegidos alcanzan exactamente $500, demostrando que la exclusión mutua se cumple correctamente.
  3. Historial de Logs en tiempo real: La aplicación incluye una lista inferior que captura la marca de tiempo exacta (milisegundos) y la dirección del hilo ejecutor para auditar cómo se encolan o aíslan los incrementos.

Demostración interactiva en SwiftUI simulando 50 hilos concurrentes

Código Completo (ContentView.swift)

A continuación se muestra la implementación del proyecto que integra las 5 alternativas evaluadas:

//
//  ContentView.swift
//  ios-lock-example
//
//  Created by Maximiliano Ovando Ramirez on 07/09/26.
//

import SwiftUI
import os

// MARK: - Models / Data Structures

/// Represents an execution log entry captured during concurrent operations.
struct OperationLog: Identifiable {
    let id = UUID()
    let timestamp: String
    let threadName: String
    let valueAfterDeposit: Int
}

// MARK: - 0. Unsafe Implementation (Race Condition Demo)

/// Demonstrates unsynchronized memory access. Multiple threads read and write simultaneously.
final class UnsafeAccount {
    var balance: Int = 0
    
    func deposit(completion: @escaping (Int) -> Void) {
        let current = balance
        // Simulate heavy work inside the thread to force a thread collision
        Thread.sleep(forTimeInterval: 1)
        balance = current + 10
        completion(balance)
    }
}

// MARK: - 1. NSLock Implementation

/// Traditional synchronization using NSLock to protect critical sections.
final class NSLockAccount {
    private(set) var balance: Int = 0
    private let lock = NSLock()
    
    func deposit(completion: @escaping (Int) -> Void) {
        lock.lock() // Acquires the lock; blocks other threads from entering
        defer { lock.unlock() } // Releases the lock upon exiting function scope
        
        let current = balance
        Thread.sleep(forTimeInterval: 0.05)
        balance = current + 10
        completion(balance)
    }
}

// MARK: - 2. Actor Implementation (Modern Swift Concurrency)

/// Swift Actor automatically isolates mutable state at compile time.
actor ActorAccount {
    private(set) var balance: Int = 0
    
    func deposit() -> Int {
        let current = balance
        Thread.sleep(forTimeInterval: 0.05)
        balance = current + 10
        return balance
    }
}

// MARK: - 3. DispatchQueue Implementation (GCD Serial Queue)

/// Serial queue synchronization ensuring tasks are processed sequentially.
final class GCDAccount {
    private(set) var balance: Int = 0
    private let queue = DispatchQueue(label: "com.example.gcd")
    
    func deposit(completion: @escaping (Int) -> Void) {
        queue.async {
            let current = self.balance
            Thread.sleep(forTimeInterval: 0.05)
            self.balance = current + 10
            completion(self.balance)
        }
    }
}

// MARK: - 4. OSAllocatedUnfairLock Implementation (iOS 16+ High Performance)

/// High-performance, lightweight lock ideal for modern iOS development.
final class UnfairLockAccount {
    private let lock = OSAllocatedUnfairLock(initialState: 0)
    
    var balance: Int {
        lock.withLock { $0 }
    }
    
    func deposit(completion: @escaping (Int) -> Void) {
        lock.withLock { balance in
            let current = balance
            Thread.sleep(forTimeInterval: 0.05)
            balance = current + 10
            completion(balance)
        }
    }
}

// MARK: - SwiftUI View

struct ContentView: View {
    @State private var balanceResult: Int = 0
    @State private var selectedMethod: String = "Ninguno"
    @State private var isProcessing: Bool = false
    @State private var logs: [OperationLog] = []
    
    private let iterations = 50 // Expected result: 50 * 10 = 500

    var body: some View {
        VStack(spacing: 20) {
            // Header summary card
            VStack(spacing: 8) {
                Text("Método actual: \(selectedMethod)")
                    .font(.subheadline)
                    .foregroundColor(.secondary)
                
                Text("Saldo final: \(balanceResult)")
                    .font(.system(size: 42, weight: .bold, design: .rounded))
                    .foregroundColor(balanceResult == 500 ? .green : (balanceResult == 0 ? .primary : .red))
                
                Text("Resultado esperado: 500")
                    .font(.caption)
                    .foregroundColor(.gray)
            }
            .padding()
            .frame(maxWidth: .infinity)
            .background(RoundedRectangle(cornerRadius: 16).fill(Color(.systemGray6)))
            
            // Interactive test controls
            if isProcessing {
                ProgressView("Ejecutando \(iterations) hilos...")
                    .padding()
            } else {
                VStack(spacing: 12) {
                    Button("1. Sin Protección (Unsafe)") {
                        runUnsafeTest()
                    }
                    .buttonStyle(.borderedProminent)
                    .tint(.red)

                    HStack(spacing: 8) {
                        Button("2. Con NSLock") {
                            runNSLockTest()
                        }
                        .buttonStyle(.bordered)

                        Button("3. Con Actor") {
                            runActorTest()
                        }
                        .buttonStyle(.bordered)
                    }

                    HStack(spacing: 8) {
                        Button("4. Con GCD Queue") {
                            runGCDTest()
                        }
                        .buttonStyle(.bordered)

                        Button("5. OSUnfairLock") {
                            runUnfairLockTest()
                        }
                        .buttonStyle(.bordered)
                    }
                }
            }

            // Real-time execution logs list
            VStack(alignment: .leading, spacing: 8) {
                HStack {
                    Text("Logs de ejecución (\(logs.count))")
                        .font(.headline)
                    Spacer()
                    if !logs.isEmpty {
                        Button("Limpiar") { logs.removeAll() }
                            .font(.caption)
                    }
                }

                List(logs) { item in
                    HStack {
                        VStack(alignment: .leading, spacing: 2) {
                            Text(item.threadName)
                                .font(.caption2)
                                .monospaced()
                                .foregroundColor(.blue)
                            Text(item.timestamp)
                                .font(.caption2)
                                .foregroundColor(.gray)
                        }
                        Spacer()
                        Text("Saldo: \(item.valueAfterDeposit)")
                            .font(.subheadline)
                            .bold()
                            .monospacedDigit()
                    }
                }
                .listStyle(.plain)
                .background(RoundedRectangle(cornerRadius: 12).stroke(Color(.systemGray4)))
            }
        }
        .padding()
    }

    // MARK: - Test Runners

    private func runUnsafeTest() {
        prepareTest(methodName: "Sin Protección")
        let account = UnsafeAccount()
        let group = DispatchGroup()
        
        for _ in 1...iterations {
            DispatchQueue.global().async(group: group) {
                account.deposit { newBalance in
                    self.recordLog(balance: newBalance)
                }
            }
        }
        
        group.notify(queue: .main) {
            self.balanceResult = account.balance
            self.isProcessing = false
        }
    }

    private func runNSLockTest() {
        prepareTest(methodName: "NSLock")
        let account = NSLockAccount()
        let group = DispatchGroup()
        
        for _ in 1...iterations {
            DispatchQueue.global().async(group: group) {
                account.deposit { newBalance in
                    self.recordLog(balance: newBalance)
                }
            }
        }
        
        group.notify(queue: .main) {
            self.balanceResult = account.balance
            self.isProcessing = false
        }
    }

    private func runActorTest() {
        prepareTest(methodName: "Actor (Swift Concurrency)")
        let account = ActorAccount()
        
        Task {
            await withTaskGroup(of: Void.self) { group in
                for _ in 1...iterations {
                    group.addTask {
                        let newBalance = await account.deposit()
                        self.recordLog(balance: newBalance)
                    }
                }
            }
            let finalBalance = await account.balance
            await MainActor.run {
                self.balanceResult = finalBalance
                self.isProcessing = false
            }
        }
    }

    private func runGCDTest() {
        prepareTest(methodName: "DispatchQueue (GCD)")
        let account = GCDAccount()
        let group = DispatchGroup()
        
        for _ in 1...iterations {
            group.enter()
            account.deposit { newBalance in
                self.recordLog(balance: newBalance)
                group.leave()
            }
        }
        
        group.notify(queue: .main) {
            self.balanceResult = account.balance
            self.isProcessing = false
        }
    }

    private func runUnfairLockTest() {
        prepareTest(methodName: "OSAllocatedUnfairLock")
        let account = UnfairLockAccount()
        let group = DispatchGroup()
        
        for _ in 1...iterations {
            DispatchQueue.global().async(group: group) {
                account.deposit { newBalance in
                    self.recordLog(balance: newBalance)
                }
            }
        }
        
        group.notify(queue: .main) {
            self.balanceResult = account.balance
            self.isProcessing = false
        }
    }

    // MARK: - Helper Methods

    private func prepareTest(methodName: String) {
        selectedMethod = methodName
        balanceResult = 0
        logs.removeAll()
        isProcessing = true
    }

    /// Thread-safe logger capturing the current thread address and accurate timestamp.
    private func recordLog(balance: Int) {
        let formatter = DateFormatter()
        formatter.dateFormat = "HH:mm:ss.SSS"
        let timestamp = formatter.string(from: Date())
        
        let threadName = Thread.isMainThread ? "Main Thread" : "Thread-\(String(format: "%p", Thread.current))"
        let newLog = OperationLog(timestamp: timestamp, threadName: threadName, valueAfterDeposit: balance)
        
        DispatchQueue.main.async {
            self.logs.append(newLog)
        }
    }
}

Repositorio del Proyecto

Puedes descargar, clonar y probar el proyecto Xcode completo directamente desde el repositorio oficial en GitHub:

👉 Ver proyecto en GitHub: ios-lock-example


Conclusiones

  • NSLock fue el estándar para la concurrencia tradicional en iOS/macOS, pero actualmente debe reservarse para código heredado u interoperabilidad con Objective-C.
  • Para desarrollos modernos en Swift, actor es la opción principal recomendada por Apple al integrarse con el compilador para prevenir Data Races.
  • Para secciones críticas síncronas de bajo nivel y máxima velocidad, OSAllocatedUnfairLock es el reemplazo nativo ideal a partir de iOS 16+.