DEV Community

GoyesDev
GoyesDev

Posted on

[SC] El BuildSetting "Approachable Concurrency"

"Approachable concurrency" es la unión de las siguientes propuestas:

5.9 SE-401: Disable Outward Actor Isolation Inference
6.0 SE-418: Infer Sendable for methods & key path literals
6.0 SE-434: Usability of global-actor-isolated types
6.2 SE-461: nonisolated(nonsending) by Default
6.2 SE-466: Control default actor isolatioin inference
6.2 SE-470: Global-actor isolated conformances
6.2 SE-486: Migration tooling for Swift features

Enables upcoming features that aim to provide a more approachable path to Swift Concurrency: DisableOutwardActorInference, GlobalActorIsolatedTypesUsability, InferIsolatedConformances, InferSendableFromCaptures, and NonisolatedNonsendingByDefault.

En el proyecto de iOS se busca SWIFT_APPROACHABLE_CONCURRENCY.

Revisión de las propuestas:

Disable Outward Actor Isolation Inference

Declarations that are not explicitly annotated with either a global actor or nonisolated can infer global actor isolation from several different places:

A struct or class containing a wrapped instance property with a global actor-qualified wrappedValue infers actor isolation from that property wrapper:

@propertyWrapper
struct UIUpdating<Wrapped> {
  @MainActor var wrappedValue: Wrapped
}

struct CounterView { // infers @MainActor from use of @UIUpdating
  @UIUpdating var intValue: Int = 0
}
Enter fullscreen mode Exit fullscreen mode

El código anterior era confuso. Por esta razón:

This proposal advocates for removing this inference rule when compiling in the Swift 6 language mode. Given the example above, CounterView would no longer infer @MainActor isolation in Swift 6.

Usability of global-actor-isolated types

Esta propuesta de Swift 6.0 resuelve tres problemas de usabilidad relacionados con tipos que están aislados a actores globales (como @MainActor).

Problema 1: Propiedades var en structs aislados

Antes: Si tenías un struct con @MainActor, las propiedades var no podían usarse fuera del actor sin marcarlas nonisolated(unsafe), lo cual era innecesariamente alarmante:

@MainActor struct S {
  nonisolated(unsafe) var x: Int = 0 // feo e innecesario
}
Enter fullscreen mode Exit fullscreen mode

Después: Si la propiedad es de tipo Sendable (como Int), Swift infiere automáticamente nonisolated dentro del mismo módulo, porque acceder a un valor Sendable nunca puede causar una carrera de datos:

@MainActor struct S {
  var x: Int = 0 // Swift lo trata como nonisolated dentro del módulo
}
Enter fullscreen mode Exit fullscreen mode

La clave es que esto solo aplica dentro del módulo, porque desde afuera alguien podría convertir esa propiedad en "computed" (con lógica aislada al actor) y eso rompería compatibilidad.

Problema 2: Closures aislados y @Sendable

Antes: Un closure @MainActor no era automáticamente @Sendable, lo que causaba errores absurdos al pasarlo a un Task:

func test(fn: @escaping @MainActor () -> Void) {
  Task {
    await fn() // ❌ error: tipo no-Sendable capturado
  }
}
Enter fullscreen mode Exit fullscreen mode

Después: @Sendable se infiere automáticamente en closures aislados a un actor global. Tiene sentido porque ese closure siempre corre en el mismo actor, así que nunca habrá acceso concurrente. Incluso puede capturar valores non-Sendable de forma segura:

class NonSendable {}

func test() {
  let ns = NonSendable()
  let closure = { @MainActor in
    print(ns) // ✅ seguro, siempre corre en MainActor
  }
  Task { await closure() }
}
Enter fullscreen mode Exit fullscreen mode

Problema 3: Subclases aisladas de superclases no-Sendable

Antes: Esto daba un error aunque en muchos casos sería perfectamente válido:

class Base {}          // no-Sendable

@MainActor
class Sub: Base {}     // ❌ error en Swift 5.10
Enter fullscreen mode Exit fullscreen mode

El problema real era que @MainActor implica Sendable, lo que permitía pasar Sub entre contextos de concurrencia heredando estado mutable no protegido de Base.

Después: Se permite la subclase aislada, pero sin conformancia a Sendable. Así se evita el peligro sin bloquear el patrón por completo:

@MainActor
class Sub: Base {}  // ✅ permitido, pero Sub no es Sendable
Enter fullscreen mode Exit fullscreen mode

Global-actor isolated conformances

Tipos de datos aislados en MainActor pueden conformar protocolos en @MainActor.

Inferring Sendable for methods and key path literals

Problema 1: Referencias a métodos y @Sendable

En Swift puedes tomar una referencia a un método sin llamarlo. Esto se llama referencia parcial o unapplied method reference:

struct Counter: Sendable {
  func increment() { ... }
}

// Referencia al método sin llamarlo
let fn = Counter.increment  // tipo: (Counter) -> () -> Void
Enter fullscreen mode Exit fullscreen mode

El problema era que al pasar esa referencia a un contexto concurrente, Swift se quejaba de que no era @Sendable, aunque el tipo Counter sí lo fuera:

// ❌ Antes: error aunque Counter es Sendable
Task {
  let fn = Counter.increment  // 'Counter.increment' no es @Sendable
}
Enter fullscreen mode Exit fullscreen mode

Esto era inconsistente: si Counter es Sendable, cualquier referencia a sus métodos también debería serlo, porque no capturan estado no-Sendable.

Después: el compilador infiere @Sendable automáticamente:

// ✅ Ahora: funciona sin anotaciones
Task {
  let fn = Counter.increment  // @Sendable inferido
}
Enter fullscreen mode Exit fullscreen mode

Problema 2: Key paths y Sendable

Un key path es una referencia a una propiedad que puedes almacenar y usar después:

struct Person {
  var name: String
}

let kp = \Person.name  // KeyPath<Person, String>
Enter fullscreen mode Exit fullscreen mode

El problema era que Swift marcaba los key paths como Sendable de forma conservadora, lo que causaba advertencias confusas incluso cuando no había ningún riesgo real:

// ❌ Antes: advertencia innecesaria en contextos no concurrentes
let kp = \Person.name  // ⚠️ advertencia: key path debería ser Sendable
Enter fullscreen mode Exit fullscreen mode

Y al revés, si necesitabas que fuera Sendable explícitamente, no había forma de anotarlo.

Después: dos mejoras:

// ✅ Sin advertencia si no se usa en contexto concurrente
let kp = \Person.name  // Sendable solo se infiere si hace falta

// ✅ Puedes forzarlo explícitamente cuando lo necesitas
let kp: any KeyPath<Person, String> & Sendable = \Person.name
Enter fullscreen mode Exit fullscreen mode

nonisolated(nonsending) by Default

Las funciones async no aisladas (nonisolated) siempre saltaban a un executor genérico al ejecutarse. Con esta propuesta, en cambio, heredan el actor del contexto que las llama, a menos que se marquen explícitamente con @concurrent.

Para activar manualmente los Upcoming features en un paquete de SPM

Usar .swiftLanguageMode(.v5)

.target(
  name: "YourPackageTarget",
  swiftSettings: [
    .swiftLanguageMode(.v5)
    .enableUpcomingFeature("DisableOutwardActorInference"),
    .enableUpcomingFeature("GlobalActorIsolatedTypesUsability"),
    .enableUpcomingFeature("InferIsolatedConformances"),
    .enableUpcomingFeature("InferSendableFromCaptures"),
    .enableUpcomingFeature("NonisolatedNonsendingByDefault")
  ]
)
Enter fullscreen mode Exit fullscreen mode

Notas del video

Pasos para implementar concurrency:

  1. Escribir código secuencial, de un solo hilo.
  2. Suspender la ejecución, sin paralelismo.
  3. Introducir paralelismo para mejorar el desempeño.

¿Cuáles son los nuevos cambios?

  • NonisolatedNonsendingByDefault.
    • `nonisolated(nonsending) no envía el objeto/estado a otro dominio de aislamiento.

Si se activa Migrate sobre NonisolatedNonsendingByDefault entonces aparece un warning sobre el código:

swift
// ⚠️ Feature 'NonisolatedNonsendingByDefault' will cause nonisolated async instance method 'performAsync' to run on the calle's actor; use '@concurrency' to preserve behavior.

En el warning aparece un signo de interrogación para aprender más y también un botón "Fix" para aplicar el cambio.

Es importante apoyarse en el "Migration tooling" porque la migración manual puede ser peligrosa.

Control default actor isolation inference

  • Originalmente todo es nonisolated. Sin embargo, se puede poner MainActor por defecto en todo.
  • Puede ser peligroso usar MainActor por defecto.
  • Para nuevos proyectos, sirve tener MainActor por defecto. Cuando se requiera mejor desempeño, se puede cambiar a nonisolated o @concurrent. Sin embargo, en código legado, es mejor hacer la migración manual.

Global-actor isolated conformances

swift
@MainActor
final class PersonViewModel {
let id: UUID
var name: String
init(id: UUID, name: String) {
self.id = id
self.name = name
}
}
// ❌ Conformance of 'PersonViewModel' to protocol 'Equatable' crosses into main actor-isolated code and can cause data races
extension PersonViewModel: Equatable {
static func == (lhs: PersonViewModel, rhs: PersonViewModel) -> Bool {
lhs.id == rhs.id
}
}

Soluciones:

1. Marcar con nonisolated el método de Equatable (i.e. nonisolated static func ==).

Esto permite usar el método desde cualquier dominio de aislamiento. Sin embargo, al hacer esto, es obligatorio asegurarse que los datos a usar son Sendable.

swift
extension PersonViewModel: Equatable {
nonisolated static func == (lhs: PersonViewModel, rhs: PersonViewModel) -> Bool {
lhs.id == rhs.id && lhs.name == rhs.name
// ❌ Main actor-isolated property 'name' can not be referenced from a nonisolated autoclosure
}
}

2. Conformar un protocolo solo en un actor global (i.e. extension PersonViewModel: @MainActor Equatable).

Con esto se puede comparar sin problema (usando Equatable) siempre que se esté en MainActor. Si se está en otro dominio de aislamiento, habrá un error.

swift
extension PersonViewModel: @MainActor Equatable {
static func == (lhs: PersonViewModel, rhs: PersonViewModel) -> Bool {
lhs.id == rhs.id && lhs.name == rhs.name
}
}

Estrategias de migración

No activar "Approachable Concurrency" de forma grupal. En su lugar, activar las funcionalidades una por una. Luego, hacer un PR por cada "upcoming feature". Esto hará más entendible los cambios.

Al final de todo el proceso sí se activa "Approachable Concurrency".

Algunos consejos:

  1. Hacerlo de forma iterativa.
  2. Sendable por defecto.
  3. Evitar refactorizar todo durante la marcha. El foco es concurrency.
  4. Hacer cambios pequeños en un PR que pueda ser revisado rápidaemtne.
  5. En nuevos proyectos: activar 1) Swift 6.2, 2) aislamiento por defecto en MainActor y 3) "Approachable concurrency".
  6. No aislar todo en MainActor.

¿Cómo migrar código existente?

  1. Encontrar código aislado. Posiblemente un solo archivo o clase pequeña.
  2. Incrementar la validación estricta de concurrencia. Pasar primero a Targeted y luego Complete.
  3. Habilitar "Upcoming Features" uno a uno.
  4. Deshabilitar/reiniciar los settings modificados.
  5. Someter PR a revisión.
  6. Cuando todo esté listo, activar Swift 6.2.

Error en tiempo de ejecución

"Block was expected to execute on queue []"

  • El sink de combine no funciona bien con atributos marcados con MainActor. Nada impide llamar sink desde un hilo de segundo plano. Artículo.

for await no puede tener return en su interior.

En lugar de usar return, debe haber continue al interior de for await.


Bibliografía

Top comments (0)