Saltar al contenido
K
Swift: memoria y concurrenciaPaso a paso30 min de lectura · 6 min de máquina Intermedio

Delegados y tareas

Al terminar: Evitar ciclos con delegados weak y con Task que capturan self.

Al terminar esta lección, el Cronometro de Torneo avisará a un delegado que guarda con una referencia weak, arrancará solo con una Task que no lo retiene, y nueve tests vigilarán que ningún ciclo vuelva a colarse: si repones el bug de la lección anterior, dos de ellos fallan.

Importa porque los delegados y las tareas son los otros dos sitios donde Swift retiene objetos sin avisarte. Temas: delegados weak y AnyObject, la Task con self, RetainCyclesTests, y Instruments y el Memory Graph Debugger en un recuadro.

Los delegados, siempre weak

Un delegado es un objeto al que otro avisa de lo que pasa (el cronómetro dice «llegó el minuto 3» a quien lo escuche). Se define con un protocolo (Protocolos y extensiones) y se guarda en una propiedad. El delegado suele ser el dueño del que lo llama (una pantalla que tiene su cronómetro): si la referencia fuera fuerte, serían dos dueños mirándose, otro ciclo. Por eso la propiedad es weak var delegado.

Para que sea weak, el protocolo se declara AnyObject: solo lo pueden adoptar clases, porque un struct no tiene contador de referencias.

Una Task con self fuerte

Una Task { … self … } guardada en una propiedad tiene la misma forma que el ciclo de alTick: self guarda la tarea (self.tarea) y la tarea captura self. La diferencia es que una tarea termina: al acabar el bucle o al cancelarla, suelta su closure, y con ella suelta self. El ciclo se deshace solo. Mientras el bucle dure, en cambio, la tarea mantiene vivo al objeto, y leaks no lo cuenta como fuga (el sistema sigue apuntando a la tarea en marcha). Con [weak self] la tarea no retiene y, si el objeto desaparece, el bucle termina. La Task de SeguimientoEnPantalla, en El estado de pantalla en el actor principal, tiene ese patrón, y el cuarto test de este paso lo demuestra: una tarea con self fuerte mantiene vivo al objeto hasta que la cancelas.

Las dos formas juntas, en un ejemplo ajeno al torneo: un horno que sube de temperatura solo y avisa a su cocinero. El delegado se llama aquí oyente, y el horno se enciende con una tarea que se puede cancelar.

Piezas de este ejemplo:

  • weak var oyente: (any OyenteDelHorno)?: referencia débil, siempre opcional (el any lo viste en some y any). Los paréntesis hacen que el ? afecte a todo el tipo any OyenteDelHorno, no solo a la palabra OyenteDelHorno. protocol … : AnyObject: solo clases. oyente?.hornoLlegoA(…): si ya no hay oyente, no hace nada.
  • try? await Task.sleep(…): si cancelan la tarea durante la pausa, el error se ignora y la tarea sigue hasta la línea siguiente; por eso justo después se pregunta !Task.isCancelled. En El estado de pantalla se usó do … catch para salir en el acto; aquí vale cualquiera de las dos formas, porque la comprobación de después hace el mismo trabajo.
  • @MainActor en el protocolo, en el horno y en el cocinero (lo explica @MainActor y el hilo principal): en el protocolo, para que quien lo adopte sea también del actor principal y el horno lo llame sin await.
  • guard let self, !Task.isCancelled else { return }: dos condiciones separadas por coma; la tarea sale si self ya no existe o si alguien la canceló.

Pégalo en tu Playground (Tu primer paquete de Swift; reutiliza el mismo y borra lo anterior). Como en async/await y Task, si tu versión de Xcode marca en rojo el await de la última parte, envuelve esas dos líneas en Task { … }.

ejemplo mínimo: un delegado weak y una Task con [weak self] (con comentarios)
// Solo clases (AnyObject) del actor principal pueden ser oyentes.
@MainActor
protocol OyenteDelHorno: AnyObject {
    func hornoLlegoA(_ horno: Horno, grados: Int)
}
 
@MainActor
final class Horno {
    private(set) var grados = 20
    weak var oyente: (any OyenteDelHorno)?            // weak: el horno no retiene a su oyente
    private var tarea: Task<Void, Never>?
 
    func subir() {
        grados += 50
        oyente?.hornoLlegoA(self, grados: grados)    // ?: si ya no hay oyente, no hace nada
    }
 
    func encender(msPorPaso: Int) {
        tarea?.cancel()
        tarea = Task { [weak self] in                 // la tarea no retiene al horno
            while !Task.isCancelled {                 // isCancelled: ¿alguien canceló la tarea?
                try? await Task.sleep(for: .milliseconds(msPorPaso))
                // Sale si el horno ya no existe O si cancelaron: las dos condiciones con coma.
                guard let self, !Task.isCancelled else { return }
                self.subir()
            }
        }
    }
 
    func apagar() {
        tarea?.cancel()
        tarea = nil
    }
}
 
@MainActor
final class Cocinero: OyenteDelHorno {
    let horno = Horno()                               // el cocinero es el dueño: referencia fuerte
    init() { horno.oyente = self }                    // el horno lo mira con weak: no hay ciclo
    func hornoLlegoA(_ horno: Horno, grados: Int) { print("  \(grados) grados") }
}
 
@MainActor
func demoHorno() async {
    let cocinero = Cocinero()
    cocinero.horno.encender(msPorPaso: 10)
    try? await Task.sleep(for: .milliseconds(35))     // da tiempo a unos pocos pasos
    cocinero.horno.apagar()
}
 
await demoHorno()
print("Horno apagado")

Lo que imprime (a veces son solo dos líneas de grados: depende de la máquina):

  70 grados
  120 grados
  170 grados
Horno apagado

Cocinero guarda al Horno con una referencia fuerte, y el Horno mira a su Cocinero con weak: un solo sentido fuerte, ningún ciclo. Y la tarea mira al horno con weak: si el horno desapareciera antes de apagarse, el bucle terminaría solo.

Kotlin y Swift, lado a lado

KotlinSwift
Cómo se pierde memoriaPor alcance más largo: un Context guardado, GlobalScope (Un alcance propio)Ciclos, y también el alcance largo (una Task con self fuerte)
Referencia débilRara: no hay un equivalente que hayas usado en el cursoweak var, el patrón normal de los delegados
Un trabajo en marcha que se guardaUn Job dentro de un alcance con dueño; cancel() lo paraUna Task en una propiedad; cancel() la para

Reto · Ejercicio: una pantalla y su botón

En tu Playground (reutiliza el mismo y borra lo anterior), escribe dos clases finales. Boton tiene una propiedad, pantalla, con la pantalla en la que está (puede no tener ninguna). Pantalla crea su propio boton al nacer y, en su inicializador, se apunta a sí misma como la pantalla de ese botón. Escribe una función, probar, con una referencia débil a una pantalla, vigilada. Dentro de un bloque aparte, crea una pantalla y apúntala con vigilada. Después del bloque, imprime «liberada» si vigilada ya no apunta a nada, o «sigue viva» si todavía apunta a la pantalla. Llama a probar.

Con la propiedad del botón escrita a secas sale:

sigue viva

Cambia una sola palabra en Boton para que salga:

liberada
Ver solución
solución: la pantalla y el botón
final class Boton {
    weak var pantalla: Pantalla?      // sin `weak`: ciclo, la pantalla no se libera
}
 
final class Pantalla {
    let boton = Boton()
    init() { boton.pantalla = self }
}
 
func probar() {
    weak var vigilada: Pantalla?
    do {
        let p = Pantalla()
        vigilada = p
    }
    print(vigilada == nil ? "liberada" : "sigue viva")
}
 
probar()

Con var pantalla: Pantalla? a secas, imprime sigue viva: la pantalla retiene el botón y el botón la retiene a ella. Con weak var pantalla, imprime liberada. Es el patrón del delegado: el que es dueño retiene, y quien avisa hacia arriba mira sin retener. Se hace dentro de una función porque una variable global sigue alcanzable hasta el final del programa.

Paso · el delegado, la tarea y los tests

Objetivo: completar Cronometro con un delegado weak y una tarea que no lo retiene, escribir RetainCyclesTests, y comprobar que los tests delatan el bug de la lección anterior. Al terminar, xcrun swift test da 138 tests in 18 suites passed.

Torneo
Sources
Torneo
Cronometro.swiftse modifica
Tests
TorneoTests
RetainCyclesTests.swiftse crea y se rellena con «Copiar fichero»
Un fichero que cambia y uno nuevo, en el panel izquierdo de Xcode (el Project navigator) con el paquete Torneo abierto.

Trabajas en ~/Marcador-Swift/Torneo; los comandos se escriben en la Terminal, dentro de esa carpeta (cd ~/Marcador-Swift/Torneo). RetainCyclesTests.swift no lo escribes: lo rellenas con un botón, más abajo.

Piezas nuevas de este paso:

  • weak var delegado: (any DelegadoDelCronometro)? y protocol … : AnyObject: los de arriba.
  • @MainActor en el protocolo y en la clase: la clase ya lo tenía; en el protocolo, para que quien lo adopte (una pantalla) también sea del actor principal y Cronometro lo llame sin await. En la clase, además, hace que la Task de arrancar herede el actor principal y pueda capturar self (sin @MainActor en la clase y en el protocolo, Swift 6 no lo compila: da un error sending).
  • Task.isCancelled y guard let self, !Task.isCancelled else { return }: los de arriba.
  • do { … }: un bloque que crea un ámbito; lo que se declara dentro deja de existir al salir (así se suelta el objeto en los tests). Lo viste en ARC.
  • weak var x: Tipo? en un test: una referencia débil para preguntar si el objeto sigue vivo.

Intenta escribirlo tú. Parte del Cronometro de la lección anterior y añade:

  1. DelegadoDelCronometro, un protocolo público, del actor principal y solo para clases, con un único método, cronometro(_:llegoAlMinuto:), que recibe el cronómetro y el minuto (los tests copiados lo llaman con ese nombre exacto).
  2. En Cronometro: un delegado al que no retiene; el aviso al delegado dentro de avanzar, después de la closure; arrancar, que con la etiqueta msPorMinuto lanza una tarea que avanza un minuto cada ese tiempo hasta que la cancelen, sin retener al cronómetro; y detener, que la cancela. La tarea se guarda en una propiedad privada.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código

El fichero entero queda así: sustituye todo el contenido de Cronometro.swift por este (lo nuevo es el protocolo, delegado, tarea, la llamada al delegado en avanzar, arrancar y detener).

Torneo/Sources/Torneo/Cronometro.swift
// Un delegado es quien recibe los avisos del cronómetro. Va en un protocolo que solo
// adoptan clases (`AnyObject`) y del actor principal, como el cronómetro.
@MainActor
public protocol DelegadoDelCronometro: AnyObject {
    func cronometro(_ cronometro: Cronometro, llegoAlMinuto minuto: Int)
}
 
@MainActor
public final class Cronometro {
    public private(set) var minuto = 0
 
    // La closure se guarda en una propiedad: sin esto no habría ciclo.
    public var alTick: (() -> Void)?
 
    // `weak`: el delegado no queda retenido por quien lo llama.
    public weak var delegado: (any DelegadoDelCronometro)?
 
    // La tarea de `arrancar`, guardada para poder cancelarla.
    private var tarea: Task<Void, Never>?
 
    public init() {}
 
    // Avanza un minuto y avisa a la closure y al delegado.
    public func avanzar() {
        minuto += 1
        alTick?()
        delegado?.cronometro(self, llegoAlMinuto: minuto)
    }
 
    // La closure mira a `self` sin retenerlo; si ya no existe, no hace nada.
    public func avisarCadaMinuto(_ aviso: @escaping (Int) -> Void) {
        alTick = { [weak self] in
            guard let self else { return }
            aviso(self.minuto)
        }
    }
 
    // Un minuto cada `msPorMinuto` milisegundos, hasta que se cancele la tarea.
    public func arrancar(msPorMinuto: Int) {
        tarea?.cancel()
        tarea = Task { [weak self] in
            while !Task.isCancelled {
                try? await Task.sleep(for: .milliseconds(msPorMinuto))
                // Sale si `self` ya no existe o si cancelaron la tarea.
                guard let self, !Task.isCancelled else { return }
                self.avanzar()
            }
        }
    }
 
    // Cancela la tarea y la suelta.
    public func detener() {
        tarea?.cancel()
        tarea = nil
    }
}

Ahora, los tests que vigilan el ciclo. RetainCyclesTests.swift tiene 9 tests y no los escribes. Para ese tipo de fichero la guía te da un botón, Copiar fichero, que ya usaste en Clases y objetos: copia el contenido del fichero al portapapeles y no escribe nada en tu proyecto. El orden es siempre el mismo: crear (o abrir) el fichero → pulsar el botón → seleccionar todo → pegar.

  1. En el panel izquierdo de Xcode, clic derecho sobre Tests → TorneoTests → New File from Template… → Swift File → Next, escribe RetainCyclesTests y pulsa Create. Se abre el fichero con unas líneas de plantilla.
  2. Pulsa el botón Copiar fichero de abajo. Aparecerá un aviso de que se ha copiado.
  3. Haz clic dentro de RetainCyclesTests.swift, selecciona todo con Cmd+A y pega con Cmd+V. Sustituye lo que había.
RetainCyclesTests.swift

Cuatro de los nueve enseñan lo de esta lección y la anterior. Cada uno pregunta lo mismo: ¿sigue vivo el objeto cuando nada debería retenerlo?

RetainCyclesTests.swift: los cuatro tests que enseñan
import Testing
@testable import Torneo
 
// El cronómetro con el error: la closure guardada captura `self` con referencia fuerte.
private final class CronometroConCiclo {
    var minuto = 0
    var alTick: (() -> Void)?
 
    func avisarCadaMinuto(_ aviso: @escaping (Int) -> Void) {
        alTick = { aviso(self.minuto) }   // captura fuerte de self
    }
}
 
// Una tarea que captura `self` sin `[weak self]`: no es un ciclo, pero mientras el
// bucle esté vivo el objeto NO se libera.
@MainActor
private final class CronometroConTareaFuerte {
    var minuto = 0
    var tarea: Task<Void, Never>?
 
    func arrancar() {
        tarea = Task {
            while !Task.isCancelled {
                try? await Task.sleep(for: .milliseconds(5))
                self.minuto += 1   // `self` fuerte durante toda la vida de la tarea
            }
        }
    }
}
 
@MainActor
private final class DelegadoQueCuenta: DelegadoDelCronometro {
    var minutosVistos: [Int] = []
    func cronometro(_ cronometro: Cronometro, llegoAlMinuto minuto: Int) {
        minutosVistos.append(minuto)
    }
}
 
@MainActor
struct RetainCyclesTests {
    @Test func asiNoVale_laClosureQueCapturaSelfFormaUnCiclo() {
        weak var cronometroDebil: CronometroConCiclo?
        do {
            let cronometro = CronometroConCiclo()
            cronometro.avisarCadaMinuto { _ in }
            cronometroDebil = cronometro
        }   // sale del alcance...
        // ...pero sigue vivo: se retiene a sí mismo a través de `alTick`. FUGA.
        #expect(cronometroDebil != nil)
        cronometroDebil?.alTick = nil   // se rompe el ciclo a mano para no dejar basura
        #expect(cronometroDebil == nil)
    }
 
    @Test func conWeakSelfElCronometroSeLibera() {
        weak var cronometroDebil: Cronometro?
        do {
            let cronometro = Cronometro()
            cronometro.avisarCadaMinuto { _ in }
            cronometroDebil = cronometro
            #expect(cronometroDebil != nil)
        }
        #expect(cronometroDebil == nil)
    }
 
    @Test func elDelegadoEsWeakYNoLoMantieneVivo() {
        let cronometro = Cronometro()
        weak var delegadoDebil: DelegadoQueCuenta?
        do {
            let delegado = DelegadoQueCuenta()
            cronometro.delegado = delegado
            delegadoDebil = delegado
            #expect(cronometro.delegado != nil)
        }
        #expect(delegadoDebil == nil)         // el cronómetro no lo retuvo
        #expect(cronometro.delegado == nil)   // y su referencia se puso a nil sola
        cronometro.avanzar()                  // no pasa nada: sin delegado, no avisa
        #expect(cronometro.minuto == 1)
    }
 
    @Test func asiNoVale_laTareaConSelfFuerteMantieneVivoAlObjetoHastaCancelarla() async {
        weak var objetoDebil: CronometroConTareaFuerte?
        var tarea: Task<Void, Never>?
        do {
            let objeto = CronometroConTareaFuerte()
            objeto.arrancar()
            tarea = objeto.tarea
            objetoDebil = objeto
        }
        try? await Task.sleep(for: .milliseconds(30))
        #expect(objetoDebil != nil)   // sigue vivo: la tarea lo retiene
        tarea?.cancel()
        await tarea?.value            // al terminar la tarea, suelta `self`
        #expect(objetoDebil == nil)
    }
}

Ejecútalos:

xcrun swift test

La última línea es:

Test run with 138 tests in 18 suites passed

(Delante lleva el tiempo que tardó, que varía.) Con los 129 de antes, los nueve nuevos suman 138 en 18 suites.

Provócalo: vuelve a poner el bug

Para ver que los tests de verdad vigilan el ciclo, vuelve a escribir el bug en Cronometro.swift: en avisarCadaMinuto, cambia el cuerpo por alTick = { aviso(self.minuto) }. Ejecuta solo esa suite:

xcrun swift test --filter RetainCyclesTests

Fallan dos tests: conWeakSelfElCronometroSeLibera() (Expectation failed: (cronometroDebil → Torneo.Cronometro) == nil) y siElCronometroYaNoExisteElAvisoNoHaceNada(), y la última línea dice Test run with 9 tests in 1 suite failed … with 2 issues. El segundo guarda una copia de la closure, hace desaparecer al cronómetro y la llama: sin cronómetro no debe avisar a nadie, y con el ciclo sí avisa. Con todos los tests, Test run with 138 tests in 18 suites failed … with 2 issues. Así queda vigilado: si alguien (tú dentro de un año) vuelve a escribir ese ciclo, los tests lo dicen.

Quita el bug (deja avisarCadaMinuto con [weak self], como arriba) y repite xcrun swift test: vuelve a 138 tests in 18 suites passed.

Desde Xcode. Los tests se ejecutan también con el esquema Torneo-Package y Product → Test (Cmd+U).

Instruments y el Memory Graph Debugger

Qué hace cada pieza:

PiezaPor qué está así
public var alTick: (() -> Void)?La closure se guarda en una propiedad: sin eso no habría ciclo
[weak self] + guard let selfLa closure mira al cronómetro sin retenerlo
weak var delegadoEl delegado no debe ser retenido por quien lo llama
protocol … : AnyObjectSolo las clases pueden ser weak
arrancar con Task { [weak self] in … }Si el cronómetro desaparece, el bucle termina; sin ello la tarea lo mantendría vivo
weak var en los testsSe pregunta si el objeto sigue vivo sin retenerlo

Punto de control

  • Cronometro.swift tiene el protocolo DelegadoDelCronometro, weak var delegado, arrancar, detener y avisarCadaMinuto con [weak self].
  • xcrun swift test termina con Test run with 138 tests in 18 suites passed, y xcrun swift build --build-tests sigue sin warning: ni error:.
  • Con el bug de alTick puesto, xcrun swift test --filter RetainCyclesTests falla conWeakSelfElCronometroSeLibera() y siElCronometroYaNoExisteElAvisoNoHaceNada() (9 tests in 1 suite failed … with 2 issues); sin él, todo en verde.
  • leaks --atExit -- .build/debug/torneo-cli cronometro (tras xcrun swift build) sigue en 0 leaks for 0 total leaked bytes..
  • Sabes decir por qué una Task con self fuerte no es un ciclo permanente y alTick sí.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.¿Por qué la propiedad delegado se declara weak?

  2. 2.¿Para qué es : AnyObject en DelegadoDelCronometro?

  3. 3.Una Task { … self … } guardada en self, ¿es un ciclo para siempre?

  4. 4.Pones otra vez alTick = { aviso(self.minuto) } en Cronometro y ejecutas todos los tests. ¿Qué pasa?

  5. 5.¿Para qué sirve weak var dentro de un test de memoria?

  6. 6.En guard let self, !Task.isCancelled else { return }, ¿cuándo sale la tarea?

Faltan 6 de 6