Continuation, CheckedContinuation y UnsafeContinuation en Swift: cuál usar y por qué
Arturo Rivas Arias
Las continuaciones son una de las piezas menos visibles de Swift Concurrency, pero también una de las más importantes cuando una aplicación moderna todavía depende de APIs basadas en completion handlers, delegados o eventos. Permiten suspender una tarea async, entregar el mecanismo necesario para reanudarla a otro ámbito y continuar la ejecución cuando el resultado está disponible.
Desde Swift 5.5, esa adaptación se realizaba principalmente con CheckedContinuation y UnsafeContinuation. Swift 6.4 incorpora una tercera alternativa: Continuation<Success, Failure>, un tipo no copiable que utiliza las reglas de propiedad del lenguaje para impedir determinados errores antes de ejecutar el programa.
Las tres APIs resuelven el mismo problema, pero ofrecen garantías muy diferentes. Elegir correctamente no depende solo del rendimiento: depende, sobre todo, de quién posee la continuación y de cuántos flujos pueden reanudarla.
Qué es realmente una continuación
Cuando una función alcanza un await, su tarea puede suspenderse sin bloquear el hilo en el que se estaba ejecutando. El sistema conserva el estado necesario para continuar más adelante: las variables locales, el punto de retorno y el contexto de ejecución. Una continuación es el identificador que permite reanudar esa tarea suspendida desde código síncrono.
Imaginemos un servicio antiguo que devuelve un código de acceso mediante un closure:
enum AccessCodeError: Error {
case unavailable
case expired
}
final class LegacyAccessCodeService {
func requestCode(
completion: @escaping (Result<String, AccessCodeError>) -> Void
) {
// La implementación real obtiene el código y ejecuta el closure.
}
}
Podemos ofrecer una interfaz async sin reescribir el servicio:
extension LegacyAccessCodeService {
func requestCode() async throws -> String {
try await withCheckedThrowingContinuation { continuation in
requestCode { result in
continuation.resume(with: result)
}
}
}
}
withCheckedThrowingContinuation suspende la tarea que llamó a requestCode(). Cuando el servicio ejecuta su completion handler, resume(with:) reporta el resultado y hace que la tarea pueda continuar.
Es importante precisar que resume no ejecuta inmediatamente el código situado después del await. La llamada marca la tarea como disponible para seguir y devuelve el control a quien ejecutó resume; será el ejecutor correspondiente quien la programe después. Por eso no debemos interpretar una continuación como un salto directo entre dos hilos o colas de ejecución.
La regla de oro: reanudar exactamente una vez
Toda continuación representa un recurso de un solo uso. En cada posible recorrido del programa debe suceder una de estas dos cosas: devolver un valor o arrojar un error. Ambas operaciones consumen conceptualmente la posibilidad de reanudar la tarea.
Si una continuación se reanuda dos veces, dos resultados compiten por completar una única operación. Si nunca se reanuda, la tarea permanece suspendida y conserva los recursos asociados a ella. El contrato es sencillo de expresar, pero era difícil de garantizar con los tipos originales porque ambos son copiables y pueden terminar almacenados o capturados en varios lugares.
CheckedContinuation: la opción segura para código de aplicación
CheckedContinuation verifica dinámicamente los dos errores más frecuentes. Reanudar dos veces provoca un trap con un diagnóstico de uso incorrecto, mientras que perder la continuación sin reanudarla genera un aviso en tiempo de ejecución. Estas comprobaciones se mantienen también en tipos de compilaciones optimizados.
La segunda situación no recupera la tarea: el aviso ayuda a encontrar el error, pero la operación continúa suspendida. Por ese motivo hay que comprobar todos los caminos, incluidos los menos evidentes:
func fetchConfiguration() async throws -> AppConfiguration {
try await withCheckedThrowingContinuation { continuation in
configurationClient.load { response in
guard let response else {
continuation.resume(throwing: ConfigurationError.emptyResponse)
return
}
switch response {
case .success(let configuration):
continuation.resume(returning: configuration)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
El return después del primer resume no es un detalle estético. Evita que la ejecución alcance otro camino que intente completar de nuevo la misma continuación.
Esta variante sigue siendo una magnífica elección cuando una API de terceros entrega el éxito en la respuesta y el error mediante closures diferentes. El compilador no puede saber si la librería garantiza que solo se ejecutará uno, pero la comprobación dinámica sí puede detectar que ese contrato se ha incumplido:
func readTemperature() async throws -> Measurement<UnitTemperature> {
try await withCheckedThrowingContinuation { continuation in
sensor.onValue = { value in
continuation.resume(returning: value)
}
sensor.onFailure = { error in
continuation.resume(throwing: error)
}
sensor.startReading()
}
}
Aquí necesitamos capturar la misma continuación en dos closures escapables. Es un flujo dinámico que encaja con CheckedContinuation, siempre que el sensor garantice que solo invocará uno de ellos y que la aplicación desactive o limpie ambos callbacks después de completarse.
UnsafeContinuation: velocidad a cambio de renunciar a los diagnósticos
UnsafeContinuation y UnsafeThrowingContinuation tienen prácticamente la misma forma de uso, pero no comprueban el contrato. Reanudar dos veces una continuación insegura produce comportamiento indefinido. No reanudarla deja la tarea suspendida silenciosamente hasta que termina el proceso.
func cachedIdentifier() async -> UUID {
await withUnsafeContinuation { continuation in
identifierStore.load { identifier in
continuation.resume(returning: identifier)
}
}
}
El código es breve, pero no expresa por sí mismo que load ejecutará siempre el callback, una sola vez y bajo todas las condiciones. Un error en la implementación, una cancelación interna o un refactor posterior pueden romper esa suposición sin producir diagnóstico alguno.
La propuesta original de continuaciones presentó esta variante como el mecanismo ligero de bajo nivel. En la práctica, CheckedContinuation debería ser el punto de partida para la mayoría del código de aplicación. Solo conviene pasar a la variante insegura después de medir que el coste es relevante y demostrar que el flujo completo está bajo nuestro control.
Swift 6.4 hace todavía más difícil justificar UnsafeContinuation únicamente por rendimiento, porque la nueva Continuation ofrece un camino rápido sin asignaciones ni operaciones atómicas y, al mismo tiempo, traslada parte de la seguridad al sistema de tipos.
Continuation en Swift 6.4
La propuesta SE-0528 añade Continuation<Success, Failure>. Su diferencia esencial no está en resume, sino en que el propio valor es ~Copyable: no puede copiarse libremente. Además, cada método resume es consuming, por lo que utiliza y consume la continuación.
func complete(
_ continuation: consuming Continuation<String, Never>
) {
continuation.resume(returning: "Procesado")
// Error de compilación: la continuación ya se ha consumido.
continuation.resume(returning: "Procesado de nuevo")
}
Con CheckedContinuation, este error compilaría y se detectaría al ejecutar la segunda llamada. Con Continuation, el segundo uso del mismo valor es inválido para el compilador. El contrato de “exactamente una vez” deja de ser únicamente una norma de documentación y pasa a formar parte del modelo de propiedad.
La nueva función withContinuation hace explícitos los tipos en el punto de uso. Para una operación que no puede fallar, Failure es Never:
let isReady = await withContinuation(of: Bool.self) { continuation in
readinessBridge.store(consume continuation)
}
Para una operación que puede fallar, el parámetro throwing: indica el tipo de error. Gracias a los typed throws, el compilador conserva esa información en toda la llamada:
enum ImportError: Error {
case cancelled
case invalidDocument
}
let documentURL = try await withContinuation(
of: URL.self,
throwing: ImportError.self
) { continuation in
importBridge.store(consume continuation)
}
La firma resultante arroja exclusivamente ImportError, no un any Error genérico. También es posible solicitar (any Error).self cuando se necesita el comportamiento no tipado de withCheckedThrowingContinuation.
Un propietario claro para la nueva continuación
Continuation funciona especialmente bien cuando puede transferirse a un único propietario que centraliza la finalización. Un coordinador de importación puede almacenar la continuación hasta que el selector de documentos termine:
@MainActor
final class DocumentImportCoordinator {
private var continuation: Continuation<URL, ImportError>?
func importDocument() async throws(ImportError) -> URL {
try await withContinuation(
of: URL.self,
throwing: ImportError.self
) { continuation in
self.continuation = consume continuation
presentDocumentPicker()
}
}
func didSelectDocument(at url: URL) {
if let continuation {
continuation.resume(returning: url)
}
}
func didCancelImport() {
if let continuation {
continuation.resume(throwing: .cancelled)
}
}
private func presentDocumentPicker() {
// Presentación del selector y configuración de su delegate.
}
}
El coordinador es el único propietario y cada resume consume el valor almacenado. Si la continuación se descarta —por ejemplo, porque se reemplaza o porque el coordinador desaparece— sin haber sido reanudada, su deinit provoca un trap con un diagnóstico claro. La tarea ya no queda colgada silenciosamente.
Esta garantía no significa que el compilador pueda probar todos los caminos posibles. La doble reanudación del mismo binding se detecta durante la compilación, pero la ausencia de resume se detecta en tiempo de ejecución. Es una diferencia importante: la nueva API reduce la superficie de error, no convierte cualquier puente asíncrono en correcto automáticamente.
Por qué no sustituye a CheckedContinuation
El carácter no copiable de Continuation es su principal fortaleza y también su limitación. Si una API exige capturar la misma continuación en dos closures escapables —uno para el éxito y otro para el error— el compilador no puede demostrar que solo se ejecutará uno. Copiar o consumir el valor desde ambos caminos contradice sus reglas de propiedad.
En esas situaciones hay tres posibilidades:
- Mantener
CheckedContinuation, que es normalmente la solución más clara para código existente. - Rediseñar el wrapper para almacenar la nueva continuación en un único coordinador o actor y hacer que todos los eventos pasen por él.
- Convertir una
ContinuationenCheckedContinuationcuando sea necesario abandonar la propiedad lineal y recurrir a comprobaciones dinámicas.
Las variantes anteriores no están obsoletas ni van a desaparecer. Swift 6.4 amplía las opciones para que el tipo elegido refleje mejor la arquitectura real del puente.
Las continuaciones no añaden cancelación automáticamente
Suspender una tarea mediante una continuación tampoco hace que la API antigua comprenda la cancelación de Swift Concurrency. Si se cancela la tarea que espera, la continuación no se reanuda por sí sola y el trabajo subyacente no se detiene automáticamente.
Cuando la operación antigua admite cancelación, el adaptador debe coordinarla explícitamente con withTaskCancellationHandler. Además, la carrera entre “llegó el resultado” y “se solicitó cancelar” debe resolverse de manera que solo uno de los dos caminos consuma o reanude la continuación. Un actor o un estado protegido suelen ser mejores soluciones que una variable booleana capturada por varios closures.
También conviene distinguir una operación de un solo resultado de una fuente de eventos. Las continuaciones están pensadas para reanudar una tarea una sola vez. Si un delegado produce ubicaciones, mensajes o mediciones repetidamente, la abstracción correcta suele ser AsyncStream o AsyncThrowingStream, no llamar varias veces a resume.
Comparativa práctica
| Situación | UnsafeContinuation | CheckedContinuation | Continuation |
|---|---|---|---|
| Reanudación correcta una vez | Funciona | Funciona | Funciona |
Dos llamadas a resume | Comportamiento indefinido | Trap en tiempo de ejecución | Error de compilación sobre el mismo valor |
Se descarta sin resume | Tarea suspendida sin aviso | Aviso en tiempo de ejecución | Trap en tiempo de ejecución |
| Copiable | Sí | Sí | No |
| Coste de comprobación | Ninguno | Asignación y operaciones atómicas | Ninguno en el camino rápido |
| Caso de uso natural | Infraestructura muy controlada | Callbacks y delegados con flujo dinámico | Propiedad única y transferencia explícita |
Qué opción elegir
Para código de aplicación que adapta SDKs antiguos o librerías de terceros, CheckedContinuation continúa siendo el valor por defecto más razonable. Sus diagnósticos son valiosos y su coste rara vez será el cuello de botella real de una operación que accede a la red, el disco, Bluetooth o una interfaz del sistema.
Continuation es preferible en Swift 6.4 cuando la propiedad resulta inequívoca: existe un solo valor, se transfiere a un actor o coordinador concreto y hay un punto central desde el que se completa la operación. En ese escenario ofrece mejores garantías y elimina el coste de las comprobaciones dinámicas en el camino correcto.
UnsafeContinuation queda reservada para infraestructura de bajo nivel en la que el rendimiento se ha medido, todos los caminos están controlados y las alternativas seguras no encajan. Utilizarla por costumbre o para ahorrar una comprobación no compensa la posibilidad de introducir comportamiento indefinido.
La evolución de estas APIs refleja una dirección más amplia del lenguaje. Swift no se limita a detectar errores después de que ocurran: utiliza tipos no copiables, operaciones consuming y typed throws para expresar en el código contratos que antes solo existían en la documentación. En una continuación, ese contrato es especialmente claro: un propietario, un resultado y una única reanudación.