Sendable en Swift: datos seguros entre dominios de aislamiento
Arturo Rivas Arias
Al adoptar la concurrencia de Swift es habitual encontrarse con diagnósticos como «sending value of non-Sendable type risks causing data races». El mensaje puede aparecer al llamar a un actor, crear una tarea o entregar un closure a una API, incluso cuando el código no menciona hilos ni parece ejecutar dos operaciones a la vez.
La clave está en dejar de pensar en hilos y observar quién puede acceder a cada valor. Swift divide el programa en dominios de aislamiento: un actor, el actor principal y determinadas tareas pueden ejecutar código de forma independiente. Cuando un valor cruza de uno de esos dominios a otro, el compilador necesita saber que ambos no podrán modificar simultáneamente el mismo estado. Sendable expresa precisamente esa garantía.
Sendable describe una propiedad de seguridad
Sendable es un protocolo de marcado: no exige métodos ni propiedades. Su conformidad declara que los valores de un tipo pueden compartirse entre contextos concurrentes sin crear una carrera de datos (data race). El compilador comprueba esa promesa a partir de la composición del tipo.
Un cruce de aislamiento ocurre, por ejemplo, al enviar un argumento a un actor:
struct InventoryItem: Sendable {
let id: UUID
let name: String
let units: Int
}
actor InventoryStore {
private var items: [UUID: InventoryItem] = [:]
func save(_ item: InventoryItem) {
items[item.id] = item
}
}
let store = InventoryStore()
let item = InventoryItem(id: UUID(), name: "Cable USB-C", units: 12)
await store.save(item)
La llamada entra desde el aislamiento del código que la realiza en el aislamiento de InventoryStore. Como InventoryItem conforma Sendable, el compilador sabe que transferir ese valor es seguro.
Esto no implica que InventoryItem sea constante para siempre. Una variable que contiene una estructura puede cambiar, pero cada dominio trabaja con su propio valor. Lo relevante no es la presencia de propiedades var por sí sola, sino que no exista estado mutable compartido sin protección.
Los tipos de valor facilitan la conformidad
Las estructuras y enumeraciones pueden conformar Sendable cuando todas sus propiedades almacenadas o valores asociados también son Sendable. Tipos habituales como String, Int, UUID y colecciones cuyos elementos son enviables ya satisfacen el requisito.
Swift puede inferir la conformidad para estructuras y enumeraciones no públicas, y también en ciertos tipos públicos @frozen. Aun así, declarar la conformidad explícitamente suele ser útil cuando forma parte del contrato del modelo:
public enum StockState: Sendable {
case available(units: Int)
case backOrder(expectedDate: Date)
case discontinued
}
public struct ProductSnapshot: Sendable {
public let code: String
public let state: StockState
}
La declaración también protege la evolución del tipo. Si posteriormente se añade una propiedad que no es Sendable, el error aparece en la propia definición, cerca de la decisión que ha roto el contrato:
final class EditingSession {
var notes: [String] = []
}
struct ProductSnapshot: Sendable {
let code: String
let session: EditingSession // ❌ EditingSession no es Sendable
}
Para tipos públicos, hacer explícita la conformidad evita además que una decisión de implementación quede confundida con una garantía estable de la API.
Las clases requieren una garantía más fuerte
Una instancia de una clase tiene identidad compartida. Enviar la referencia a dos tareas no produce dos objetos independientes: ambas pueden apuntar al mismo almacenamiento. Por eso una clase comprobada como Sendable debe ser final, tener propiedades almacenadas inmutables y Sendable, y no heredar salvo, en los casos admitidos, de NSObject.
final class MusicCatalogConfiguration: Sendable {
let endpoint: URL
let supportedFormats: Set<String>
init(endpoint: URL, supportedFormats: Set<String>) {
self.endpoint = endpoint
self.supportedFormats = supportedFormats
}
}
final impide que una subclase añada estado mutable y destruya silenciosamente la garantía. Las propiedades let aseguran que, una vez construido el objeto, dos contextos no puedan competir por modificarlo.
Si una clase necesita mutación compartida, añadir Sendable no la sincroniza. Las alternativas derivan del modelo de propiedad: convertirla en estructura si no necesita identidad, hacerla inmutable si solo representa una configuración o aislar su estado mutable en un actor.
actor PlaybackHistory {
private var trackIDs: [UUID] = []
func record(_ trackID: UUID) {
trackIDs.append(trackID)
}
func recentTrackIDs() -> [UUID] {
trackIDs.suffix(20)
}
}
Los actores conforman implícitamente Sendable. La referencia al actor se puede compartir porque el acceso a su estado aislado se serializa. El array que devuelve recentTrackIDs() también es Sendable porque contiene valores UUID que son Sendable.
@MainActor ofrece una garantía análoga para el estado ligado a la interfaz, pero no debe utilizarse como solución universal. Aislar en el actor principal una caché, un índice de búsqueda o un cálculo intensivo puede trasladar trabajo innecesario a la UI. La elección correcta consiste en situar cada estado en el dominio que realmente lo posee.
@Sendable aplica la regla a funciones y closures
Las funciones pueden capturar referencias y variables de su entorno. Un closure que vaya a ejecutarse desde un contexto concurrente se marca con @Sendable; sus capturas deben ser seguras para compartir.
func filteredCodes(
from items: [InventoryItem],
using predicate: @Sendable (InventoryItem) -> Bool
) -> [UUID] {
items.filter(predicate).map(\.id)
}
let minimumUnits = 5
let codes = filteredCodes(from: items) { item in
item.units >= minimumUnits
}
La captura de minimumUnits es segura porque Int es Sendable y se captura por valor. El problema aparece al capturar estado mutable compartido:
var inspectedCount = 0
let codes = filteredCodes(from: items) { item in
inspectedCount += 1 // ❌ Mutación de una variable capturada concurrentemente
return item.units > 0
}
Aunque la implementación actual de filteredCodes invoque el predicado secuencialmente, su firma permite transportar el closure con seguridad. Una API solo debería exigir @Sendable cuando realmente vaya a compartir la función entre dominios; añadir el atributo por sistema restringe capturas sin aportar una garantía necesaria.
Cuando se necesita una instantánea del valor de una variable, una lista de captura permite separar el valor enviado del almacenamiento original:
var threshold = 5
let predicate: @Sendable (InventoryItem) -> Bool = { [threshold] item in
item.units >= threshold
}
threshold = 10
El closure conserva el valor 5; la modificación posterior afecta a otra variable y no crea acceso concurrente al mismo almacenamiento.
Mutex permite una conformidad asegurada
El framework Synchronization está diseñado para participar en el modelo de concurrencia de Swift. Su tipo Mutex conforma Sendable y encapsula el valor protegido, que solo puede consultarse o modificarse mientras se mantiene el bloqueo. Por eso una clase final que almacena un Mutex inmutable puede declarar una conformidad comprobada a Sendable:
import Synchronization
final class DownloadMetrics: Sendable {
private let counters = Mutex<[String: Int]>([:])
func record(host: String) {
counters.withLock { values in
values[host, default: 0] += 1
}
}
func count(for host: String) -> Int {
counters.withLock { values in
values[host, default: 0]
}
}
}
Aquí no hace falta @unchecked: el diccionario forma parte del almacenamiento interno de Mutex y withLock proporciona acceso exclusivo mediante un parámetro inout. No es posible añadir accidentalmente otra propiedad que lea ese diccionario sin bloqueo, porque el estado protegido no existe como propiedad independiente de DownloadMetrics.
Un bloqueo sigue siendo una herramienta síncrona: si está ocupado, bloquea el hilo que intenta adquirirlo. Resulta apropiado para secciones críticas pequeñas que no suspendan la ejecución. No se debe ejecutar await ni realizar trabajo lento dentro de withLock; cuando el estado requiere operaciones asíncronas o una coordinación de mayor nivel, un actor suele representar mejor el problema.
@unchecked Sendable traslada la prueba al programador
@unchecked Sendable sigue siendo necesario cuando un tipo es seguro por un mecanismo que el compilador no puede comprobar, como una implementación heredada que mantiene el bloqueo y el estado mutable en propiedades separadas o una librería externa con su propia garantía de sincronización:
import Foundation
final class LegacyDownloadMetrics: @unchecked Sendable {
private let lock = NSLock()
private var counters: [String: Int] = [:]
func record(host: String) {
lock.withLock {
counters[host, default: 0] += 1
}
}
func count(for host: String) -> Int {
lock.withLock {
counters[host, default: 0]
}
}
}
En este caso el compilador ve una propiedad var en una clase compartida, pero no puede demostrar que cada acceso sucede bajo lock. La conformidad depende de que la variación de counters nunca se toca fuera de withLock. Una propiedad añadida más tarde que incumpla esa regla introduciría un data race y el compilador seguiría confiando en la declaración.
⚠️ @unchecked Sendable no convierte un tipo en seguro: desactiva la comprobación estática y hace responsable al desarrollador. Conviene reservarlo para implementaciones auditadas y buscar otras soluciones para código nuevo. En código moderno, Synchronization.Mutex permite expresar secciones críticas pequeñas sin renunciar a la conformidad estrictamente comprobada; para estado mutable con operaciones asíncronas, al igual que en la sección anterior, un actor suele ser la abstracción adecuada.
Un valor no Sendable también puede transferirse
Sendable es una propiedad global del tipo, pero Swift 6 incorpora un análisis más preciso llamado aislamiento basado en regiones. El compilador puede permitir que una instancia concreta de un tipo no Sendable cruce una frontera si demuestra que deja de estar accesible desde su región original.
final class ReportDraft {
var lines: [String] = []
}
actor ReportArchive {
private var drafts: [ReportDraft] = []
func store(_ draft: sending ReportDraft) {
drafts.append(draft)
}
}
func archiveNewReport(in archive: ReportArchive) async {
let draft = ReportDraft()
draft.lines.append("Inventario revisado")
await archive.store(draft)
// Usar draft de nuevo aquí sería inseguro.
}
El modificador sending expresa una transferencia: el receptor puede quedarse con el valor y el llamante no debe conservar acceso que pudiera competir con él. No afirma que todas las instancias de ReportDraft sean compartibles; describe el movimiento seguro de esta instancia concreta.
Esta distinción mejora el modelo mental. Un valor Sendable puede copiarse o compartirse libremente entre dominios según su semántica. Un valor no Sendable puede moverse una vez cuando el compilador demuestra que no quedan “alias” utilizables en el origen. Si el código intenta leer o modificar draft después de enviarlo, el análisis detecta el riesgo.
Cómo interpretar un diagnóstico de envío
Cuando el compilador señala un valor no Sendable, añadir atributos hasta silenciarlo suele ocultar la causa. El proceso más fiable comienza identificando la frontera: una llamada a un actor, una tarea, un retorno aislado o un parámetro @Sendable. Después hay que localizar cuál es el estado mutable que podría quedar accesible desde ambos lados.
La solución se desprende de la propiedad deseada:
- Si son datos independientes, utilizar una estructura compuesta por valores
Sendable. - Si es una referencia que no cambia, usar una clase
finalcon propiedadesletque seanSendable. - Si existe estado mutable compartido, asignarle un propietario mediante un actor.
- Si el valor cambia de propietario, expresar la transferencia con
sendingy dejar de usarlo en el origen. - Si una sincronización manual ya garantiza la seguridad, intentar usar el framework
Synchronizationantes de recurrir a@unchecked Sendable.
Sendable evita carreras de datos, no todos los errores de concurrencia
El compilador puede impedir que dos tareas accedan sin sincronización al mismo estado mutable, pero no garantiza que las operaciones ocurran en el orden que necesita el negocio. Dos llamadas válidas a un actor están libres de carreras de datos y, aun así, pueden llegar en un orden diferente al esperado. Esto se conoce como una condición de carrera o race condition. Tampoco convierte una secuencia de varias llamadas al actor en una operación atómica.
Por tanto, Sendable forma parte de una garantía más amplia: los valores seguros cruzan las fronteras, los actores protegen el estado compartido y la concurrencia estructurada delimita el ciclo de vida de las tareas. La lógica todavía debe diseñar el orden, la cancelación y la atomicidad de forma explícita.
✅ La pregunta práctica ante cada diagnóstico no es «¿cómo hago que el compilador acepte este tipo?», sino «¿por qué este valor puede verse desde dos dominios?». Si debe compartirse, se diseña como Sendable o se protege con aislamiento. Si debe transferirse, se entrega sin conservar acceso en el origen. Esa decisión explícita sobre propiedad y mutación es la que permite a Swift verificar en compilación que el código concurrente no introduce carreras de datos.