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
}
}
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
}
}
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
}
}
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
}
}
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
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
}
}
¿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)
}
}
let store = OrdersStore()
let expensiveOrders = await store.filter {
order in order.total > 1000
}
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
}
}
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)
}
}
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
}
)
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
}
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í:
- Invocador a
nonisolated: el closure entra como parámetro acleanUpInBackground. -
nonisolatedaTask: el closure es capturado por el bloqueTask(priority: .background), cruzando hacia el dominio de la tarea. Aquí es donde@escapingy@Sendableson necesarios. -
Taskaactor: el closure se pasa aremoveAll(where:)conawait, 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
¿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
¿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
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
Top comments (0)