Usa una única Foundation OperationQueue de larga duración y establece su maxConcurrentOperationCount antes de enviar los cálculos. En una utilidad para Mac que recibe una solicitud cada vez que se pulsa un botón, esto limita el trabajo simultáneo y permite que las solicitudes posteriores esperen.

Este tutorial crea el motor de cálculo para esa tarea de escritorio y un programa independiente para comprobar su comportamiento. Cada solicitud contiene números enteros sintéticos y todos los resultados permanecen en memoria. El cálculo se ejecuta de forma síncrona dentro de su operación, por lo que la duración de esta abarca el trabajo real.

¿Qué herramienta de concurrencia conviene para solicitudes individuales entrantes?

Una OperationQueue acepta operaciones de forma individual, y su método addOperation(_:), basado en bloques, envuelve cada cierre enviado en una operación. Mantén la cola en un objeto que persista entre solicitudes y, luego, envía un cierre por cada elemento entrante. La documentación de addOperation(_:) de Apple describe este método de envío.

Establece maxConcurrentOperationCount en un límite positivo, como 2. Apple define esta propiedad como el número máximo de operaciones de esa cola que pueden ejecutarse simultáneamente. El límite pertenece a esa cola en particular; crear una cola distinta para cada solicitud impediría aplicar un límite compartido. Si reduces el límite más adelante, las operaciones que ya se están ejecutando continuarán. La documentación de Apple sobre el límite de concurrencia explica estos límites.

Un grupo de tareas de Swift tiene un ciclo de vida distinto. Pertenece al ámbito de un withTaskGroup, no se puede usar desde fuera de la tarea que lo creó y espera a que terminen sus tareas secundarias antes de devolver el control. Por lo tanto, no se admite almacenar un grupo de tareas como propiedad para futuras pulsaciones de botón sin relación entre sí. Para este motor de cálculo síncrono, una cola de operaciones persistente proporciona el ciclo de vida necesario y un ajuste explícito de concurrencia. La documentación de grupos de tareas de la biblioteca estándar de Swift describe estas restricciones.

Organización del trabajo Enfoque adecuado
Llegan cálculos síncronos independientes de forma individual Conserva una OperationQueue y envía una operación por cada cálculo.
Una operación asíncrona principal tiene un conjunto finito de tareas secundarias Usa un grupo de tareas dentro del ámbito de esa operación; controla explícitamente las admisiones si necesitas un límite numérico.
El trabajo debe actualizar la interfaz de escritorio Realiza el cálculo por separado y, después, entrega el resultado a código aislado a MainActor.

La última fila se refiere a la entrega del resultado. La guía del lenguaje Swift identifica el actor principal como el lugar para el estado de la interfaz y muestra cierres aislados explícitamente al actor principal. Mantén la aritmética costosa fuera de ese paso de entrega. The Swift Programming Language: Concurrency explica el aislamiento de MainActor.

Define el cálculo y el contrato de finalización

El ejemplo acepta un identificador entero y un número de iteraciones entre 1 y 1_000_000. Para un número de iteraciones de n, suma los cuadrados de todos los enteros desde 1 hasta n. Devuelve el identificador junto con un resultado UInt64. El identificador etiqueta el resultado; no afecta a la aritmética.

Estos son límites ilustrativos elegidos para este programa. Con la entrada máxima admitida, el resultado es 333333833333500000, que cabe en un UInt64. La validación se realiza antes del envío, por lo que una solicitud no válida lanza un error de inmediato y no crea ninguna operación en cola. La restricción de entrada también mantiene válido el rango del bucle.

Cada solicitud aceptada produce un resultado almacenado cuando termina su operación. Los resultados se acumulan en un array, así que los identificadores repetidos no sobrescriben los resultados anteriores. La demostración usa identificadores distintos y ascendentes, y ordena por identificador para facilitar la lectura; ese orden de visualización no afirma nada sobre el orden de ejecución.

Este contrato presupone que la cola sigue disponible y que sus operaciones se ejecutan hasta completarse. El ejemplo no expone la cancelación. Si se añade más adelante, se necesitará un resultado independiente para las solicitudes sin terminar, porque Apple documenta la cancelación como una solicitud para detenerse, ante la cual el trabajo en curso debe responder. La documentación de OperationQueue de Apple describe el ciclo de vida y la cancelación de las operaciones.

Crea un programa completo con un límite compartido

Usa un Mac con un compilador Swift 6.2 o posterior y el SDK de macOS disponible mediante Xcode o sus herramientas de línea de comandos. Guarda el siguiente programa completo como main.swift. Los comandos siguientes seleccionan explícitamente el aislamiento predeterminado no aislado, de modo que los ayudantes de cálculo sean independientes de los ajustes de aislamiento del destino de una app. Swift documenta este ajuste del compilador en SE-0466: Control default actor isolation inference.

El registro de resultados usa NSLock para proteger tanto su array como sus contadores de actividad. Su conformidad @unchecked Sendable hace que la implementación sea responsable de esa sincronización: cada acceso al estado mutable usa el mismo bloqueo. Apple documenta esta responsabilidad en Sendable y la protección de secciones críticas en NSLock.

import Foundation

enum InputError: Error {
    case invalidIterations
}

struct Calculation: Sendable {
    let id: Int
    let iterations: Int
}

func sumOfSquares(_ job: Calculation) -> UInt64 {
    var sum: UInt64 = 0
    for integer in 1...job.iterations {
        let value = UInt64(integer)
        sum += value * value
    }
    return sum
}

final class ExecutionLog: @unchecked Sendable {
    private let lock = NSLock()
    private var active = 0
    private var peak = 0
    private var rows: [(id: Int, value: UInt64)] = []

    func begin() {
        lock.lock()
        defer { lock.unlock() }
        active += 1
        peak = max(peak, active)
    }

    func finish(id: Int, value: UInt64) {
        lock.lock()
        defer { lock.unlock() }
        rows.append((id: id, value: value))
        active -= 1
    }

    func snapshot() -> (
        rows: [(id: Int, value: UInt64)], peak: Int
    ) {
        lock.lock()
        defer { lock.unlock() }
        return (rows, peak)
    }
}

final class CalculationPool {
    private let queue = OperationQueue()
    private let log = ExecutionLog()

    init(limit: Int) {
        precondition(limit > 0)
        queue.name = "Synthetic calculations"
        queue.maxConcurrentOperationCount = limit
    }

    func submit(id: Int, iterations: Int) throws {
        guard (1...1_000_000).contains(iterations) else {
            throw InputError.invalidIterations
        }
        let job = Calculation(id: id, iterations: iterations)
        queue.addOperation { [log] in
            log.begin()
            let value = sumOfSquares(job)
            log.finish(id: job.id, value: value)
        }
    }

    // Call only after the producer has stopped submitting work.
    // This blocking method is for the command-line demonstration.
    func finishForDemo() -> (
        rows: [(id: Int, value: UInt64)], peak: Int
    ) {
        queue.waitUntilAllOperationsAreFinished()
        return log.snapshot()
    }
}

@main
struct Demo {
    static func main() throws {
        let limit = 2
        let pool = CalculationPool(limit: limit)

        try pool.submit(id: 1, iterations: 100_000)
        try pool.submit(id: 2, iterations: 200_000)
        try pool.submit(id: 3, iterations: 300_000)

        let report = pool.finishForDemo()
        let rows = report.rows.sorted { $0.id < $1.id }
        precondition(rows.map { $0.id } == [1, 2, 3])
        precondition(rows.map { $0.value } == [
            UInt64(333338333350000),
            UInt64(2666686666700000),
            UInt64(9000045000050000)
        ])
        precondition(report.peak >= 1 && report.peak <= limit)

        for row in rows {
            print("Job \(row.id): \(row.value)")
        }
        print("Observed peak: \(report.peak)")
    }
}

Compílalo y ejecútalo desde el directorio que contiene main.swift:

swiftc -swift-version 6 -default-isolation nonisolated \
  -parse-as-library main.swift -o calculation-demo
./calculation-demo

Las tres líneas de resultados deberían ser:

Job 1: 333338333350000
Job 2: 2666686666700000
Job 3: 9000045000050000

La última línea informa de un pico observado de 1 o 2. Un límite permite la superposición, pero no la exige. Las sumas esperadas se comprobaron de forma independiente mediante aritmética entera y la fórmula n * (n + 1) * (2 * n + 1) / 6. Se revisó el programa Swift, pero no se pudo compilar en el entorno de investigación porque no había ningún ejecutable de Swift instalado.

¿Por qué este límite abarca el cálculo real?

Cada operación inicia su contador de actividad, realiza el cálculo completo de forma síncrona, registra el resultado y, después, retorna. De ese cierre no se escapa ningún cálculo asíncrono adicional. Por lo tanto, limitar las operaciones en ejecución también limita estos cálculos.

Mantén esa relación al adaptar el programa. Si una operación se limita a crear una Task independiente y retorna de inmediato, la cola considera que la operación ha terminado mientras esa tarea podría seguir calculando. En ese caso, el límite de operaciones regiría los breves cierres de lanzamiento. Esto se desprende del ciclo de vida documentado de las operaciones y de la API para enviarlas mediante bloques. La descripción general de OperationQueue de Apple y addOperation(_:) definen esos elementos.

El bloqueo del registro solo cubre secciones breves de contabilidad. Mantenerlo durante sumOfSquares(_:) serializaría el acceso al cálculo e impediría la superposición prevista. Cada adquisición y liberación del bloqueo se produce dentro de un único método síncrono, de acuerdo con el requisito de Apple de desbloquear un NSLock desde el mismo hilo que lo bloqueó. La documentación de NSLock de Apple explica ese requisito.

Para comprobar una adaptación, ejecuta las mismas tres solicitudes con limit establecido en 1 y, luego, restaura 2. Ambas ejecuciones deben conservar los tres resultados exactos. La ejecución en serie debería indicar un pico de 1; la segunda debe mantenerse dentro de 2. Estas comprobaciones verifican que se conserven los resultados y el límite de cálculo observado, sin presuponer un orden de planificación concreto.

Conecta las acciones de escritorio entrantes sin bloquear la interfaz

Conserva una única CalculationPool en el componente propietario de la función de cálculo de escritorio. Cada acción entrante llama a submit(id:iterations:) una vez. Evita recrear el grupo en el controlador de acciones, porque cada nueva instancia tiene una cola y un límite independientes.

La demostración llama a finishForDemo() solo después del último envío, para que el proceso de línea de comandos pueda recoger los resultados antes de salir. Este método bloquea el hilo que lo llama. En una app de escritorio, usa la entrega de cada resultado en lugar de llamar a este método desde el controlador de un botón o desde el actor principal. Apple documenta explícitamente el comportamiento de bloqueo de waitUntilAllOperationsAreFinished().

Para integrar la interfaz, amplía la API de envío con una función de retorno de resultados aislada a MainActor y marcada como @Sendable. Después de calcular un resultado dentro de la operación, planifica una Task con un cierre explícitamente @MainActor para invocar esa función. Captura el valor inmutable del resultado y la función de retorno. Mantén solo la actualización de la vista dentro de ese cierre; deja la aritmética en la operación. Swift documenta tanto la sintaxis de los cierres del actor principal como las capturas en cierres Sendable.

Esa versión para la interfaz necesita otra definición de finalización: terminar el cálculo y mostrar su resultado son eventos distintos. El programa de línea de comandos espera a que terminen los cálculos y se almacenen los resultados de forma síncrona. Una función de retorno de interfaz programada por separado podría ejecutarse después.

¿Qué ocurre cuando llegan solicitudes más rápido de lo que se procesan?

El ajuste de concurrencia limita las operaciones que se ejecutan, mientras que el trabajo enviado adicional puede permanecer en cola. También deja que el array de resultados completados del ejemplo siga creciendo. Por consiguiente, este ejemplo limita la concurrencia de los cálculos, pero no establece un límite total de memoria ni de solicitudes pendientes. Apple documenta que la cola conserva las operaciones hasta que terminan. La descripción general de OperationQueue explica esa retención.

Si la función de escritorio puede generar ráfagas sostenidas, elige una política de admisión antes de ampliarla: acepta solo una cantidad fija de solicitudes pendientes y comunica el rechazo, pausa los envíos posteriores o sustituye una vista previa sintética pendiente por una solicitud más reciente cuando ese comportamiento sea explícitamente intencional. Coordina los recuentos de admisión y las actualizaciones de finalización mediante un único propietario sincronizado. El límite del trabajo en ejecución, por sí solo, no puede implementar esas políticas.

Para solicitudes síncronas individuales, el diseño esencial es sencillo: conserva una cola, configura su límite una vez, mantén cada cálculo dentro de su operación y sincroniza los resultados compartidos. Conserva esos límites al conectar las acciones de escritorio y añade una política explícita para las solicitudes pendientes solo cuando el patrón de llegada lo requiera.

Fuentes