Programación asincrónica en F#
La programación asincrónica es un mecanismo que es esencial para las aplicaciones modernas por diversos motivos. Hay dos casos de uso principales que la mayoría de los desarrolladores encontrarán:
- Presentar un proceso de servidor que pueda dar servicio a un número significativo de solicitudes entrantes simultáneas, a la vez que se minimizan los recursos del sistema ocupados mientras el procesamiento de solicitudes espera las entradas de sistemas o servicios externos a ese proceso.
- Mantener una interfaz de usuario con capacidad de respuesta o un subproceso principal mientras se progresa simultáneamente el trabajo en segundo plano
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 cuestiones independientes y una no implica la otra. En este artículo se describen los conceptos independientes con más detalle.
Asincronía definida
Merece la pena explicar un poco más el punto anterior, 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í:
- Simultaneidad; cuando se ejecutan varios cálculos en períodos de tiempo superpuestos.
- Paralelismo; cuando varios cálculos o varias partes de un único cálculo se ejecutan exactamente 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 inflar 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 entre sí.
Si tiene en cuenta la etimología de la palabra "asincrónica", hay dos partes implicadas:
- "a", que significa "not".
- "synchronous", que significa "al mismo tiempo".
Al reunir 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 en 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 sincrónicamente, en función de la naturaleza del cálculo y del entorno en el que se ejecuta el cálculo.
La principal idea que debe tener es que los cálculos asincrónicos son independientes del flujo de 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 integrados en F#.
Conceptos principales
En F#, la programación asincrónica se centra en dos conceptos básicos: cálculos asincrónicos y tareas.
- Tipo con expresión de cálculo , que representa un cálculo asincrónico que admite composición que
Async<'T>se puede iniciar para formar unaasync { }tarea. - Tipo,
Task<'T>con expresión de cálculotask { },que representa una tarea de .NET en ejecución.
En general, debe usar la programación en F# a menos que necesite crear o async { } consumir tareas de .NET con frecuencia.
Conceptos básicos de async
Puede ver los conceptos básicos de la 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 Async<unit> un 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 C#/Visual Basic. async En F#, los cálculos asincrónicos se pueden pensar como tareas en frío. Deben iniciarse explícitamente para ejecutarse realmente. 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
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:
- Transforme los argumentos de la línea de comandos en una secuencia de
Async<unit>cálculos conSeq.map. - Cree un
Async<'T[]>que programe y ejecute losprintTotalFileBytescálculos en paralelo cuando se ejecute. - Cree un
Async<unit>objeto que ejecutará el cálculo paralelo y omitirá su resultado (que es ununit[]). - 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.
Cálculos asincrónicos de secuencia
Dado que es una especificación de trabajo en lugar de una tarea que ya se está Async<'T> ejecutando, 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 tras 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á la ejecución en el orden de los elementos de en lugar de printTotalFileBytes argv programarlos en paralelo. Dado que cada operación sucesiva no se programará hasta que haya terminado de ejecutarse el cálculo anterior, los cálculos se secuencian de forma 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 interactuará con un marco que controla la programación de cálculos. Sin embargo, esto no siempre es así, por lo que es bueno comprender las distintas 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 de inicio. 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 dentro de un cálculo asincrónico. Esto permite ejecutar varios cálculos asincrónicos simultáneamente. 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 cálculo secundario.
Signature:
computation: Async<'T> * ?millisecondsTimeout: int -> Async<Async<'T>>
Cuándo usarlo:
- Si desea ejecutar varios cálculos asincrónicos simultáneamente en lugar de uno a la vez, pero no programarlos en paralelo.
- Si desea 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.StartChildno es lo mismo que programarlos en paralelo. Si desea programar cálculos en paralelo, useAsync.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 (como actualizar una barra de progreso), Async.StartImmediate se debe usar.
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é hay que tener en cuenta:
- El código del cálculo asincrónico se ejecutará en cualquier subproceso en el que se programe. Esto puede ser problemático si ese subproceso es de algún modo confidencial, como un subproceso de interfaz de usuario. En tales casos,
Async.StartImmediatees probable que no sea apropiado usarlo.
Async.StartAsTask
Ejecuta un cálculo en el grupo de subprocesos. Devuelve un objeto que se completará en el estado correspondiente una vez que finalice el cálculo (genera el resultado, produce una excepción Task<TResult> 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 produce un objeto Task<TResult> para representar el resultado de un cálculo asincrónico.
Qué hay que tener en cuenta:
- Esta llamada asignará un objeto
Taskadicional, 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, 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 .
Signature:
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.
- Si no necesita resultados de los cálculos programados en paralelo hasta que se hayan completado todos.
Qué hay que tener en cuenta:
- Solo puede acceder a la matriz de valores resultante una vez que todos los cálculos han finalizado.
- Los cálculos se ejecutarán cada vez que terminen programando. 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. Se ejecutará el primer cálculo, luego el siguiente, y así sucesivamente. No se ejecutará ningún cálculo en paralelo.
Signature:
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 acceder a la matriz de valores resultante una vez que todos los cálculos han finalizado.
- 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 objeto Task<TResult> dado y devuelve su resultado como un Async<'T>
Signature:
task: Task<'T> -> Async<'T>
Cuándo usarlo:
- Cuando se consume una API de .NET que devuelve dentro Task<TResult> de un cálculo asincrónico de F#.
Qué hay que tener en cuenta:
- Las excepciones se encapsulan siguiendo la convención de la biblioteca de tareas paralelas; este comportamiento es diferente de la forma en que la asincrónica de F# suele AggregateException superficier las excepciones.
Async.Catch
Crea un cálculo asincrónico que ejecuta un determinado Async<'T> , que devuelve un Async<Choice<'T, exn>> . Si el objeto Async<'T> especificado se completa correctamente, se devuelve un objeto con el valor Choice1Of2 resultante. Si se produce una excepción antes de que se complete, se Choice2of2 devuelve con la excepción producida. 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 que abarca se detendrán por completo.
Signature:
computation: Async<'T> -> Async<Choice<'T, exn>>
Cuándo usarlo:
- Cuando realiza un trabajo asincrónico que puede producir un error con una excepción y desea controlar esa excepción en el autor de la llamada.
Qué hay que tener en cuenta:
- Al usar cálculos asincrónicos combinados o secuenciados, el cálculo que abarca se detendrá completamente si uno de sus cálculos "internos" produce una excepción.
Async.Ignore
Crea un cálculo asincrónico que ejecuta el cálculo dado, pero quita su resultado.
Signature:
computation: Async<'T> -> Async<unit>
Cuándo usarlo:
- Cuando tiene un cálculo asincrónico cuyo resultado no es necesario. Esto es análogo a la función
ignorepara el código no asincrónico.
Qué hay que tener en cuenta:
- Si debe usar porque desea usar u otra función que requiera , considere si descartar
Async.IgnoreAsync.Startel resultado esAsync<unit>correcto. Evite descartar los resultados solo para ajustarse a una firma 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 una excepción. Esta llamada está bloqueando.
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 ejecutable.
- Cuando no le interesa el rendimiento y desea ejecutar un conjunto de otras operaciones asincrónicas a la vez.
Qué hay que tener en cuenta:
- La
Async.RunSynchronouslyllamada a bloquea el subproceso que realiza la llamada hasta que se completa la ejecución.
Async.Start
Inicia un cálculo asincrónico que devuelve unit en el grupo de subprocesos. No espera a su finalización ni observa un resultado de excepción. Los cálculos anidados iniciados con se inician independientemente del cálculo primario que los llamó; su duración no está Async.Start asociada a ningún cálculo primario. Si se cancela el cálculo primario, no se cancela ningún cálculo secundario.
Signature:
computation: Async<unit> * ?cancellationToken: CancellationToken -> unit
Use solo cuando:
- Tiene un cálculo asincrónico que no produce un resultado o requiere el procesamiento de uno.
- No es necesario saber cuándo se completa un cálculo asincrónico.
- No le importa en qué subproceso se ejecuta un cálculo asincrónico.
- No es necesario tener en cuenta ni notificar las excepciones resultantes de la ejecución.
Qué hay que tener en cuenta:
- Las excepciones que se han producido en los cálculos iniciados con
Async.Startno se propagan al autor de la llamada. La pila de llamadas estará completamente desenlazada. - Cualquier trabajo (como llamar a ) iniciado con no hará que el efecto se haga en el subproceso
printfnprincipal de la ejecución de unAsync.Startprograma.
Interoperar con .NET
Si usa la programación, es posible que tenga que interoperar con una biblioteca .NET o código base de C# que usa la programación asincrónica de estilo async { } async/await. Dado que C# y la mayoría de las bibliotecas de .NET usan los tipos y como abstracciones principales, esto puede cambiar la forma de escribir el código asincrónico Task<TResult> Task de F#.
Una opción es cambiar a escribir 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 API que usan (es decir, cálculos asincrónicos de .NET que no devuelven un valor), es posible que tenga que agregar una función adicional que convertirá un en Task Async<'T> Task :
module Async =
// Async<unit> -> Task
let startTaskFromAsyncUnit (comp: Async<unit>) =
Async.StartAsTask comp :> Task
Ya hay un Async.AwaitTask que acepta un como Task entrada. Con esta función y la función definida anteriormente, puede iniciar y esperar tipos desde un cálculo asincrónico startTaskFromAsyncUnit Task 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> . La llamada a la función comienza a ejecutar la tarea.
La llamada a task.Wait() espera a que se complete la tarea.
Relación con multiproceso
Aunque el subprocesamiento se menciona a lo largo de este artículo, hay dos cosas importantes que recordar:
- No hay ninguna afinidad entre un cálculo asincrónico y un subproceso, a menos que se haya iniciado explícitamente en el subproceso actual.
- La programación asincrónica en F# no es una abstracción para multiproceso.
Por ejemplo, un cálculo puede ejecutarse realmente en el subproceso de su autor de la llamada, en función de la naturaleza del trabajo. Un cálculo también podría "saltar" entre subprocesos y tomarlos prestada durante una pequeña cantidad de 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 explícitamente no en el subproceso actual), la asincronía generalmente no está asociada a una estrategia de subprocesos determinada.