DEV Community

GoyesDev
GoyesDev

Posted on

[SC] Usar un ejecutor de actor personalizado

Preguntas

¿En qué situaciones el executor por defecto de Swift resulta insuficiente y cuándo debería considerarse un ejecutor personalizado?

  • No se quiere usar el pool de hilos global cooperativo.
  • Se quiere usar una cola serial específica.
  • Se quiere enganchar un hilo específico.

Puede ser el caso de una biblioteca de terceros que espera recibir un DispatchQueue serial para orquestar sus operaciones.

¿Qué diferencia hay entre un SerialExecutor y un TaskExecutor, y para qué sirve cada uno?

Un SerialExecutor sirve para despachar tareas en un actor, mientras que un TaskExecutor sirve para despachar tareas en un Task.

¿Por qué es importante que el actor mantenga una referencia fuerte al ejecutor cuando se usa asUnownedSerialExecutor()?

asUnownedSerialExecutor() entrega una referencia simple a un objeto en el heap sin conteo de referencias. Por esta razón, si no se retiene la referencia, el objeto se pierde.

¿Cómo funciona el método enqueue(_:) dentro de un SerialExecutor basado en DispatchQueue?

Se despacha una tarea en la cola seria (i.e. dispatchQueue.async) que consiste en ejecutar síncronamente un UnownedJob en un SerialExecutor (i.e. unownedJob.runSynchronously(on: unownedExecutor)).

final class DispatchQueueExecutor: SerialExecutor {
  private let dispatchQueue: DispatchQueue
  init(dispatchQueue: DispatchQueue) {
    self.dispatchQueue = dispatchQueue
  }

  func enqueue(_ job: consuming ExecutorJob) {
    let unownedJob = UnownedJob(job)
    let unownedExecutor = asUnownedSerialExecutor()

    dispatchQueue.async {
      unownedJob.runSynchronously(on: unownedExecutor)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
actor LoggingActor {
  private let executor: DispatchQueueExecutor
  nonisolated var unownedExecutor: UnownedSerialExecutor {
    executor.asUnownedSerialExecutor()
  }

  init(dispatchQueue: DispatchQueue) {
    self.executor = DispatchQueueExecutor(dispatchQueue: dispatchQueue)
  }

  func log(_ message: String) {
    print("[\(Thread.current)]: \(message)")
  }
}
Enter fullscreen mode Exit fullscreen mode

¿Qué métodos permiten configurar una preferencia de ejecutor para tareas no aisladas a un actor?

Primero hay que crear un TaskExecutor. Notar en el siguiente código cómo se conforma TaskExecutor y cómo se despacha dentro de la cola serial (i.e. dispatchQueue.async) una tarea que consiste en ejecutar síncronamente el job recibido (i.e. unownedJob.runSynchronously(on: unownedExecutor)):

final class DispatchQueueTaskExecutor: TaskExecutor {
  private let dispatchQueue: DispatchQueue
  init(dispatchQueue: DispatchQueue) {
    self.dispatchQueue = dispatchQueue
  }
  func enqueue(_ job: consuming ExecutorJob) {
    let unownedJob = UnownedJob(job)
    let unownedExecutor = asUnownedTaskExecutor()

    dispatchQueue.async {
      unownedJob.runSynchronously(on: unownedExecutor)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Luego, al definir el Task, se le pasa el ejecutor preferido:

struct DispatchQueueTaskExecutorTests {
  @Test
  func execute() async {
    let queue = DispatchQueue(label: "com.logger.queue")

    let taskExecutor = DispatchQueueTaskExecutor(dispatchQueue: queue)

    await Task(executorPreference: taskExecutor) {
      print("Task Executor example")
    }.value

    #expect(1 == 1)
  }
}
Enter fullscreen mode Exit fullscreen mode

¿Qué métodos permiten configurar una preferencia de ejecutor para tareas aisladas a un actor?

Para ejecutar una tarea aislada a un actor se usa runSynchronously(isolatedTo:taskExecutor:) en lugar de runSynchronously(on:) como se muestra a continuación:

final class DispatchQueueTaskExecutor: TaskExecutor {
  private let dispatchQueue: DispatchQueue
  init(dispatchQueue: DispatchQueue) {
    self.dispatchQueue = dispatchQueue
  }
  func enqueue(_ job: consuming ExecutorJob) {
    let unownedJob = UnownedJob(job)
    let unownedExecutor = asUnownedTaskExecutor()

    dispatchQueue.async {
      unownedJob.runSynchronously(isolatedTo: DispatchQueueExecutor.loggingExecutor.asUnownedSerialExecutor(), taskExecutor: self.asUnownedTaskExecutor())
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Mostrar un ejemplo donde se comparta un executor entre actores

Se debe mantener una referencia al executor como si fuera un singleton (e.g. static let loggingExecutor). Luego, el actor hará referencia a ese singleton desde var unownedExecutor: UnownedSerialExecutor.

extension DispatchQueueExecutor {
  static let loggingExecutor = DispatchQueueExecutor(
    dispatchQueue: DispatchQueue(label: "com.logger.queue", qos: .utility)
  )
}

actor SharedExecutorLoggingActor {
  nonisolated var unownedExecutor: UnownedSerialExecutor {
    DispatchQueueExecutor.loggingExecutor.asUnownedSerialExecutor()
  }

  func log(_ message: String) {
    print("[\(Thread.current)] \(message)")
  }
}
Enter fullscreen mode Exit fullscreen mode

Recordar sin mirar

¿Cuál es la diferencia entre compartir un ejecutor entre múltiples actores y que cada actor tenga el suyo propio? ¿Qué implicaciones tiene esto en la concurrencia?

¿Qué restricción importante existe al combinar TaskExecutor y SerialExecutor en un mismo tipo?


Revisión y reflexión

El artículo describe los ejecutores personalizados como una "solución excepcional". ¿En qué escenarios concretos del desarrollo real estaría justificado usarlos frente al executor por defecto?

¿Qué riesgos o errores podrían surgir si se implementa incorrectamente un ejecutor personalizado, por ejemplo usando una cola concurrente donde se requiere una serial?


Bibliografía

Top comments (0)