DEV Community

GoyesDev
GoyesDev

Posted on

[SC] Closures con @Sendable

Preguntas

¿Por qué no basta con el protocolo Sendable para funciones y closures?

Los closures y funciones pueden capturar valores. A la hora de usar un closure o una función en un entorno concurrente, es posible que sea necesario pasarlo a través de varios dominios de aislamiento y, por tanto, también se debe garantizar que los valores capturados sean seguros.

Actualmente los closures y funciones no pueden conformar el protocolo Sendable, sin embargo, la etiqueta @Sendable le permite al compilador validar que el closure o función sea seguro para pasar entre dominios de aislamiento.

¿Qué garantía ofrece @Sendable al compilador sobre los valores capturados?

@Sendable garantiza que los valores capturados deben ser Sendable y también deben ser capturados por valor. Esto último puede ser algo confuso inicialmente, sin embargo, espero poder aclararlo con los siguiente ejemplos:

El siguiente closure captura un Int escalar, así que tiene una referencia directa a su valor.

func execute() -> () -> Int {
  return {
    return 1 + 1
  }
}
Enter fullscreen mode Exit fullscreen mode

Puedo extraer el Int escalar y moverlo a una constante por fuera. En este caso, el closure va a tener una copia del valor de esa constante. Una vez que termina el scope de execute() la variable x se libera del stack, mientras que el closure retornado va a conservar una copia del valor que tenía.

func execute() -> () -> Int {
  let x = 1
  return {
    return x + 1
  }
}
Enter fullscreen mode Exit fullscreen mode

Si x es una variable, Swift internamente genera una caja para almacenar el valor y poder referenciarla desde el closure después. En efectos prácticos, estaría moviendo un valor del stack al heap.

Consideremos el siguiente código:

func execute() -> () -> Int {
  var x = 1
  return {
    return x + 1
  }
}
Enter fullscreen mode Exit fullscreen mode

Swift internamente haría lo siguiente:

// Swift crea un "box" en el heap para almacenar x
class Box<T> {
    var value: T
    init(_ value: T) { self.value = value }
}

func execute() -> () -> Int {
  let x = Box(1)
  return {
    return x + 1
  }
}
Enter fullscreen mode Exit fullscreen mode

En código de un solo hilo no habría ningún problema, sin embargo, si se marca execute() con @Sendable, entonces aparecería un error como el siguiente:

// Reference to captured var ‘x’ in concurrently-executing code 
Enter fullscreen mode Exit fullscreen mode

Por esta razón, hay que usar un capture list, para guardar una copia de la variable en la definición del closure. Esto sería un "snapshot".

func execute() -> () -> Int {
  var x = 1
  return { [x] in
    return x + 1
  }
}
Enter fullscreen mode Exit fullscreen mode

¿Cuándo es necesario cruzar dominios de aislamiento (isolation domains) con una función?

Como regla general, se dice que una función necesita cruzar un dominio de aislamiento cuando se crea en un dominio, pero se ejecuta en otro. Esta situación puede presentarse en los siguientes escenarios:

1. Pasar callbacks o predicados a un actor

actor OrdersStore {
  private(set) var orders: [Order] = []
  func filter(_ isIncluded: @Sendable (Order) -> Bool) -> [Order] {
    orders.filter(isIncluded)
  }
}
Enter fullscreen mode Exit fullscreen mode
let store = OrdersStore()
let expensiveOrders = await store.filter {
  order in order.total > 1000
}
Enter fullscreen mode Exit fullscreen mode

En el caso anterior, se define el predicado en el contexto del invocador y se pasa al actor OrdersStore cuando se invoca await store.filter.

2. Tareas en segundo plano

actor ImageProcessor {
    func process(_ image: UIImage) -> UIImage {
        // procesamiento pesado...
        return image
    }
}

let processor = ImageProcessor()

func processInBackground(
  image: UIImage, 
  transform: @escaping @Sendable (UIImage) -> UIImage) {
    Task(priority: .background) {
      let result = transform(image)         // dominio de la Task
      await processor.process(result)       // dominio del actor
    }
}
Enter fullscreen mode Exit fullscreen mode

En el ejemplo anterior, el closure recibido por parámetro transform se originó en el dominio del invocador y luego se pasó al dominio del Task.

3. Programación funcional con actores

actor ProductCatalog {
  private(set) var products: [Product] = []

  func apply(
    filter: @Sendable (Product) -> Bool,
    transform: @Sendable (Product) -> Product
  ) -> [Product] {
    products
      .filter(filter)
      .map(transform)
  }
}
Enter fullscreen mode Exit fullscreen mode
let catalog = ProductCatalog()
let discounted = await catalog.apply(
  filter: { $0.stock > 0 },
  transform: { product in
    var p = product
    p.price *= 0.9  // 10% de descuento
    return p
  }
)
Enter fullscreen mode Exit fullscreen mode

En este ejemplo, dos funciones de orden superior se crearon en el contexto invocador y luego se pasan al actor.

4. Código nonisolated que delega trabajo a un actor

actor NotificationsStore {
    private(set) var notifications: [Notification] = []

    func removeAll(where condition: @Sendable (Notification) -> Bool) {
        notifications.removeAll(where: condition)
    }
}

extension NotificationsStore {
    nonisolated func cleanUpInBackground(
        _ shouldRemove: @escaping @Sendable (Notification) -> Bool
    ) {
        Task(priority: .background) {
            await removeAll(where: shouldRemove)
        }
    }
}

// Uso:
let notificationsStore = NotificationsStore()
notificationsStore.cleanUpInBackground { notification in
    notification.isRead && notification.age > 30
}
Enter fullscreen mode Exit fullscreen mode

A pesar de que Swift Concurrency define nonisolated como un dominio de aislamiento, en principio no es un dominio propio porque no protege datos ni sincroniza acceso. Por eso, en principio, cleanUpInBackground es código que vive fuera de cualquier actor. Luego, shouldRemove viaja así:

  1. Invocador a nonisolated: el closure entra como parámetro a cleanUpInBackground.
  2. nonisolated a Task: el closure es capturado por el bloque Task(priority: .background), cruzando hacia el dominio de la tarea. Aquí es donde @escaping y @Sendable son necesarios.
  3. Task a actor: el closure se pasa a removeAll(where:) con await, cruzando hacia el dominio del actor.

¿Qué diferencia hay entre capturar una variable let y una var dentro de un closure @Sendable?

Cuando se captura un var dentro de un closure, en realidad se envuelve el valor en una especie de caja que, en sentido práctico, mueve el valor del stack al heap. Como pasar esta referencia entre dominios de aislamiento es inseguro, al usar @Sendable aparece el error de compilación:

// Reference to captured var ‘x’ in concurrently-executing code 
Enter fullscreen mode Exit fullscreen mode

¿Para qué sirve una capture list y cuándo es obligatorio usarla?

Una capture list sirve para tomar una fotografía inmutable del valor de una variable en el momento en que se crea el closure, en lugar de capturar una referencia a esa variable, lo que satisface el requisito de inmutabilidad de @Sendable.

¿Qué errores de compilación aparecen si se pasa un closure entre dominios de aislamiento sin marcarlo como @Sendable?

// Passing closure as a sending parameter risks causing data races between code in the current Task and the concurrent execution of the closure.

// Sending task-isolated value of type ‘(X) -> Y’ with later accesses to actor-isolated context risks causing data races 
Enter fullscreen mode Exit fullscreen mode

¿Cómo cambia el comportamiento del compilador al agregar @Sendable al parámetro shouldBeRemoved?

El compilador puede verificar que los valores capturados por el closure sean Sendable.

¿Por qué el compilador rechaza capturar una var de tipo String aunque String sea un value type?

Porque cuando un closure captura una variable, se crea una caja que la mueve del stack al heap. Por esta razón, se tiene que trabajar con valores inmutables.

¿Qué significa "capturar por valor" (capture by-value) y qué problema evita?

Capturar por valor significa que el closure se queda con una copia independiente del valor en el momento de su creación, en lugar de mantener una referencia a la variable.

Esto evita mutaciones inesperadas y carreras de datos. Por ejemplo:

var counter = 0
let closure = { print(counter) }
counter = 99
closure() // imprime 99, no 0
Enter fullscreen mode Exit fullscreen mode

Si se captura por valor pasa lo siguiente:

var counter = 0
let closure = { [counter] in print(counter) }
counter = 99
closure() // imprime 0 — trabaja con su propia copia
Enter fullscreen mode Exit fullscreen mode

Bibliografía

Top comments (0)