La concurrencia en Swift no consiste únicamente en ejecutar varias tareas a la vez. Una parte fundamental del modelo introducido con Swift Concurrency es impedir que dos contextos concurrentes accedan de forma insegura al mismo estado mutable. Sendable y @Sendable son dos de las herramientas que permiten al compilador comprobarlo incluso antes de que el código llegue a ejecutarse.
Sendable es un protocolo marcador. No define métodos ni propiedades que debamos implementar, sino una garantía semántica: un valor de un tipo Sendable puede transferirse entre distintos dominios de concurrencia sin introducir una carrera de datos. Esto ocurre, por ejemplo, cuando enviamos un valor a un actor, lo devolvemos desde una tarea o lo capturamos desde código que puede ejecutarse concurrentemente.
struct SearchQuery: Sendable {
let text: String
let page: Int
}
Este tipo puede ser Sendable porque todas sus propiedades almacenadas también lo son. String e Int son tipos seguros para transferirse entre dominios de concurrencia, por lo que una instancia de SearchQuery no contiene estado compartido problemático.
Los tipos por valor encajan especialmente bien con este modelo. Una struct o un enum puede conformar a Sendable cuando todas sus propiedades almacenadas o valores asociados también son Sendable. Al transferir un valor de este tipo no estamos compartiendo necesariamente una referencia a un estado mutable, sino un valor cuya semántica permite al compilador evaluar su seguridad en el acceso.
struct DownloadRequest: Sendable {
let url: URL
let destination: URL
}
enum ExportFormat: Sendable {
case json
case csv(separator: Character)
}
Además, Swift puede inferir la conformidad a Sendable para determinados tipos de valor cuando todas sus propiedades cumplen los requisitos necesarios. Sin embargo, declarar la conformidad explícitamente sigue siendo útil cuando queremos expresar que la posibilidad de cruzar dominios de concurrencia forma parte del contrato del tipo.
La situación cambia con las clases. Al tener semántica por referencia, dos tareas podrían conservar una referencia al mismo objeto y modificarlo simultáneamente. Por eso una clase con estado mutable no puede convertirse en Sendable simplemente añadiendo el protocolo.
final class DownloadProgress {
var completedBytes = 0
}
Si una instancia de DownloadProgress se comparte entre varias tareas, todas estarían accediendo al mismo completedBytes. El compilador no dispone de ninguna garantía de que esos accesos estén sincronizados y, por tanto, el tipo no es seguro para cruzar libremente dominios de concurrencia.
Una clase sí puede ser Sendable cuando su diseño garantiza que no existe estado mutable compartido inseguro. El caso más sencillo es un tipo inmutable:
final class APIConfiguration: Sendable {
let baseURL: URL
let timeout: Duration
init(baseURL: URL, timeout: Duration) {
self.baseURL = baseURL
self.timeout = timeout
}
}
Otra opción consiste en aislar el estado mediante un actor. Los actores serializan el acceso a sus propiedades aisladas y forman parte directamente del modelo de seguridad de Swift Concurrency.
actor ImageCache {
private var images: [URL: Data] = [:]
func image(for url: URL) -> Data? {
images[url]
}
func store(_ data: Data, for url: URL) {
images[url] = data
}
}
Aquí no necesitamos convertir una clase mutable convencional en un objeto supuestamente seguro. El propio aislamiento del actor define cómo puede accederse al estado compartido.
Existe también @unchecked Sendable, pero su nombre ya deja clara la responsabilidad que asumimos. Con él indicamos al compilador que un tipo es seguro aunque Swift no pueda demostrarlo. Las comprobaciones automáticas desaparecen y somos nosotros quienes debemos garantizar la sincronización.
import Foundation
final class Counter: @unchecked Sendable {
private let lock = NSLock()
private var value = 0
func increment() {
lock.lock()
value += 1
lock.unlock()
}
func currentValue() -> Int {
lock.lock()
defer { lock.unlock() }
return value
}
}
En este caso la conformidad puede tener sentido porque el acceso al estado mutable está protegido manualmente. Pero @unchecked Sendable no vuelve seguro el código por sí mismo: únicamente evita que el compilador siga comprobándolo. Usarlo para silenciar un error sin disponer de una estrategia real de sincronización elimina precisamente una de las principales ventajas de Swift Concurrency.
Hasta aquí hemos hablado de datos, pero las funciones también pueden atravesar dominios de concurrencia. Un bloque (closure) puede almacenarse, enviarse a otro actor o ejecutarse desde una tarea distinta. Como los tipos función no conforman directamente a protocolos de la misma forma que una struct o una clase, Swift utiliza el atributo @Sendable.
func performInBackground(
operation: @escaping @Sendable () async -> Void
) {
Task {
await operation()
}
}
@Sendable no significa simplemente que el bloque sea asíncrono. Significa que el bloque debe ser seguro para transferirse y potencialmente ejecutarse desde otro dominio de concurrencia. La consecuencia más importante es que sus capturas también deben ser seguras.
let endpoint = URL(string: "https://example.com/catalog")!
let operation: @Sendable () -> Void = {
print(endpoint)
}
Como URL es un valor que puede transferirse de forma segura, la captura no representa un problema. Sin embargo, capturar una referencia mutable no protegida provoca un diagnóstico del compilador.
final class RequestLog {
var entries: [String] = []
}
func startLogging() {
let log = RequestLog()
let operation: @Sendable () -> Void = {
// Error en comprobación estricta de concurrencia:
// captura de un tipo no Sendable en un bloque @Sendable
log.entries.append("Petición iniciada")
}
operation()
}
El problema no es el append en sí, sino que log es una referencia a un objeto mutable que podría terminar siendo utilizado simultáneamente desde distintos contextos. Al exigir @Sendable, Swift analiza las capturas y rechaza aquellas que no cumplen las garantías necesarias.
También hay una diferencia importante entre capturar un valor y capturar una variable mutable. Los bloques @Sendable pueden ejecutarse concurrentemente, por lo que modificar una variable local capturada puede crear una carrera de datos (data race).
func processItems() {
var processed = 0
let operation: @Sendable () -> Void = {
// No es seguro modificar una variable capturada
// desde código potencialmente concurrente.
processed += 1
}
operation()
}
Swift 6 es especialmente estricto con este tipo de situaciones. Un patrón que antes podía compilar con una advertencia puede convertirse en error al activar el modo de lenguaje Swift 6 y la comprobación estricta de concurrencia.
Una solución habitual no consiste en buscar una forma de convencer al compilador, sino en cambiar dónde vive el estado. Si varios contextos necesitan modificarlo, un actor suele representar mejor la intención.
actor ProcessingMetrics {
private var processed = 0
func registerItem() {
processed += 1
}
func total() -> Int {
processed
}
}
func process(using metrics: ProcessingMetrics) async {
let operation: @Sendable () async -> Void = {
await metrics.registerItem()
}
await operation()
}
Esta diferencia ayuda a entender por qué Sendable no debe interpretarse como un sinónimo de «inmutable». Un tipo mutable también puede ser seguro si protege correctamente su estado. Lo que importa es que utilizarlo desde distintos dominios de concurrencia no permita accesos simultáneos incompatibles.
El aislamiento mediante actores globales ofrece otra herramienta. Una clase asociada a @MainActor, por ejemplo, tiene su estado mutable confinado al actor principal. En lugar de declarar que cualquiera puede acceder a ella concurrentemente, Swift impone que el acceso al estado aislado se realice desde el actor correspondiente.
@MainActor
final class PlaybackViewModel {
var isPlaying = false
func togglePlayback() {
isPlaying.toggle()
}
}
Esto es especialmente relevante en aplicaciones SwiftUI, donde gran parte del estado relacionado con la interfaz pertenece naturalmente al actor principal. Intentar convertir todos los modelos a Sendable no siempre es la solución correcta: en muchos casos la respuesta adecuada es expresar mejor su aislamiento.
Los genéricos también deben conservar estas garantías. Si una función pretende enviar un valor genérico a un contexto concurrente, debe exigir Sendable cuando no exista otra forma de demostrar su seguridad.
func schedule<Value: Sendable>(
_ value: Value,
handler: @escaping @Sendable (Value) async -> Void
) {
Task {
await handler(value)
}
}
Ahora el contrato es explícito: tanto el valor como el bloque que lo procesa pueden transferirse con seguridad. Esto permite que los errores aparezcan en el punto donde intentamos utilizar un tipo incompatible, en lugar de esconder un posible problema de concurrencia dentro de la implementación.
También es habitual encontrarse estos diagnósticos al utilizar APIs antiguas o frameworks que todavía no expresan correctamente sus garantías de concurrencia. @preconcurrency import puede ser útil durante una migración porque reduce determinados diagnósticos procedentes de APIs anteriores al modelo moderno de concurrencia.
@preconcurrency import LegacyNetworkingKit
Esto no convierte mágicamente el framework en seguro. Solo cambia cómo se importan determinadas anotaciones y comprobaciones para facilitar la adopción progresiva. Si una API realmente comparte estado mutable sin sincronización, el riesgo continúa existiendo aunque el compilador deje de advertirnos.
Otra fuente frecuente de confusión aparece al pasar bloques entre APIs. Si una función almacena un bloque que después puede ejecutarse concurrentemente, su parámetro debería expresar @Sendable desde el principio. No hacerlo desplaza el problema a los usuarios de la API y puede obligar a introducir cambios incompatibles más adelante.
struct EventDispatcher: Sendable {
let deliver: @Sendable (String) async -> Void
func dispatch(_ event: String) async {
await deliver(event)
}
}
Diseñar APIs pensando en Sendable obliga a definir con mayor precisión la propiedad y el aislamiento del estado. Si un valor debe viajar libremente entre tareas, Sendable forma parte de su contrato. Si una operación puede ejecutarse desde distintos dominios, @Sendable debe reflejarlo en su tipo. Y si el estado pertenece a un único contexto, probablemente sea mejor aislarlo en un actor que intentar hacerlo universalmente compartible.
En proyectos que migran a Swift 6, los errores relacionados con Sendable suelen ser una señal útil, no una molestia. Antes de añadir @unchecked Sendable, conviene preguntarse qué está detectando realmente el compilador: una referencia mutable compartida, una captura insegura, un tipo genérico sin restricciones o un objeto cuyo aislamiento no está correctamente expresado.
El objetivo de todas estas reglas es que una categoría completa de errores deje de depender de pruebas, sincronización manual y suerte. Sendable, @Sendable, los actores y el aislamiento trabajan juntos para que el compilador pueda demostrar qué valores pueden cruzar fronteras de concurrencia y qué código necesita permanecer dentro de un dominio concreto.
La regla mental más útil es sencilla: Sendable describe valores; @Sendable describe funciones y bloques que pueden transferirse entre dominios de concurrencia. Ambos conceptos terminan respondiendo a la misma cuestión: si este elemento se utiliza desde otro contexto concurrente, ¿puede Swift garantizar que no estamos introduciendo una carrera de datos? Cuando la respuesta forma parte del sistema de tipos, el código concurrente resulta mucho más fácil de mantener y de predecir su comportamiento.