Para ejecutar cálculos Swift intensivos en CPU en paralelo, define explícitamente un límite @concurrent para el trabajador y divide el trabajo independiente entre tareas secundarias. Este ejemplo para Mac mantiene las actualizaciones de la interfaz en MainActor y combina resultados sintéticos sin mutación compartida.

Por qué añadir tareas aún puede serializar el cálculo

Supongamos que una app para Mac tiene un botón Calculate que inicia un cálculo local considerable. Envolver el cálculo en Task { ... } proporciona un punto de entrada asíncrono, pero una tarea creada en un contexto de actor principal puede heredar ese aislamiento. Un bucle síncrono largo dentro de ella sigue ocupando el actor principal. Apple ilustra esta distinción en Adoptar la concurrencia de Swift.

Añadir también un grupo de tareas deja una pregunta importante sin responder: ¿dónde se ejecuta la función que contiene el bucle costoso? Si cada tarea secundaria llama al mismo método de cálculo @MainActor, ese actor serializa el cálculo. Los actores permiten que una sola tarea a la vez acceda a su estado aislado, como se explica en el capítulo sobre concurrencia de la guía del lenguaje Swift.

Para Swift 6.2 y versiones posteriores, marca el punto de entrada asíncrono del cálculo con @concurrent. Esto cambia explícitamente la ejecución para que salga del actor del llamador. Con la función NonisolatedNonsendingByDefault activada, una función asíncrona normal no aislada se ejecuta en el actor del llamador; el comportamiento anterior era distinto. La propuesta SE-0461 implementada documenta tanto el límite de la función como el atributo explícito.

Dos decisiones independientes producen el comportamiento deseado: el aislamiento del trabajador permite ejecutar fuera de MainActor, y varias tareas secundarias proporcionan trabajo que se puede planificar por separado. El entorno de ejecución decide qué hilos disponibles ejecutan esas tareas. Ni la cantidad de tareas ni @concurrent garantizan un número determinado de núcleos ejecutándose simultáneamente. Apple describe ese modelo de planificación en Adoptar la concurrencia de Swift.

Define un cálculo que se pueda dividir correctamente

El siguiente ejemplo permite que una persona usuaria de Mac inicie o cancele un cálculo sintético y muestre su suma de comprobación final. Todas las entradas son constantes o enteros generados. El cálculo tiene este contrato de valores exacto:

  1. Visitar exactamente una vez cada índice entero de 0..<count.
  2. Convertir cada índice a UInt64 y aplicar repetidamente x = (x &* 1_664_525) &+ 1_013_904_223 durante la cantidad de rondas solicitada.
  3. Sumar los valores resultantes mediante suma con desbordamiento circular.
  4. Devolver cero para una entrada vacía, rechazar cantidades o rondas negativas y rechazar un límite de trabajadores no positivo.
  5. Lanzar un error cuando se observe la cancelación, en lugar de presentar una suma de comprobación incompleta como resultado terminado.

La aritmética con desbordamiento circular es intencional. La aritmética entera habitual de Swift provoca una trampa si hay desbordamiento; los operadores &+ y &* admiten explícitamente el desbordamiento. Consulta Operadores avanzados.

En este contrato, cada índice es independiente y la suma con desbordamiento circular combina los totales parciales módulo 2 elevado a 64. Por lo tanto, cambiar la agrupación o el orden en que se completan los trabajos conserva el valor final exacto. Esa conclusión se desprende de la aritmética elegida. Un cálculo de punto flotante necesita su propia regla de corrección, porque reagrupar las sumas puede cambiar el redondeo.

La partición usa división y resto: cada trabajador recibe una cantidad base igual de índices, y los primeros trabajadores reciben un índice adicional. Por ejemplo, para una cantidad de 17 y tres trabajadores, los rangos son 0..<6, 6..<12 y 12..<17. Cubren toda la entrada una sola vez, incluido el resto.

Construye el ejemplo completo de cálculo para Mac

Usa una cadena de herramientas Swift 6.2 o posterior y un destino de app SwiftUI para macOS con un destino de implementación de macOS 13 o posterior. Sustituye el punto de entrada de la app y la vista de contenido generados por los siguientes dos bloques, colocados juntos en un único archivo Swift. Forman un programa completo; mantén una sola declaración de app @main en el destino.

El espacio de nombres del cálculo es explícitamente nonisolated, por lo que sus miembros no adquieren aislamiento inferido de un actor global. Swift documenta esta declaración a nivel de tipo en SE-0449. Ambos métodos asíncronos de cálculo usan @concurrent para declarar explícitamente su límite de ejecución.

import Foundation
import SwiftUI

nonisolated enum CalculationError: Error {
    case invalidInput
}

nonisolated enum SyntheticCalculator {
    @concurrent
    static func run(
        count: Int,
        rounds: Int,
        workerLimit: Int
    ) async throws -> UInt64 {
        guard count >= 0, rounds >= 0, workerLimit > 0 else {
            throw CalculationError.invalidInput
        }
        try Task.checkCancellation()
        guard count > 0 else { return 0 }

        let workers = min(count, workerLimit)
        let baseSize = count / workers
        let remainder = count % workers

        return try await withThrowingTaskGroup(
            of: UInt64.self,
            returning: UInt64.self
        ) { group in
            var lower = 0

            for worker in 0..<workers {
                try Task.checkCancellation()
                let size = baseSize + (worker < remainder ? 1 : 0)
                let start = lower
                let end = start + size
                lower = end

                group.addTask {
                    try await calculateChunk(
                        lower: start,
                        upper: end,
                        rounds: rounds
                    )
                }
            }

            var total: UInt64 = 0
            for try await partial in group {
                total = total &+ partial
            }
            try Task.checkCancellation()
            return total
        }
    }

    @concurrent
    private static func calculateChunk(
        lower: Int,
        upper: Int,
        rounds: Int
    ) async throws -> UInt64 {
        try Task.checkCancellation()
        var total: UInt64 = 0

        for index in lower..<upper {
            if index % 256 == 0 {
                try Task.checkCancellation()
            }
            var value = UInt64(index)
            for round in 0..<rounds {
                if round % 4096 == 0 {
                    try Task.checkCancellation()
                }
                value = (value &* 1_664_525) &+ 1_013_904_223
            }
            total = total &+ value
        }

        try Task.checkCancellation()
        return total
    }
}

Cada tarea secundaria captura límites inmutables y devuelve un entero. Solo la tarea principal actualiza total; las tareas secundarias nunca añaden elementos a una colección compartida ni modifican el estado de la interfaz. Los grupos de tareas admiten la ejecución concurrente de tareas secundarias, requieren resultados transferibles y mantienen la vida útil de las tareas secundarias dentro del ámbito del grupo. Consulta la documentación de TaskGroup de Apple.

Añade este código de interfaz al mismo archivo:

@main
@MainActor
struct ParallelCalculationApp: App {
    var body: some Scene {
        WindowGroup {
            CalculationView()
        }
    }
}

@MainActor
struct CalculationView: View {
    @State private var status = "Ready"
    @State private var work: Task<Void, Never>?

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Synthetic Calculation")
                .font(.title2)
            Text(status)
                .textSelection(.enabled)

            HStack {
                Button("Calculate") {
                    startCalculation()
                }
                .disabled(work != nil)

                Button("Cancel") {
                    work?.cancel()
                }
                .disabled(work == nil)
            }
        }
        .padding(24)
        .frame(minWidth: 440, minHeight: 180)
        .onDisappear {
            work?.cancel()
        }
    }

    private func startCalculation() {
        guard work == nil else { return }
        status = "Calculating…"
        let workerLimit = max(
            1,
            ProcessInfo.processInfo.activeProcessorCount
        )

        work = Task { @MainActor in
            defer { work = nil }
            do {
                let checksum = try await SyntheticCalculator.run(
                    count: 1_000_000,
                    rounds: 128,
                    workerLimit: workerLimit
                )
                try Task.checkCancellation()
                status = "Checksum: \(checksum)"
            } catch is CancellationError {
                status = "Cancelled"
            } catch {
                status = "Calculation failed: \(error)"
            }
        }
    }
}

La interfaz conserva el identificador de la tarea externa para que el botón Cancel pueda cancelarla. Su tarea permanece aislada en el actor principal, llama al cálculo concurrente y actualiza el estado mostrado después de esperar el resultado. Conservar el identificador también permite cancelar la tarea cuando desaparece la vista. La documentación de Task de Apple explica que descartar un identificador no cancela la tarea.

El límite de trabajadores parte de activeProcessorCount de Foundation, que informa la cantidad de núcleos de procesamiento activos disponibles en la computadora. Tómalo como un valor inicial que conviene medir, no como una cantidad de tareas óptima garantizada. Su significado documentado aparece en activeProcessorCount.

Comprueba la corrección antes de comparar el rendimiento

Empieza con la entrada ilustrativa pequeña: cambia la llamada a count: 17, rounds: 4 y workerLimit: 3. La suma de comprobación esperada es 18149427060254280572. Repite con workerLimit: 1; el valor debe coincidir. Comprueba también count: 0, que debe devolver cero, y una cantidad positiva menor que el límite de trabajadores, que debe incluir todos los índices.

La suma de comprobación ilustrativa y la cobertura de rangos se verificaron con una pequeña reproducción en Python de la aritmética con desbordamiento circular. No había un compilador de Swift disponible en el entorno de investigación, así que el programa Swift no se ha compilado ni ejecutado aquí.

A continuación, restablece la entrada mayor y compara compilaciones optimizadas con límites de trabajadores de uno, dos y la cantidad de procesadores activos. Mantén sin cambios la cantidad y las rondas, conserva la suma de comprobación obtenida y mide varias ejecuciones. Este es un procedimiento de medición propuesto; no se afirma ningún tiempo ni aceleración.

Una ejecución con un trabajador usa el mismo límite concurrente y la misma estructura de grupo de tareas, lo que la convierte en una comparación útil para esta implementación. Puede que las cargas pequeñas no justifiquen el costo adicional de partición y planificación. Si los índices de un cálculo real tienen costos muy distintos, los rangos del mismo tamaño también pueden hacer que un trabajador termine mucho más tarde; una alternativa razonable para el siguiente experimento es usar fragmentos más pequeños con una cantidad limitada de tareas simultáneas.

En un cálculo que genere una matriz, adjunta un índice de fragmento a cada resultado y ensambla los fragmentos en el orden de entrada. Las tareas secundarias del grupo pueden ejecutarse en cualquier orden, y el grupo expone los resultados completados a medida que están disponibles. La suma de comprobación evita los problemas de orden solo porque la reducción elegida es independiente del orden. Consulta TaskGroup.

Haz visibles los problemas de cancelación y aislamiento

La cancelación es cooperativa. Al llamar a cancel(), se marca la tarea; el código del cálculo debe observar ese estado. Las comprobaciones de ambos bucles dan oportunidades al trabajador para lanzar un error, incluso cuando un solo índice requiere muchas rondas. Esos intervalos son decisiones de implementación, no una latencia de cancelación garantizada. Apple documenta esta responsabilidad en Task.

El bucle que procesa los resultados y puede lanzar errores también es importante. Si una tarea secundaria lanza un error y este se propaga fuera del cuerpo del grupo, las tareas secundarias restantes se cancelan. El grupo sigue esperándolas hasta que terminan, por lo que sus comprobaciones de cancelación siguen siendo necesarias. La implementación de la biblioteca estándar de Swift documenta este comportamiento en TaskGroup.swift.

Si la interfaz sigue bloqueándose, rastrea toda la ruta de llamadas costosas. Busca si un trabajador llama a un método del actor principal, accede a propiedades aisladas por actor o realiza preparativos considerables antes de llegar al límite concurrente. Pasa los valores enteros necesarios al trabajador y mantén los bucles largos dentro de ese límite.

Si el compilador rechaza @concurrent, comprueba la versión de la cadena de herramientas. El atributo se implementó en Swift 6.2 y se aplica a funciones asíncronas. Para una cadena de herramientas anterior, el modelo previo de ejecución asíncrona no aislada está documentado en SE-0461; consulta la documentación correspondiente a ese compilador en lugar de asumir que todas las versiones dan el mismo comportamiento a nonisolated async.

Para esta tarea de Mac, la arquitectura útil es sencilla: el actor principal es dueño del estado de la interfaz, los trabajadores concurrentes reciben valores independientes y la tarea principal combina resultados completos. Conserva el contrato de valores al ajustar los tamaños de los fragmentos y verifica tanto la igualdad de las sumas de comprobación como la capacidad de respuesta de la interfaz antes de mantener un cambio de rendimiento.

Fuentes