Programación asincrónica en F#

La programación asincrónica es un mecanismo esencial para las aplicaciones modernas por diversos motivos. La mayoría de los desarrolladores se encontrarán con dos casos de uso principales:

  • Presentar un proceso de servidor que puede atender un número considerable de solicitudes entrantes simultáneas, al mismo tiempo que se minimizan los recursos del sistema que están ocupados mientras el procesamiento de solicitudes espera entradas de sistemas o servicios externos a ese proceso.
  • Mantener un subproceso principal o interfaz de usuario con capacidad de respuesta al mismo tiempo que progresa el trabajo en segundo plano.

Aunque el trabajo en segundo plano suele implicar el uso de varios subprocesos, es importante considerar por separado los conceptos de asincronía y multithreading. De hecho, son aspectos independientes, y uno no implica el otro. En este artículo se describen estos conceptos con más detalle.

Definición de asincronía

Vale la pena explicar con un poco más de detalle la afirmación anterior, es decir, que la asincronía es independiente del uso de varios subprocesos. Hay tres conceptos que a veces están relacionados, pero son estrictamente independientes entre sí:

  • Simultaneidad: cuando se ejecutan varios cálculos en períodos de tiempo superpuestos.
  • Paralelismo: cuando se ejecutan varios cálculos o varias partes de un solo cálculo exactamente al mismo tiempo.
  • Asincronía: cuando uno o varios cálculos se pueden ejecutar con independencia del flujo del programa principal.

Los tres son conceptos ortogonales, pero se pueden combinar fácilmente, sobre todo cuando se usan juntos. Por ejemplo, es posible que tenga que ejecutar varios cálculos asincrónicos en paralelo. Esta relación no significa que el paralelismo o la asincronía se impliquen entre sí.

Si considera la etimología de la palabra "asincrónico", está integrada por dos elementos:

  • "a", que significa "no".
  • "sincrónico", que significa "al mismo tiempo".

Al unir estos dos elementos, el término resultante significa "no al mismo tiempo". Eso es todo. No hay ninguna implicación de simultaneidad o paralelismo en esta definición. Esto también es cierto en la práctica.

En términos prácticos, los cálculos asincrónicos de F# se programan para ejecutarse independientemente del flujo del programa principal. Esta ejecución independiente no implica simultaneidad ni paralelismo, ni implica que un cálculo siempre se produzca en segundo plano. De hecho, los cálculos asincrónicos pueden incluso ejecutarse de forma sincrónica, en función de la naturaleza del cálculo y del entorno en el que se ejecuta.

La conclusión principal es que los cálculos asincrónicos son independientes del flujo del programa principal. Aunque hay pocas garantías sobre cuándo o cómo se ejecuta un cálculo asincrónico, hay varias maneras de orquestarlos y programarlos. En el resto de este artículo se exploran los conceptos básicos de la asincronía de F# y se indica cómo se usan los tipos, las funciones y las expresiones integradas en F#.

Conceptos principales

En F#, la programación asincrónica se centra en dos conceptos básicos: los cálculos asincrónicos y las tareas.

  • Tipo Async<'T> con las expresiones async { }, que representa un cálculo asincrónico compuesto que se puede iniciar para formar una tarea.
  • Tipo Task<'T>, con las expresiones task { }, que representa una tarea de .NET en ejecución.

En general, debe considerar la posibilidad de usar task {…} antes que async {…} en el código nuevo si está interoperando con bibliotecas de .NET que usan tareas y, si no se basa en llamadas de cola de código asincrónicas o en la propagación implícita de tokens de cancelación.

Conceptos básicos de "async"

Puede ver los conceptos básicos de la programación "async" en el ejemplo siguiente:

open System
open System.IO

// Perform an asynchronous read of a file using 'async'
let printTotalFileBytesUsingAsync (path: string) =
    async {
        let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    printTotalFileBytesUsingAsync "path-to-file.txt"
    |> Async.RunSynchronously

    Console.Read() |> ignore
    0

En el ejemplo, la función printTotalFileBytesUsingAsync es de tipo string -> Async<unit>. Si se llama a la función no se ejecuta realmente el cálculo asincrónico. En su lugar, se devuelve un objeto Async<unit> que actúa como una especificación del trabajo que se va a ejecutar de forma asincrónica. Llama a Async.AwaitTask en su cuerpo, que convierte el resultado de ReadAllBytesAsync en un tipo adecuado.

Otra línea importante es la llamada a Async.RunSynchronously. Se trata de una de las funciones de inicio del módulo Async a las que deberá llamar si quiere ejecutar realmente un cálculo asincrónico de F#.

Esta es una diferencia fundamental con el estilo de programación async de C# o Visual Basic. En F#, los cálculos asincrónicos se pueden considerar tareas inactivas. Deben iniciarse explícitamente para ejecutarse de manera efectiva. Esto tiene algunas ventajas, ya que permite combinar y secuenciar el trabajo asincrónico mucho más fácilmente que en C# o Visual Basic.

Combinación de cálculos asincrónicos

Aquí se muestra un ejemplo que se basa en el anterior mediante la combinación de cálculos:

open System
open System.IO

let printTotalFileBytes path =
    async {
        let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    argv
    |> Seq.map printTotalFileBytes
    |> Async.Parallel
    |> Async.Ignore
    |> Async.RunSynchronously

    0

Como puede ver, la función main tiene bastantes elementos más. Conceptualmente, hace lo siguiente:

  1. Transforma los argumentos de la línea de comandos en una secuencia de cálculos Async<unit> con Seq.map.
  2. Crea un objeto Async<'T[]> que programa y ejecuta los cálculos printTotalFileBytes en paralelo cuando se ejecuta.
  3. Crea un objeto Async<unit> que ejecutará el cálculo paralelo y omitirá su resultado (que es unit[]).
  4. Ejecuta explícitamente el cálculo compuesto general con Async.RunSynchronously y aplica un bloqueo hasta que se completa.

Cuando se ejecuta este programa, printTotalFileBytes se ejecuta en paralelo para cada argumento de la línea de comandos. Dado que los cálculos asincrónicos se ejecutan independientemente del flujo del programa, no hay un orden definido en el que imprimen su información y terminan de ejecutarse. Los cálculos se programarán en paralelo, pero no se garantiza su orden de ejecución.

Secuenciación de cálculos asincrónicos

Como Async<'T> es una especificación de trabajo, y no una tarea ya en ejecución, esto le permite realizar transformaciones más complejas fácilmente. Este es un ejemplo en el que se secuencia un conjunto de cálculos asincrónicos para que se ejecuten uno después de otro.

let printTotalFileBytes path =
    async {
        let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    argv
    |> Seq.map printTotalFileBytes
    |> Async.Sequential
    |> Async.Ignore
    |> Async.RunSynchronously
    |> ignore

Esto programará printTotalFileBytes para que se ejecute en el orden de los elementos de argv, en lugar de programarlos en paralelo. Dado que cada operación sucesiva no se programará hasta después de que el cálculo anterior termine de ejecutarse, los cálculos se secuencian de modo que no haya ninguna superposición en su ejecución.

Funciones importantes del módulo Async

Cuando escriba código asincrónico en F#, por lo general interactuará con un marco que controle automáticamente la programación de los cálculos. Aun así, este no siempre es el caso, por lo que conviene comprender las distintas funciones que se pueden usar para programar el trabajo asincrónico.

Debido a que los cálculos asincrónicos de F# son una especificación de trabajo, y no una representación del trabajo que ya se está ejecutando, deben iniciarse explícitamente con una función inicial. Hay muchos métodos Async de inicio que son útiles en contextos diferentes. En la sección siguiente se describen algunas de las funciones de inicio más habituales.

Async.StartChild

Inicia un cálculo secundario en un cálculo asincrónico. Esto permite ejecutar simultáneamente varios cálculos asincrónicos. El cálculo secundario comparte un token de cancelación con el cálculo primario. Si se cancela el cálculo primario, también se cancela el secundario.

Signature:

computation: Async<'T> * ?millisecondsTimeout: int -> Async<Async<'T>>

Cuándo usarlo:

  • Cuando se quiere ejecutar varios cálculos asincrónicos simultáneamente, en lugar de uno a la vez, pero sin programarlos en paralelo.
  • Cuando se quiere vincular la duración de un cálculo secundario a la de un cálculo primario.

Qué conviene tener en cuenta:

  • Iniciar varios cálculos con Async.StartChild no es lo mismo que programarlos en paralelo. Si quiere programar cálculos en paralelo, use Async.Parallel.
  • La cancelación de un cálculo primario desencadenará la cancelación de todos los cálculos secundarios que inició.

Async.StartImmediate

Ejecuta un cálculo asincrónico y comienza inmediatamente en el subproceso actual del sistema operativo. Esto resulta útil si necesita actualizar algo en el subproceso que realiza la llamada durante el cálculo. Por ejemplo, si un cálculo asincrónico debe actualizar una interfaz de usuario (por ejemplo, actualizar una barra de progreso), debe usarse Async.StartImmediate.

Signature:

computation: Async<unit> * ?cancellationToken: CancellationToken -> unit

Cuándo usarlo:

  • Cuando necesite actualizar algo en el subproceso que realiza la llamada en medio de un cálculo asincrónico.

Qué conviene tener en cuenta:

  • El código del cálculo asincrónico se ejecutará en cualquier subproceso en el que esté programado. Esto puede ser problemático si ese subproceso es sensible de alguna manera, como un subproceso de interfaz de usuario. En tales casos, es probable que sea inadecuado usar Async.StartImmediate.

Async.StartAsTask

Ejecuta un cálculo en el grupo de subprocesos. Devuelve un objeto Task<TResult> que se completará en el estado correspondiente cuando finalice el cálculo (genera el resultado, produce la excepción o se cancela). Si no se proporciona ningún token de cancelación, se usa el token de cancelación predeterminado.

Signature:

computation: Async<'T> * ?taskCreationOptions: TaskCreationOptions * ?cancellationToken: CancellationToken -> Task<'T>

Cuándo usarlo:

  • Cuando necesite llamar a una API de .NET que genere un objeto Task<TResult> para representar el resultado de un cálculo asincrónico.

Qué conviene tener en cuenta:

  • Esta llamada asignará un objeto Task adicional, lo que puede aumentar la sobrecarga si se usa con frecuencia.

Async.Parallel

Programa una secuencia de cálculos asincrónicos que se ejecutarán en paralelo y genera una matriz de resultados en el orden en el que se proporcionaron. El grado de paralelismo se puede ajustar o limitar opcionalmente si se especifica el parámetro maxDegreeOfParallelism.

Signature:

computations: seq<Async<'T>> * ?maxDegreeOfParallelism: int -> Async<'T[]>

Cuándo usarlo

  • Cuando se necesita ejecutar un conjunto de cálculos al mismo tiempo y no se depende de su orden de ejecución.
  • Cuando no necesitan los resultados de los cálculos programados en paralelo hasta que se hayan completado todos.

Qué conviene tener en cuenta:

  • Solo se puede acceder a la matriz resultante de valores una vez que hayan finalizado todos los cálculos.
  • Los cálculos se ejecutarán cuando se programen. Este comportamiento significa que no puede confiar en su orden de ejecución.

Async.Sequential

Programa una secuencia de cálculos asincrónicos que se ejecutarán en el orden en el que se pasan. Se ejecutará el primer cálculo, después el siguiente, y así sucesivamente. No se ejecutarán cálculos en paralelo.

Signature:

computations: seq<Async<'T>> -> Async<'T[]>

Cuándo usarlo

  • Cuando se necesita ejecutar varios cálculos en orden.

Qué conviene tener en cuenta:

  • Solo se puede acceder a la matriz resultante de valores una vez que hayan finalizado todos los cálculos.
  • Los cálculos se ejecutarán en el orden en el que se pasan a esta función, lo que puede conllevar que pasará más tiempo antes de que se devuelvan los resultados.

Async.AwaitTask

Devuelve un cálculo asincrónico que espera a que el objeto Task<TResult> determinado se complete y devuelva su resultado como Async<'T>.

Signature:

task: Task<'T> -> Async<'T>

Cuándo usarlo:

  • Cuando se consume una API de .NET que devuelve un objeto Task<TResult> dentro de un cálculo asincrónico de F#.

Qué conviene tener en cuenta:

  • Las excepciones se encapsulan en un objeto AggregateException según la convención de la biblioteca TPL. Este comportamiento es diferente de la forma en que F# suele mostrar excepciones.

Async.Catch

Crea un cálculo asincrónico que ejecuta un objeto Async<'T> especificado y devuelve un objeto Async<Choice<'T, exn>>. Si el objeto Async<'T> especificado se completa correctamente, se devuelve un objeto Choice1Of2 con el valor resultante. Si se produce una excepción antes de que se complete, se devuelve un objeto Choice2of2 con la excepción generada. Si se usa en un cálculo asincrónico compuesto a su vez de muchos cálculos y uno de estos produce una excepción, el cálculo que lo engloba se detendrá por completo.

Signature:

computation: Async<'T> -> Async<Choice<'T, exn>>

Cuándo usarlo:

  • Cuando se realiza un trabajo asincrónico que podría producir un error con una excepción y se quiere controlar dicha excepción en el llamador.

Qué conviene tener en cuenta:

  • Cuando se usan cálculos asincrónicos combinados o secuenciados, el cálculo que los engloba se detendrá por completo si uno de sus cálculos "internos" produce una excepción.

Async.Ignore

Crea un cálculo asincrónico que ejecuta el cálculo especificado, pero anula su resultado.

Signature:

computation: Async<'T> -> Async<unit>

Cuándo usarlo:

  • Cuando se tiene un cálculo asincrónico cuyo resultado no es necesario. Esto es análogo a la función ignore para código no asincrónico.

Qué conviene tener en cuenta:

  • Si debe usar Async.Ignore porque quiere usar Async.Start u otra función que requiera Async<unit>, considere si es adecuado descartar el resultado. Evite descartar los resultados solo para ajustarse a una signatura de tipo.

Async.RunSynchronously

Ejecuta un cálculo asincrónico y espera su resultado en el subproceso que realiza la llamada. Si el cálculo produce una excepción, la propaga. Esta llamada produce un bloqueo.

Signature:

computation: Async<'T> * ?timeout: int * ?cancellationToken: CancellationToken -> 'T

Cuándo usarlo

  • Si lo necesita, úselo solo una vez en una aplicación (en el punto de entrada de un archivo ejecutable).
  • Cuando no importa el rendimiento y se quiere ejecutar a la vez un conjunto de otras operaciones asincrónicas.

Qué conviene tener en cuenta:

  • La llamada a Async.RunSynchronously bloquea el subproceso que realiza la llamada hasta que se completa la ejecución.

Async.Start

Inicia el cálculo asincrónico que devuelve unit en el grupo de subprocesos. No espera su finalización ni observa el resultado de una excepción. Los cálculos anidados iniciados con Async.Start se inician independientemente del cálculo primario que los llamó; su duración no está vinculada a ningún cálculo primario. Si se cancela el cálculo primario, no se cancelan los cálculos secundarios.

Signature:

computation: Async<unit> * ?cancellationToken: CancellationToken -> unit

Úselo solo en los casos siguientes:

  • Cuando tiene un cálculo asincrónico que no produce un resultado o que no requiere el procesamiento de uno.
  • Cuando no es necesario saber cuándo se completa un cálculo asincrónico.
  • Cuando no le importa en qué subproceso se ejecuta un cálculo asincrónico.
  • Cuando no es necesario conocer ni notificar las excepciones resultantes de la ejecución.

Qué conviene tener en cuenta:

  • Las excepciones generadas por los cálculos iniciados con Async.Start no se propagan al llamador. La pila de llamadas se desenredará por completo.
  • Los trabajos (como llamar a printfn) que se inicien con Async.Start no producirán efecto en el subproceso principal de la ejecución de un programa.

Interoperación con .NET

Si usa la programación async { }, es posible que tenga que interoperar con una biblioteca de .NET o con código base de C# que use la programación asincrónica de estilo async/await. Dado que C# y la mayoría de las bibliotecas de .NET usan los tipos Task<TResult> y Task como abstracciones principales, esto podría cambiar su forma de escribir código asincrónico de F#.

Una opción consiste en pasar a escribir las tareas de .NET directamente mediante task { }. Como alternativa, puede usar la función Async.AwaitTask para esperar un cálculo asincrónico de .NET:

let getValueFromLibrary param =
    async {
        let! value = DotNetLibrary.GetValueAsync param |> Async.AwaitTask
        return value
    }

Puede usar la función Async.StartAsTask para pasar un cálculo asincrónico a un llamador de .NET:

let computationForCaller param =
    async {
        let! result = getAsyncResult param
        return result
    } |> Async.StartAsTask

Para trabajar con las API que usan Task (es decir, cálculos asincrónicos de .NET que no devuelven un valor), es posible que tenga que agregar una función adicional que convierta un objeto Async<'T> en un objeto Task:

module Async =
    // Async<unit> -> Task
    let startTaskFromAsyncUnit (comp: Async<unit>) =
        Async.StartAsTask comp :> Task

Ya hay un objeto Async.AwaitTask que acepta un objeto Task como entrada. Con esto y con la función startTaskFromAsyncUnit definida anteriormente, puede iniciar y esperar tipos Task desde un cálculo asincrónico de F#.

Escritura de tareas de .NET directamente en F#

En F#, puede escribir tareas directamente mediante task { }, por ejemplo:

open System
open System.IO

/// Perform an asynchronous read of a file using 'task'
let printTotalFileBytesUsingTasks (path: string) =
    task {
        let! bytes = File.ReadAllBytesAsync(path)
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    let task = printTotalFileBytesUsingTasks "path-to-file.txt"
    task.Wait()

    Console.Read() |> ignore
    0

En el ejemplo, la función printTotalFileBytesUsingTasks es de tipo string -> Task<unit>. Cuando se llama a la función, la tarea empieza a ejecutarse. La llamada a task.Wait() espera a que se complete la tarea.

Relación con el multithreading

Aunque en este artículo se mencionan los subprocesos, hay dos cuestiones importantes que conviene recordar:

  1. No hay afinidad entre un cálculo asincrónico y un subproceso, a menos que se inicie explícitamente en el subproceso actual.
  2. La programación asincrónica en F# no es una abstracción para multithreading.

Por ejemplo, un cálculo puede ejecutarse realmente en el subproceso del llamador, en función de la naturaleza del trabajo. Un cálculo también podría "saltar" entre subprocesos, es decir, tomarlos prestados durante un breve tiempo para realizar un trabajo útil entre períodos de "espera" (por ejemplo, cuando una llamada de red está en tránsito).

Aunque F# proporciona algunas capacidades para iniciar un cálculo asincrónico en el subproceso actual (o, de manera explícita, no en el subproceso actual), por lo general la asincronía no está asociada a una estrategia de subproceso determinada.

Consulte también