Actores en Swift: actor, @MainActor, Sendable y seguridad ante data races

Los data races son uno de los bugs más difíciles de reproducir y depurar: el programa falla de forma intermitente dependiendo del orden exacto en que los hilos acceden a la memoria. Swift 5.5 introdujo los actores como solución nativa a este problema, garantizando que el acceso a su estado interno sea siempre seguro y secuencial, independientemente del número de tareas concurrentes que intenten acceder.

Qué es un actor

Un actor es un tipo de referencia similar a una clase pero con una diferencia fundamental: solo permite que una tarea acceda a su estado mutable en cada momento. Desde fuera del actor, cualquier acceso a sus propiedades o métodos requiere await, porque el sistema puede necesitar esperar a que el actor esté libre:

actor Contador {
    private var valor: Int = 0

    func incrementar() {
        valor += 1
    }

    func obtenerValor() -> Int {
        return valor
    }
}

let contador = Contador()

Task {
    await contador.incrementar()
    let v = await contador.obtenerValor()
    print(v)
}

Dentro del actor, el código puede acceder a valor directamente sin await, porque ya estás en el contexto protegido del actor. Desde fuera, siempre necesitas await.

Métodos nonisolated

No todo tiene que vivir dentro del aislamiento del actor. Con nonisolated, marcas un método o propiedad para que se ejecute fuera del actor y pueda llamarse sin await:

actor ServidorEstadisticas {
    private var peticiones: Int = 0
    let nombre: String // Inmutable, no necesita aislamiento

    init(nombre: String) {
        self.nombre = nombre
    }

    nonisolated var descripcion: String {
        "Servidor: (nombre)" // Solo accede a 'nombre', que es inmutable
    }

    func registrarPeticion() {
        peticiones += 1
    }
}

let servidor = ServidorEstadisticas(nombre: "API Principal")
print(servidor.descripcion) // Sin await, porque es nonisolated

Las propiedades constantes (let) son implícitamente nonisolated porque no pueden causar data races al ser inmutables.

@MainActor: el actor del hilo principal

El hilo principal es el único lugar seguro para actualizar la interfaz de usuario en iOS y macOS. Swift proporciona @MainActor, un actor global que representa ese hilo. Puedes aplicarlo a propiedades, métodos, closures o tipos enteros:

@MainActor
class VistaModelo: ObservableObject {
    @Published var usuarios: [Usuario] = []
    @Published var cargando = false

    func cargarUsuarios() async {
        cargando = true
        defer { cargando = false }

        do {
            // fetch puede ejecutarse en cualquier hilo
            let resultado = try await APIService.shared.fetchUsuarios()
            // Pero como estamos en @MainActor, la asignación
            // siempre ocurre en el hilo principal
            usuarios = resultado
        } catch {
            print("Error: (error)")
        }
    }
}

Cuando marcas una clase entera con @MainActor, todos sus métodos y propiedades se ejecutan en el hilo principal por defecto. Si necesitas que un método concreto salga de ese contexto, usa nonisolated.

Llamar a @MainActor desde código en background

La forma idiomática de saltar al hilo principal desde una tarea en background es await MainActor.run:

func procesarEnBackground() async {
    let resultado = await calcularEnBackground()

    await MainActor.run {
        // Este código se ejecuta en el hilo principal
        actualizarInterfaz(con: resultado)
    }
}

También puedes usar Task { @MainActor in ... } para crear una tarea que se ejecute directamente en el actor principal sin necesitar run.

Sendable: tipos seguros para transferencia entre tareas

El protocolo Sendable marca tipos que pueden pasarse de forma segura entre tareas concurrentes sin riesgo de data races. El compilador verifica este protocolo y emite advertencias (o errores en Swift 6) cuando intentas pasar tipos no Sendable entre contextos de concurrencia:

// Struct con todos sus campos Sendable: automáticamente Sendable
struct Coordenada: Sendable {
    let latitud: Double
    let longitud: Double
}

// Clase que gestiona su propio aislamiento
final class Cache: @unchecked Sendable {
    private let lock = NSLock()
    private var almacen: [String: Any] = [:]

    func guardar(_ valor: Any, clave: String) {
        lock.lock()
        defer { lock.unlock() }
        almacen[clave] = valor
    }
}

El compilador infiere automáticamente Sendable para:

  • Tipos de valor (struct, enum) cuyos campos son todos Sendable
  • Tipos inmutables (let en clase final)
  • Actores (que gestionan su propio aislamiento)

@Sendable para closures

Los closures que se pasan a tareas asíncronas deben ser @Sendable, lo que significa que no pueden capturar estado mutable de forma insegura:

func procesarItems(_ items: [Item]) {
    var resultados: [Resultado] = []

    // Error en Swift 6: closure captura 'resultados' (var) de forma insegura
    Task {
        for item in items {
            let r = await procesar(item)
            resultados.append(r) // ?? Data race potencial
        }
    }
}

// Solución: usar actor o retornar los resultados
func procesarItemsSafe(_ items: [Item]) async -> [Resultado] {
    await withTaskGroup(of: Resultado.self) { group in
        for item in items {
            group.addTask { await procesar(item) }
        }
        var resultados: [Resultado] = []
        for await r in group { resultados.append(r) }
        return resultados
    }
}

Actores globales personalizados con @globalActor

Puedes crear tus propios actores globales para aislar grupos de código que deben ejecutarse en el mismo contexto:

@globalActor
actor BaseDatosActor {
    static let shared = BaseDatosActor()
}

@BaseDatosActor
class RepositorioUsuarios {
    func guardar(_ usuario: Usuario) async {
        // Siempre se ejecuta en el contexto de BaseDatosActor
        try? await baseDatos.insertar(usuario)
    }

    func buscar(id: Int) async -> Usuario? {
        return try? await baseDatos.query("SELECT * WHERE id = (id)")
    }
}

@BaseDatosActor
class RepositorioPedidos {
    // También vive en el mismo actor — sin data races entre ellos
}

Los actores globales son útiles cuando tienes varios tipos que deben compartir el mismo contexto de ejecución sin necesidad de pasarse referencias entre sí.

Ejemplo real: caché con actor

actor ImagenCache {
    private var cache: [URL: UIImage] = [:]
    private var descargasEnCurso: [URL: Task] = [:]

    func imagen(para url: URL) async throws -> UIImage {
        // Si ya está en caché, retorna inmediatamente
        if let imagen = cache[url] { return imagen }

        // Si ya hay una descarga en curso para esta URL, espera a que termine
        if let tareaExistente = descargasEnCurso[url] {
            return try await tareaExistente.value
        }

        // Nueva descarga
        let tarea = Task {
            let (datos, _) = try await URLSession.shared.data(from: url)
            guard let imagen = UIImage(data: datos) else {
                throw ImagenError.formatoInvalido
            }
            return imagen
        }

        descargasEnCurso[url] = tarea

        do {
            let imagen = try await tarea.value
            cache[url] = imagen
            descargasEnCurso[url] = nil
            return imagen
        } catch {
            descargasEnCurso[url] = nil
            throw error
        }
    }
}

Este patrón garantiza que nunca se lanzarán dos descargas simultáneas para la misma URL, y que todos los accesos a cache y descargasEnCurso son seguros sin ningún lock manual.

Resumen

Los actores en Swift son la solución idiomática a los data races: protegen el estado mutable de forma automática y hacen que los abusos sean errores de compilación, no bugs en producción. @MainActor resuelve el problema eterno de actualizar la UI desde el hilo incorrecto, Sendable propaga la seguridad a los tipos que cruzan contextos, y los actores globales permiten crear dominios de ejecución propios. Juntos forman un sistema coherente que hace que la concurrencia segura sea el camino de menor resistencia.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP