Mutex en Swift: protege el estado mutable compartido sin renunciar al rendimiento
Arturo Rivas Arias
🔒 La concurrencia en Swift ha madurado mucho con Swift 6 y el modelo de actores, pero hay situaciones donde los actores no son la herramienta correcta, ni siquiera suficente. El tipo Mutex ha sido introducido en el framework Synchronization junto a iOS 18, y cubre ese espacio: protección síncrona, de bajo nivel y alto rendimiento sobre estado mutable compartido.
⚙️ Un Mutex (del inglés mutual exclusion) garantiza que solo un hilo puede acceder al valor protegido en un instante determinado. A diferencia de los actores, que suspenden la tarea con await y dejan que el planificador de Swift Concurrency tome el control, un Mutex bloquea el hilo hasta que el recurso queda libre. Esta diferencia es fundamental: bloqueante contra suspensivo. No hay una opción universalmente mejor; hay una opción correcta para cada escenario.
🧵 Para usar Mutex hay que importar el framework Synchronization, disponible a partir de iOS 18, macOS 15, watchOS 11 y tvOS 18. El tipo es genérico: encapsula el valor que quieres proteger y solo permite acceder a él a través del método withLock. No hay forma de leer o escribir el dato directamente. El compilador te lo impide.
import Synchronization
// Un registro de eventos de audio protegido por Mutex
final class AudioEventLog: Sendable {
private let events = Mutex<[String]>([])
func record(_ event: String) {
events.withLock { log in
log.append("[\(Date())] \(event)")
}
}
func recentEvents(count: Int) -> [String] {
events.withLock { log in
Array(log.suffix(count))
}
}
}
El dato vive dentro del Mutex. No hay forma de acceder al array de eventos sin pasar por withLock, lo que elimina por diseño cualquier posibilidad de data race.
📊 La pregunta inevitable es cuándo elegir Mutex sobre un actor. En benchmarks simples, Mutex puede superar a los actores en la mayoría de operaciones recursivas, porque evita el coste de la suspensión de tareas y la replanificación de tareas. Para operaciones cortas que duran microsegundos, los mecanismos bloqueantes de hilo tienen menos sobrecarga que la suspensión de tareas, que implica alojar continuaciones, planificar tareas y potencialmente cambiar de hilo.
La tabla de decisión sería algo como:
| Escenario | Herramienta recomendada |
|---|---|
| Contexto síncrono, operación rápida | Mutex |
| Acceso frecuente desde múltiples hilos | Mutex |
| Lógica de dominio compleja | Actor |
| Trabajo asíncrono dentro del bloque | Actor |
| Lectura frecuente, escritura esporádica | DispatchQueue con barrier |
🆕 Una de las ventajas más relevantes de Mutex en Swift 6 es que el compilador entiende su semántica de concurrencia y vincula mutabilidad con seguridad de concurrencia, haciendo que la conformidad a Sendable la verifique el compilador en lugar de depender de la disciplina del desarrollador. Esto significa que puedes eliminar @unchecked Sendable de tus clases y dejar que el sistema de tipos haga el trabajo.
import Synchronization
// ❌ Antes de Mutex: dependía de que el desarrollador no cometiese errores
final class MetronomeState: @unchecked Sendable {
private var bpm: Int = 120
private let lock = NSLock()
func setBPM(_ newBPM: Int) {
lock.lock()
defer { lock.unlock() }
bpm = newBPM
}
}
// ✅ Con Mutex: el compilador verifica la seguridad automáticamente
final class MetronomeState: Sendable {
private let _bpm = Mutex<Int>(120)
func setBPM(_ newBPM: Int) {
_bpm.withLock { bpm in
bpm = newBPM
}
}
var currentBPM: Int {
_bpm.withLock { $0 }
}
}
⚠️ El uso de nonisolated merece atención especial cuando el objeto que contiene el Mutex vive en un contexto aislado por @MainActor. En Swift 6, puedes encontrar una advertencia indicando que un método aislado al actor principal no puede llamarse desde fuera del actor. La solución es marcar la propiedad como nonisolated, lo que le indica al compilador que la referencia es segura para usar desde cualquier contexto.
import Synchronization
@MainActor
final class SynthesizerViewModel: ObservableObject {
// nonisolated permite acceder a este Mutex desde cualquier hilo
nonisolated let parameterCache = Mutex<[String: Float]>([:])
func applyPreset(_ preset: [String: Float]) {
Task.detached {
self.parameterCache.withLock { cache in
for (key, value) in preset {
cache[key] = value
}
}
}
}
}
🚫 ¡Ojo! Mutex no soporta reentrada. Llamar a withLock sobre el mismo Mutex desde dentro de un cierre withLock activo produce un deadlock inmediato. El hilo queda bloqueado esperando un recurso que él mismo retiene. No hay detección automática en tiempo de ejecución: el programa simplemente se congela. La reentrada es un comportamiento que los actores implementan por defecto para evitar bloqueos.
let sequencerSteps = Mutex<[Int]>([])
// ❌ Deadlock garantizado: withLock anidado sobre el mismo Mutex
sequencerSteps.withLock { steps in
steps.append(1)
sequencerSteps.withLock { inner in // 💀 Bloqueo aquí
inner.append(2)
}
}
// ✅ Correcto: toda la lógica dentro de un único withLock
sequencerSteps.withLock { steps in
steps.append(1)
steps.append(2)
}
🔧 Bajo el capó, Mutex se implementa usando os_unfair_lock en plataformas Apple, el mismo mecanismo de bajo nivel que OSAllocatedUnfairLock, pero con una API más ergonómica y consciente del sistema de tipos de Swift 6. Esto explica su rendimiento: os_unfair_lock es una de las primitivas de sincronización más ligeros disponibles en Darwin.
📱 El único punto delicado es la disponibilidad: Mutex requiere iOS 18+. Si necesitas soportar versiones anteriores, puedes implementar un tipo similar usando OSAllocatedUnfairLock u otras primitivas disponibles en versiones anteriores de iOS. La API pública puede ser idéntica; lo que cambia es la implementación interna.
import Synchronization
// Wrapper con disponibilidad condicional para proyectos con deployment target < iOS 18
final class SafeCounter: Sendable {
@available(iOS 18, macOS 15, *)
private let _modernCounter = Mutex<Int>(0)
// Fallback para iOS < 18 usando OSAllocatedUnfairLock
private let _legacyCounter = OSAllocatedUnfairLock(initialState: 0)
func increment() {
if #available(iOS 18, macOS 15, *) {
_modernCounter.withLock { $0 += 1 }
} else {
_legacyCounter.withLock { $0 += 1 }
}
}
var value: Int {
if #available(iOS 18, macOS 15, *) {
_modernCounter.withLock { $0 }
} else {
_legacyCounter.withLock { $0 }
}
}
}
🎯 La regla de oro para usar Mutex correctamente es simple: mantén el bloque a ejectuar dentro de withLock lo más corto posible. Evita realizar I/O, networking o cómputo pesado mientras el lock está activo. El Mutex bloquea el hilo entero durante ese tiempo, y un bloque largo se convierte en un cuello de botella que degrada el rendimiento de toda la aplicación. Si la lógica dentro del bloque necesita ser asíncrona, ese es el momento de reconsiderar si un actor no sería la opción correcta.
👨💻 Mutex llena un hueco real que los actores no cubren bien: sincronización sincrónica, de ejecución recurrente y bajo sobrecarga, con verificación estática de concurrencia por parte del compilador. No reemplaza a los actores; los complementa. Conocer cuándo usar cada herramienta es lo que distingue un código concurrente correcto de uno que simplemente parece correcto.