Sendable y el modo Swift 6
Al terminar: Cambiar el modo de lenguaje del paquete a Swift 6, marcar los tipos como Sendable y escribir el estado de pantalla en el actor principal, sin avisos.
Al terminar esta lección, el paquete Torneo compila en modo Swift 6 sin un solo aviso y Equipo puede viajar entre tareas sin riesgo. Hasta la lección anterior el compilador te dejaba pasar los peligros de la concurrencia; ahora los convierte en errores, y tú los arreglas uno a uno con los mensajes literales delante.
Importa porque esta es la comprobación que convierte el data race de Actores (un número mal, a veces) en un error de compilación que se ve antes de ejecutar. Temas: Sendable, Mutex y la concurrencia estricta de Swift 6. El actor principal, que se usa de verdad en la lección siguiente, se explica en @MainActor y el hilo principal.
Sendable: lo que puede cruzar de una tarea a otra
Un dominio de concurrencia es una zona con sus propias reglas de acceso: un actor, el actor principal o una tarea. Sendable es el protocolo que dice «este tipo se puede pasar de un dominio a otro sin riesgo». El compilador lo comprueba:
| Tipo | ¿Es Sendable? |
|---|---|
struct o enum cuyos campos son Sendable | Sí, aunque los campos sean var: al cruzar, se copia. En un tipo public hay que escribirlo: no se deduce |
actor | Sí, siempre: protege su estado |
final class con solo let Sendable | Sí, si escribes : Sendable: en las clases nunca se deduce |
class con un var que cambia | No: dos tareas podrían pisarse (el caso de MarcadorInseguro) |
| Una closure que cruza tareas | Debe marcarse @Sendable, y no puede capturar un var que cambia. Las closures de Task { } y addTask { } ya lo son: solo la marcas tú en las que guardas o pasas a mano |
struct Gol: Sendable { // un struct: se copia al cruzar entre tareas
var minuto: Int // `var` vale: cada tarea recibe su copia y cambia la suya
let jugador: String // `let` también vale: no cambia nunca
}
final class Cuenta: Sendable { // una clase: todas las tareas comparten EL MISMO objeto
var total = 0 // error: stored property 'total' of 'Sendable'-conforming class 'Cuenta' is mutable
// (con `let` compilaría)
}Pega el código en tu Playground y verás el error en rojo en la línea de total: es el modo Swift 6, y es el mismo mensaje que da el paquete.
El modo Swift 6
Un modo de lenguaje es el conjunto de reglas que aplica el compilador. Con swift-tools-version: 6.2 el modo por defecto es Swift 6. Desde Closures el paquete tenía swiftLanguageModes: [.v5], que desactivaba casi todas estas comprobaciones para poder aprender cada tema por separado. El compilador es el mismo en los dos modos: lo que cambia es cuántas cosas rechaza.
Cuando el compilador encuentra algo que Swift 6 no permite, lo cuenta de una de estas dos maneras, según el modo: en Swift 5 con la comprobación estricta encendida (el recuadro de más abajo explica cómo se enciende) es un aviso (warning: el programa compila igual) y en Swift 6 es un error (el programa no compila). Es la misma frase en los dos casos; en el aviso acaba con this is an error in the Swift 6 language mode (en el modo Swift 6 esto es un error). Esa escalera es lo que hay detrás del ajuste que cambias en el primer paso.
Dos salidas para una clase que cambia: Mutex o @unchecked
Para que un struct sea Sendable, todo lo que guarda tiene que serlo. Una clase con un valor que cambia no lo es, y en el paquete la tienes: Equipo guarda un capitan que cambia. Tiene dos salidas:
| Salida | Qué hace | Por qué |
|---|---|---|
@unchecked Sendable | Le dices al compilador «confía, yo me hago responsable» | Se calla, y el bug sigue ahí: es justo lo que no hay que hacer |
Mutex | El valor vive dentro de un cerrojo (una pieza que deja pasar a una sola tarea a la vez): solo una tarea a la vez ejecuta el bloque de withLock | El compilador puede demostrar que es seguro |
Se usa Mutex (de import Synchronization). Necesita macOS 15 e iOS 18, y tu platforms ya pide justo eso desde async/await (some AsyncSequence<String, Never> y Task.sleep(for:) ya lo exigían). No hay nada que instalar: Synchronization viene con Swift y con el sistema.
Rompe la simetría con Kotlin. El Equipo de Kotlin tiene un var capitan con private set y ningún cerrojo: la JVM deja pasar cualquier objeto entre hilos, y tiene el mismo riesgo sin que nadie lo avise. En Swift, para poder cruzar tareas, el dato que cambia entra en un Mutex, que bloquea el hilo un instante mientras alguien lo usa (por eso el bloque de withLock debe ser corto).
Piezas nuevas de esta lección:
Sendable,@Sendabley@unchecked Sendable: los de arriba.import SynchronizationyMutex<Jugador?>(nil): un cerrojo que guarda un valor (aquí, un capitán opcional);withLock { … }da acceso al valor dentro del bloque, con$0como nombre del valor (los parámetros anónimos de Closures):{ $0 }lo lee y{ $0 = jugador }lo cambia (llega comoinout, un parámetro que se puede modificar).1_000: el guion bajo solo separa los miles para leerlo mejor; vale igual que1000.(@Sendable () -> Void)?: una closure@Sendableque puede sernil; los paréntesis son necesarios para que el?afecte a toda la closure.
Ya las viste: la propiedad calculada (public var capitan: Jugador? { … }, sin valor guardado: se calcula cada vez que se lee) viene de Propiedades, y el Task y el TaskGroup de los ejemplos, de async/await y TaskGroup y errores en tareas hijas.
El Mutex y la closure @Sendable opcional, con una taquilla en vez de un equipo:
import Synchronization // trae Mutex; viene con Swift, no se instala
public final class Taquilla: Sendable { // una clase Sendable: solo let y nada que cambie sin cerrojo
public let nombre: String
// El valor que cambia vive dentro del cerrojo. Mutex<Int>: guarda un Int.
private let vendidas = Mutex<Int>(0)
public init(nombre: String) { self.nombre = nombre }
// Propiedad calculada: lee el valor dentro del cerrojo. { $0 } devuelve lo que hay.
public var entradasVendidas: Int { vendidas.withLock { $0 } }
public func vender() {
vendidas.withLock { $0 += 1 } // $0 llega como inout: aquí sí se puede cambiar
}
}
let taquilla = Taquilla(nombre: "Norte")
await withTaskGroup(of: Void.self) { grupo in
for _ in 0..<1_000 {
grupo.addTask { taquilla.vender() } // compila en Swift 6: Taquilla es Sendable
}
}
print(taquilla.entradasVendidas) // 1000, siempre
// Una closure @Sendable que puede ser nil: los paréntesis hacen opcional toda la closure.
let alCerrar: (@Sendable () -> Void)? = { print("taquilla cerrada") }
alCerrar?() // el ? solo la llama si no es nil1000
taquilla cerradaReto · Ejercicio: un sensor que cruzan mil tareas
En tu Playground (borra lo anterior), escribe un struct, Lectura, con un valor entero que se pueda cambiar y que se pueda compartir entre tareas. Escribe también una clase final, Sensor, compartible entre tareas sin promesas sin comprobar, que cuente cuántas lecturas recibe y recuerde la mayor (lo que cambia va en un cerrojo).
Lanza a la vez mil tareas que registran las lecturas del 1 al 1000 y, al terminar, imprime el número de lecturas y la máxima. Lo que debe salir:
Lecturas: 1000
Máxima: 1000Para pensar: ¿qué error da si guardas el resumen en una propiedad normal que se pueda cambiar, sin cerrojo?
Ver solución
import Synchronization
struct Lectura: Sendable { var valor: Int } // struct con var: Sendable, se copia al cruzar
struct Resumen { var cuantas = 0; var maxima = 0 } // solo enteros: Sendable sin escribirlo (no es public)
final class Sensor: Sendable {
private let guardado = Mutex(Resumen()) // lo que cambia vive en el cerrojo
func registrar(_ lectura: Lectura) {
guardado.withLock {
$0.cuantas += 1
if lectura.valor > $0.maxima { $0.maxima = lectura.valor } // se queda con la mayor
}
}
var resumen: Resumen { guardado.withLock { $0 } }
}
let sensor = Sensor()
await withTaskGroup(of: Void.self) { grupo in
for n in 1...1_000 {
grupo.addTask { sensor.registrar(Lectura(valor: n)) }
}
}
print("Lecturas: \(sensor.resumen.cuantas)")
print("Máxima: \(sensor.resumen.maxima)")Lectura es Sendable con var porque al cruzar entre tareas se copia, y cada tarea cambia su copia. Sensor es una clase: las mil tareas comparten el mismo objeto, así que lo que cambia necesita un cerrojo. Con una propiedad var normal en lugar del Mutex, el error es stored property '…' of 'Sendable'-conforming class 'Sensor' is mutable, el mismo de Cuenta en el ejemplo de arriba. Es lo que le pasa a Equipo con su capitán en el segundo paso.
Paso · Cambia el modo de lenguaje a Swift 6
Objetivo: quitar el modo Swift 5 del Package.swift y ver, con los mensajes reales, todo lo que Swift 6 te va a exigir. Al terminar, xcrun swift build --build-tests falla con una lista de errores de concurrencia: es lo que esperas ver, y los arreglas en los pasos siguientes. Es el ajuste que pusiste en Closures, deshecho: allí está la receta completa del manifiesto.
1 · Qué ajustas y por qué ahora. El modo de lenguaje del paquete, la propiedad swiftLanguageModes de Package.swift, que pusiste en .v5 para aprender un tema cada vez. Ya conoces actores y tareas, las piezas que Swift 6 comprueba: es el momento de quitar el apagón.
2 · Dónde vive. En Package.swift, el manifiesto del paquete (lo creó Tu primer paquete de Swift): en Xcode, Torneo → Package.swift en el panel izquierdo. Ahí hay dos cosas que se parecen:
- La primera línea,
// swift-tools-version: 6.2: la versión de herramientas que necesita el manifiesto. No la tocas. swiftLanguageModes, justo antes del paréntesis que cierraPackage(...): el modo con el que se compila el código de tus ficheros.swift. Es la que cambias.
3 · El cambio. Borra la línea swiftLanguageModes: [.v5] y la coma que hay después del ] que cierra targets: el último argumento no lleva coma.
],
swiftLanguageModes: [.v5]
) ]
)Guarda el fichero con Cmd+S. Sin la línea, el paquete vuelve al modo que le toca por su versión de herramientas: Swift 6.
4 · Compila (aquí no hay «Sync Now» como en Android Studio: Swift lee el manifiesto cada vez que compilas). En la Terminal:
cd ~/Marcador-Swift/Torneo
xcrun swift build --build-tests--build-tests compila también los tests (se usa en los pasos siguientes). Esta primera vez la compilación se detiene en el módulo Torneo y no llega a los tests. Verás más de veinte errores; estos son cuatro, uno de cada tipo (las líneas y columnas dependen de los comentarios que hayas escrito; los dos primeros son iguales en todos los paquetes):
Sources/Torneo/Closures.swift:4:12: error: let 'esGol' is not concurrency-safe because non-'Sendable' type '(EventoDePartido) -> Bool' may have shared mutable state [#MutableGlobalVariable]
Sources/Torneo/Ejemplo.swift:6:23: error: static property 'ana' is not concurrency-safe because non-'Sendable' type 'Jugador' may have shared mutable state [#MutableGlobalVariable]
Sources/Torneo/PartidoEnVivo.swift:57:17: error: capture of 'alTerminar' with non-Sendable type '(() -> Void)?' in a '@Sendable' closure [#SendableClosureCaptures]
Sources/Torneo/Simulacion.swift:99:15: error: type 'Partido' does not conform to the 'Sendable' protocolSi tienes el paquete abierto en Xcode, también los verás en el panel de Issues (el triángulo con una exclamación, arriba en el panel izquierdo) tras Product → Build (Cmd+B). El esquema (scheme, la lista desplegable de la barra superior que dice qué se compila) que compila el paquete entero se llama Torneo-Package.
5 · Si algo sale distinto. Si no sale ningún error, no guardaste el fichero o editaste otro Package.swift: debe ser el de ~/Marcador-Swift/Torneo. Si sale error: ... Invalid manifest, quedó una coma de más o de menos al borrar (el mensaje acaba con el fichero, la línea y el motivo).
6 · Comprueba. Lo que debes ver es lo contrario de lo habitual: el compilador falla, con errores de concurrencia (is not concurrency-safe, does not conform to the 'Sendable' protocol). Ese fallo es la prueba de que el ajuste está aplicado.
Paso · Marca Sendable y arregla Equipo
Objetivo: que el módulo Torneo compile en Swift 6. Al terminar, xcrun swift build (sin --build-tests) dice Build complete!.
Los ocho ficheros que solo ganan Sendable son Closures.swift, EventoDePartido.swift, Jugador.swift, Partido.swift, PartidoEnVivo.swift, Reglamento.swift, Simulacion.swift y Tabla.swift; cada uno lleva de una a tres líneas.
Trabajas en ~/Marcador-Swift/Torneo, y los comandos se escriben en la Terminal, dentro de esa carpeta.
La cura de la mayoría de los errores es la misma: marcar como Sendable lo que lo es (structs y enums) y las closures que cruzan tareas como @Sendable. El protocolo Reglamento también: un tipo que adopta un protocolo Sendable tiene que ser Sendable, y así un any Reglamento («cualquier tipo que cumple el protocolo») puede viajar entre tareas sin romper la garantía. Cuando Partido pasa a ser Sendable, sale el error de fondo, que lleva de nuevo al mismo sitio:
Sources/Torneo/Partido.swift:4:16: error: stored property 'local' of 'Sendable'-conforming struct 'Partido' has non-Sendable type 'Equipo'
Sources/Torneo/EventoDePartido.swift:24:10: error: associated value 'gol(minuto:jugador:equipo:)' of 'Sendable'-conforming enum 'EventoDePartido' contains non-Sendable type 'Equipo'Equipo es la clase con el capitan que cambia, y la tabla de arriba dice qué hacer: un Mutex.
Intenta escribirlo tú: marca los tipos Sendable de uno en uno, compilando con xcrun swift build cuando quieras ver cómo cambia la lista de errores; al final, el Mutex de Equipo. Lo que debes ver al terminar: Build complete!.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Las marcas Sendable, de una en una. Cada bloque es la línea de declaración que ya tienes, sustituida por esta (solo cambia el final, Sendable, y la { que cierra la línea): no añadas una línea nueva. Jugador.swift:
public struct Jugador: Equatable, Hashable, Sendable {Partido.swift:
public struct Partido: Equatable, Sendable {EventoDePartido.swift (tres tipos):
public enum ColorDeTarjeta: String, CaseIterable, Codable, Sendable {
public enum EventoDePartido: Equatable, Sendable {
public enum Resultado: Equatable, Sendable {Tabla.swift:
public struct FilaDePosicion: Equatable, Sendable {Simulacion.swift:
public struct Estadisticas: Equatable, Sendable {Reglamento.swift:
public protocol Reglamento: Sendable {Closures.swift (las dos closures globales):
public let esGol: @Sendable (EventoDePartido) -> Bool = {
public let esDelMinutoOAntes: @Sendable (EventoDePartido, Int) -> Bool = { evento, limite inPartidoEnVivo.swift, dos cambios en el mismo tipo. La propiedad:
private let alTerminarLaEmision: (@Sendable () -> Void)?y el parámetro del init, en el mismo sitio donde estaba:
alTerminarLaEmision: (@Sendable () -> Void)? = nil,Equipo.swift, con cuatro cambios. Uno, el import va arriba del todo:
import SynchronizationDos, la clase pasa a Sendable:
public final class Equipo: Sendable {Tres, el public private(set) var capitan: Jugador? (debajo del init?) se sustituye por el cerrojo y una propiedad calculada. El comentario que tiene encima, sobre private(set), ya no describe nada: bórralo o cámbialo por «el capitán vive en un cerrojo»:
private let capitanGuardado = Mutex<Jugador?>(nil)
public var capitan: Jugador? { capitanGuardado.withLock { $0 } }Cuatro, en nombrarCapitan, la asignación capitan = jugador pasa a:
capitanGuardado.withLock { $0 = jugador }Compila con xcrun swift build. Debe terminar con Build complete!. Si falta algún Sendable, el error nombra el tipo y el fichero: añade la marca ahí. Si sale stored property ... has non-Sendable type 'Equipo', es que Equipo todavía no tiene el Mutex.
Algo que no sale: main.swift llama a métodos async (await enDirecto.jugar()) sobre un PartidoEnVivo, que no es Sendable, y compila sin avisos. Lo creaste ahí mismo y nadie más lo usa, y Swift lo sabe: es la regla de sending de @MainActor y el hilo principal, que deja entregar un objeto no Sendable si nadie más lo toca.
Para ver cómo avisa el compilador de un tipo que falta, provócalo: quita Sendable de la línea de Jugador en Jugador.swift, compila (verás errores como non-'Sendable' type 'Jugador') y devuélvelo a su sitio.
Paso · Arregla los tests
Objetivo: que los tests también compilen en Swift 6. Al terminar, xcrun swift build --build-tests termina sin ningún warning: ni error:.
Ahora sí, compila los tests con xcrun swift build --build-tests. Salen tres errores nuevos, uno en ActoresTests.swift y dos en EventosEnVivoTests.swift, uno por cada test que usa terminado (los números de línea dependen de tus comentarios):
Tests/TorneoTests/ActoresTests.swift:25:23: error: passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure
Tests/TorneoTests/EventosEnVivoTests.swift:45:107: error: mutation of captured var 'terminado' in concurrently-executing code [#SendableClosureCaptures]
Tests/TorneoTests/EventosEnVivoTests.swift:59:108: error: mutation of captured var 'terminado' in concurrently-executing code [#SendableClosureCaptures]El primero es el test de la clase compartida de Actores: MarcadorInseguro no es Sendable, y el compilador la rechaza en cuanto la mandas a un TaskGroup. Los otros dos son una var terminado = false que una closure @Sendable quiere cambiar desde otra tarea: no puede, porque dos tareas podrían tocarla a la vez.
Los arreglos:
- En
ActoresTests.swift, marcarMarcadorInsegurocomo@unchecked Sendable. Es la línea que hace callar al compilador, y el bug sigue ahí: se conserva a propósito, para que la prueba siga enseñando el fallo. - En
EventosEnVivoTests.swift, sustituir las dosvar terminado = falsepor una claseBandera,Sendable, cuyoBoolvive en unMutex(consubir()yestaLevantada).
Bandera es la taquilla del ejemplo de arriba con un Bool en vez de un Int: Mutex(false) deduce el tipo del valor inicial.
Intenta escribirlo tú:
- En
ActoresTests.swift,MarcadorInseguropasa a prometer, sin comprobación, que se puede compartir. - En
EventosEnVivoTests.swift, una clase privadaBandera, que se puede compartir entre tareas, con un valor que empieza bajado y vive en un cerrojo; un métodosubir, que lo levanta, y una propiedadestaLevantada, que lo lee. - En los dos tests que usaban
terminado, esa variable pasa a ser una bandera: se crea, se sube al terminar la emisión, y la espera y la comprobación preguntan si está levantada.
Lo que debes ver al compilar los tests: ningún error:.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
En ActoresTests.swift, la clase de Actores pasa a @unchecked Sendable (sustituye esa línea de declaración):
private final class MarcadorInseguro: @unchecked Sendable {En EventosEnVivoTests.swift, añade import Synchronization encima de los dos import que ya tienes (Testing y @testable import Torneo) y la clase Bandera debajo de ellos:
import Synchronization
import Testing
@testable import Torneo
private final class Bandera: Sendable {
private let valor = Mutex(false)
func subir() { valor.withLock { $0 = true } }
var estaLevantada: Bool { valor.withLock { $0 } }
}En los dos tests de EventosEnVivoTests.swift que usaban terminado (primerGolCancelaElResto y cancelarAlConsumidorCancelaAlProductor), cambian solo estas líneas; el msPorMinuto de cada test se queda como estaba:
// antes: var terminado = false
// después: let terminado = Bandera()
// antes: alTerminarLaEmision: { terminado = true }
// después: alTerminarLaEmision: { terminado.subir() }
// antes: for _ in 0..<50 where !terminado { … }
// después: for _ in 0..<50 where !terminado.estaLevantada { … }
// antes: #expect(terminado)
// después: #expect(terminado.estaLevantada)Vuelve a compilar con xcrun swift build --build-tests: sin errores ni avisos.
Lo que hace cada cambio de la lección:
| Cambio | Para qué |
|---|---|
Quitar swiftLanguageModes: [.v5] | Vuelve el modo Swift 6 y sus errores |
Sendable en structs, enums y Reglamento | Que crucen tareas |
@Sendable en las closures globales y en alTerminarLaEmision | Una closure que cruza tareas no puede capturar estado mutable |
Mutex en Equipo | El capitán, lo único que cambia, queda protegido: Equipo es Sendable sin promesas |
@unchecked Sendable en MarcadorInseguro | Solo en el test, para conservar el bug a la vista |
Bandera con Mutex | Una closure @Sendable no puede mutar un var capturado |
Kotlin y Swift, lado a lado
| Kotlin | Swift | |
|---|---|---|
| Pasar datos entre hilos | Sin comprobación: val, Mutex o StateFlow por disciplina | Sendable, comprobado |
| Cerrojo | No lo viste en este curso | Mutex de Synchronization: bloquea el hilo un instante |
Un var capturado por una tarea | Compila; el riesgo es tuyo | En una closure @Sendable, error |
| Promesa de «esto es seguro» | No existe | @unchecked Sendable (sin comprobar) |
Punto de control
- Desde
~/Marcador-Swift/Torneo,xcrun swift build --build-teststermina sin ninguna línea conwarning:nierror:(prueba conrm -rf .buildantes si quieres compilarlo todo de cero:.buildes la carpeta donde Swift deja lo compilado). El mismo resultado sale en Xcode con el esquemaTorneo-Packagey Product → Test (Cmd+U), si esa opción te aparece; si no, basta con los comandos de la Terminal. xcrun swift run torneo-clitermina con estas dos líneas:Marcador tras 1000 goles de cada equipo a la vez: 1000-1000yMarcador final del partido en vivo: 2-1.xcrun swift testtermina conTest run with 125 tests in 16 suites passed. El test de la clase compartida sigue perdiendo goles, ahora con@unchecked Sendable.- Provocaste un fallo: quitaste
SendabledeJugador(la línea deJugador.swift), compilaste, viste el error que nombraJugadory lo devolviste a su sitio. - Sabes decir por qué
Equipousa unMutexy no@unchecked Sendable. - Tu
Package.swiftya no tieneswiftLanguageModes, y sabes decir por qué sin esa línea el paquete se compila en modo Swift 6.
Tienes el paquete en Swift 6 y sin avisos. En la lección siguiente, El estado de pantalla en el actor principal, escribes con él la clase que guardará lo que una pantalla muestra.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Cuál de estos tipos puede ser
Sendablesin promesas ni cerrojos?2.¿Por qué
Equipoguarda el capitán en unMutexen lugar de marcar la clase@unchecked Sendable?3.¿Qué hacía
swiftLanguageModes: [.v5]en elPackage.swiftdurante las lecciones anteriores?4.¿Por qué una closure que cruza tareas se marca
@Sendabley no puede capturar unvarque cambia?5.En
capitanGuardado.withLock { $0 = jugador }, ¿qué hace elMutex?