Compartir a través de


Programación asincrónica en F#

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

  • Presentar un proceso de servidor que puede atender un número significativo de solicitudes entrantes simultáneas, al tiempo que minimiza los recursos del sistema ocupados mientras el procesamiento de solicitudes espera entradas de sistemas o servicios externos a ese proceso.
  • Mantener una interfaz de usuario o un hilo principal sensibles mientras el trabajo en segundo plano progresa simultáneamente

Aunque el trabajo en segundo plano suele implicar el uso de varios subprocesos, es importante tener en cuenta los conceptos de asincronía y multiproceso por separado. De hecho, son preocupaciones independientes, y una no implica la otra. En este artículo se describen los conceptos independientes con más detalle.

Asincronía definida

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 estrictamente independientes entre sí:

  • Concurrencia; 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 al mismo tiempo.
  • Asincronía; cuando uno o varios cálculos se pueden ejecutar por separado del flujo de programa principal.

Los tres son conceptos ortogonales, pero se pueden combinar fácilmente, especialmente cuando se usan juntos. Por ejemplo, puede 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 mutuamente.

Si considera la etimología de la palabra "asincrónico", hay dos partes implicadas:

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

Al combinar estos dos términos, verá que "asincrónico" 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 de programa principal. Esta ejecución independiente no implica simultaneidad ni paralelismo, ni implica que un cálculo siempre se produce en segundo plano. De hecho, los cálculos asincrónicos incluso se pueden ejecutar de forma sincrónica, en función de la naturaleza del cálculo y del entorno en el que se ejecuta el cálculo.

La conclusión principal que debe tener 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 algunos enfoques para orquestarlos y programarlos. En el resto de este artículo se exploran los conceptos básicos de la asincronía de F# y cómo usar los tipos, funciones y expresiones integradas en F#.

Conceptos básicos

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

  • 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 fundamentales de la asincronía

Puede ver los conceptos básicos de programación "asincrónica" 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 printTotalFileBytesUsingAsync función es de tipo string -> Async<unit>. Llamar a la función no ejecuta realmente el cálculo asincrónico. En su lugar, devuelve un 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 asincrónico a las que deberá llamar si desea ejecutar realmente un cálculo asincrónico de F#.

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

Combinar cálculos asincrónicos

Este es 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 main función 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. Cree un Async<unit> que ejecute el cálculo paralelo e ignore su resultado (que es un unit[]).
  4. Ejecute explícitamente el cálculo compuesto general con Async.RunSynchronously, bloqueando hasta que se complete.

Cuando se ejecuta este programa, printTotalFileBytes se ejecuta en paralelo para cada argumento de línea de comandos. Dado que los cálculos asincrónicos se ejecutan independientemente del flujo de programa, no hay ningún orden definido en el que impriman su información y terminen 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

Dado que Async<'T> es una especificación de trabajo en lugar de una tarea ya en ejecución, puede realizar transformaciones más complejas fácilmente. Este es un ejemplo que 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 haya terminado de ejecutarse, los cálculos se secuencian de modo que no haya ninguna superposición en su ejecución.

Funciones importantes del módulo asincrónico

Al escribir código asincrónico en F#, normalmente interactúas con una biblioteca que gestiona la planificación de las operaciones por ti. Sin embargo, esto no siempre es así, por lo que es bueno comprender las diversas funciones que se pueden usar para programar el trabajo asincrónico.

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

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.

Firma:

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

Cuándo usar:

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

Qué hay que tener en cuenta:

  • Iniciar varios cálculos con Async.StartChild no es lo mismo que programarlos en paralelo. Si desea 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, comenzando inmediatamente en el subproceso del sistema operativo actual. Esto resulta útil si necesita actualizar algo en el hilo llamador 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), Async.StartImmediate debe usarse.

Firma:

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

Cuándo usar:

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

Qué hay que 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, Async.StartImmediate es probable que sea inapropiado usar.

Async.StartAsTask

Ejecuta un cálculo en el grupo de subprocesos. Devuelve un objeto Task<TResult> que se completará en el estado correspondiente una vez que finalice el cálculo (ya sea que produzca el resultado, genere una excepción o se cancele). Si no se proporciona ningún token de cancelación, se usa el token de cancelación predeterminado.

Firma:

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

Cuándo usar:

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

Qué hay que tener en cuenta:

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

Async.Parallel

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

Firma:

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

Cuándo usarlo:

  • Si necesita ejecutar un conjunto de cálculos al mismo tiempo y no 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é hay que tener en cuenta:

  • Solo puede tener acceso a la matriz resultante de valores una vez finalizados todos los cálculos.
  • Los cálculos se ejecutarán siempre que estén programados. 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 que se pasan. El primer cálculo se ejecutará, después el siguiente, etc. No se ejecutarán cálculos en paralelo.

Firma:

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

Cuándo usarlo:

  • Si necesita ejecutar varios cálculos en orden.

Qué hay que tener en cuenta:

  • Solo puede tener acceso a la matriz resultante de valores una vez finalizados todos los cálculos.
  • Los cálculos se ejecutarán en el orden en que se pasan a esta función, lo que puede significar que transcurrirá más tiempo antes de que se devuelvan los resultados.

Async.AwaitTask

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

Firma:

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

Cuándo usar:

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

Qué hay que tener en cuenta:

  • Las excepciones se encapsulan en AggregateException siguiendo la convención de la Biblioteca Paralela de Tareas; este comportamiento es diferente de cómo el lenguaje F# generalmente maneja las excepciones.

Async.Catch

Crea un cálculo asincrónico que ejecuta un determinado Async<'T> y devuelve un Async<Choice<'T, exn>>. Si el especificado Async<'T> se completa correctamente, se devuelve un Choice1Of2 con el valor resultante. Si se produce una excepción antes de que finalice, se devuelve un Choice2of2 con la excepción generada. Si se usa en un cálculo asincrónico que se compone de muchos cálculos, y uno de esos cálculos produce una excepción, el cálculo abarcador se detendrá por completo.

Firma:

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

Cuándo usar:

  • 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é hay que tener en cuenta:

  • Al usar cálculos asincrónicos combinados o secuenciados, el cálculo abarcador se detendrá completamente si uno de sus cálculos "internos" produce una excepción.

Async.Ignore

Crea un cálculo asincrónico que lleva a cabo el cálculo especificado, pero descarta su resultado.

Firma:

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

Cuándo usar:

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

Qué hay que tener en cuenta:

  • Si debe usar Async.Ignore porque desea usar Async.Start u otra función que requiera Async<unit>, considere si descartar el resultado es correcto. 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. Propaga una excepción si el cálculo produce uno. Esta llamada produce un bloqueo.

Firma:

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 le importa el rendimiento y desea ejecutar un conjunto de otras operaciones asincrónicas a la vez.

Qué hay que tener en cuenta:

  • La llamada Async.RunSynchronously bloquea el hilo de llamada hasta que se complete 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.

Firma:

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

Use solo cuando:

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

Qué hay que tener en cuenta:

  • Las excepciones generadas por los cálculos iniciados con Async.Start no se propagan al autor de la llamada. La pila de llamadas se desenredará por completo.
  • Cualquier trabajo (como llamar a printfn) iniciado con Async.Start no provocará que el efecto se produzca en el subproceso principal de la ejecución de un programa.

Interoperar 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 Task<TResult> tipos y Task como abstracciones principales, esto puede cambiar la forma de escribir el código asincrónico de F#.

Una opción es cambiar a escribir tareas de .NET directamente mediante task { }. Como alternativa, puede usar la Async.AwaitTask función 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 Async.StartAsTask función 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 una Async<'T> en :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#.

Escribir 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 printTotalFileBytesUsingTasks función es de tipo string -> Task<unit>. Llamar a la función inicia la ejecución de la tarea. La llamada a task.Wait() espera a que se complete la tarea.

Relación con el multithreading

Aunque se menciona el uso de hilos a lo largo de este artículo, hay dos cosas importantes que 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 no explícitamente en el subproceso actual), la asincronía generalmente no está asociada a una estrategia de subproceso determinada.

Consulte también