Esta traducción surgió gracias al buen comentario 0x1000000 .

.NET Framework 4 introdujo el espacio System.Threading.Tasks, y con él la clase Task. Este tipo y la Tarea <Resultado> generada a partir de él han estado esperando mucho tiempo hasta que los estándares en .NET los reconozcan como los aspectos clave del modelo de programación asincrónico que se introdujo en C # 5 con sus declaraciones asíncronas / en espera. En este artículo, hablaré sobre los nuevos tipos de ValueTask / ValueTask <TResult>, diseñados para mejorar el rendimiento de los métodos asincrónicos en los casos en que se debe tener en cuenta la sobrecarga de la asignación de memoria.
Tarea
La tarea actúa en diferentes roles, pero el principal es la "promesa" (promesa), un objeto que representa la posible finalización de alguna operación. Inicia una operación y obtiene un objeto Tarea para él, que se ejecutará cuando se complete la operación, lo que puede suceder en modo síncrono como parte de la inicialización de la operación (por ejemplo, recibir datos que ya están en el búfer), en modo asíncrono con ejecución en el momento en que obtienes Task (que recibe datos no del búfer, pero muy rápidamente), o en modo asíncrono, pero después de que ya tienes Task (que recibe datos de un recurso remoto). Dado que la operación puede finalizar de forma asincrónica, puede bloquear el flujo de ejecución, esperar el resultado (que a menudo hace que la asincronía de la llamada no tenga sentido) o crear una función de devolución de llamada que se activará después de que se complete la operación. En .Net 4, la creación de una devolución de llamada se implementa mediante los métodos ContinueWith del objeto Task, que demuestran explícitamente este modelo al aceptar una función delegada para ejecutarlo después de ejecutar la tarea:
SomeOperationAsync().ContinueWith(task => { try { TResult result = task.Result; UseResult(result); } catch (Exception e) { HandleException(e); } });
Pero en .NET Framework 4.5 y C # 5, el operador de espera simplemente puede llamar a los objetos de tarea, lo que facilita obtener el resultado de una operación asincrónica, y el código generado que está optimizado para las opciones anteriores funcionará correctamente en todos los casos cuando la operación se complete en modo síncrono, asíncrono rápido o asíncrono al hacer callbacka:
TResult result = await SomeOperationAsync(); UseResult(result);
La tarea es una clase muy flexible y tiene varias ventajas. Por ejemplo, puede realizar la espera varias veces para cualquier número de consumidores a la vez. Puede ponerlo en una colección (diccionario) para la espera repetida en el futuro, para usarlo como un caché de los resultados de las llamadas asincrónicas. Puede bloquear la ejecución mientras espera que se complete la tarea si es necesario. Y puede escribir y aplicar varias operaciones en objetos de Tarea (a veces llamados "combinadores"), por ejemplo, "cuando exista" para esperar asincrónicamente la primera finalización de varias Tareas.
Pero esta flexibilidad se vuelve superflua en el caso más común: simplemente llame a la operación asincrónica y espere a que se complete la tarea:
TResult result = await SomeOperationAsync(); UseResult(result);
Aquí no necesitamos esperar la ejecución varias veces. No necesitamos asegurarnos de que las expectativas sean competitivas. No necesitamos realizar un bloqueo sincrónico. No escribiremos combinadores. Estamos a la espera de que se complete la promesa de una operación asincrónica. Al final, esta es la forma en que escribimos código síncrono (por ejemplo, TResult result = SomeOperation ();), y esto normalmente se traduce en async / await.
Además, la tarea tiene una debilidad potencial, especialmente cuando se crea una gran cantidad de instancias, y un alto rendimiento y rendimiento son requisitos clave: la tarea es una clase. Esto significa que cualquier operación que necesita una tarea se ve obligada a crear y colocar un objeto, y cuantos más objetos se crean, más trabajo se requiere para el recolector de basura (GC), y este trabajo consume recursos que podríamos gastar en algo más útil
Las bibliotecas de tiempo de ejecución y del sistema ayudan a mitigar este problema en muchas situaciones. Por ejemplo, si escribimos un método como este:
public async Task WriteAsync(byte value) { if (_bufferedCount == _buffer.Length) { await FlushAsync(); } _buffer[_bufferedCount++] = value; }
como regla general, habrá suficiente espacio libre en el búfer y la operación se ejecutará sincrónicamente. Cuando esto sucede, no hay necesidad de hacer nada con la Tarea, que debe devolverse, ya que no hay un valor de retorno, esto está usando la Tarea como el equivalente de un método sincrónico que devuelve un valor vacío (nulo). Por lo tanto, el entorno puede simplemente almacenar en caché una Tarea no genérica y usarla una y otra vez como resultado de la ejecución de cualquier método asíncrono que finalice de forma sincrónica (este singleton en caché se puede obtener a través de Task.CompletedTask). O, por ejemplo, escribes:
public async Task<bool> MoveNextAsync() { if (_bufferedCount == 0) { await FillBuffer(); } return _bufferedCount > 0; }
y en general, espere que los datos ya estén en el búfer, por lo que el método simplemente verifica el valor de _bufferedCount, ve que es mayor que 0 y devuelve verdadero; y solo si todavía no hay datos en el búfer, debe realizar una operación asincrónica. Y dado que solo hay dos resultados posibles de tipo booleano (verdadero y falso), solo se necesitan dos objetos de tarea posibles para representar estos resultados, el entorno puede almacenar en caché estos objetos y devolverlos con el valor correspondiente sin asignar memoria. Solo en caso de finalización asincrónica, el método deberá crear una nueva Tarea, ya que deberá devolverse antes de que se conozca el resultado de la operación.
El entorno proporciona almacenamiento en caché para algunos otros tipos, pero no es realista almacenar en caché todos los tipos posibles. Por ejemplo, el siguiente método:
public async Task<int> ReadNextByteAsync() { if (_bufferedCount == 0) { await FillBuffer(); } if (_bufferedCount == 0) { return -1; } _bufferedCount--; return _buffer[_position++]; }
a menudo también se ejecutará sincrónicamente. Pero a diferencia de una variante con un resultado de tipo booleano, este método devuelve Int32, que tiene aproximadamente 4 mil millones de valores, y el almacenamiento en caché de todas las variantes de la tarea <int> requerirá cientos de gigabytes de memoria. El entorno proporciona una pequeña memoria caché para la tarea <int>, pero un conjunto muy limitado de valores, por ejemplo, si este método finaliza de forma sincrónica (los datos ya están en el búfer) con el valor de retorno de 4, será una tarea almacenada en caché, pero si se devuelve el valor 42, deberá crear uno nuevo Tarea <int>, similar a llamar a Task.FromResult (42).
Muchos métodos de biblioteca intentan suavizar esto proporcionando su propia caché. Por ejemplo, una sobrecarga en .NET Framework 4.5 del método MemoryStream.ReadAsync siempre finaliza sincrónicamente, ya que lee datos de la memoria. ReadAsync devuelve una tarea <int>, donde un resultado Int32 indica cuántos bytes se han leído. Este método a menudo se usa en un bucle, a menudo con el mismo número requerido de bytes para cada llamada, y a menudo esta necesidad se satisface por completo. Entonces, para llamadas repetidas a ReadAsync, es razonable esperar que la Tarea <int> regrese sincrónicamente con el mismo valor que en la llamada anterior. Por lo tanto, un MemoryStream crea un caché para un objeto que regresó en la última llamada exitosa. Y en la próxima llamada, si el resultado se repite, devolverá el objeto almacenado en caché, y si no, creará uno nuevo con Task.FromResult, guárdelo en la caché y devuélvalo.
Sin embargo, hay muchos otros casos en los que la operación se realiza de forma sincrónica, pero el objeto Tarea <resultado> se ve obligado a crearse.
ValueTask <TResult> y ejecución síncrona
Todo esto requería la implementación de un nuevo tipo en .NET Core 2.0, que estaba disponible en versiones anteriores de .NET en el paquete NuGet System.Threading.Tasks.Extensions: ValueTask <TResult>.
ValueTask <TResult> se creó en .NET Core 2.0 como una estructura capaz de ajustar TResult y Task <TResult>. Esto significa que se puede devolver desde el método asíncrono, y si este método se ejecuta sincrónicamente y con éxito, no necesita colocar ningún objeto en el montón: simplemente puede inicializar esta estructura ValueTask <TResult> con el valor TResult y devolverlo. Solo en el caso de ejecución asincrónica, se colocará el objeto Task <TResult> y ValueTask <TResult> lo envolverá (para minimizar el tamaño de la estructura y optimizar el caso de ejecución exitosa, el método asíncrono, que termina con una excepción no admitida, también colocará la tarea <TResult>, por lo que ValueTask <TResult> también simplemente envuelve la tarea <TResult>, y no llevará consigo un campo adicional para almacenar Excepción).
En base a esto, un método como MemoryStream.ReadAsync, pero que devuelve una ValueTask <int>, no debería ocuparse del almacenamiento en caché, sino que puede escribirse así:
public override ValueTask<int> ReadAsync(byte[] buffer, int offset, int count) { try { int bytesRead = Read(buffer, offset, count); return new ValueTask<int>(bytesRead); } catch (Exception e) { return new ValueTask<int>(Task.FromException<int>(e)); } }
ValueTask <TResult> y ejecución asincrónica
La capacidad de escribir un método asíncrono que puede completarse sincrónicamente sin la necesidad de una ubicación adicional para el resultado es una gran victoria. Es por eso que ValueTask <TResult> se agregó en .NET Core 2.0, y los nuevos métodos que probablemente se usarán en aplicaciones que requieren rendimiento ahora se anuncian con el retorno de ValueTask <TResult> en lugar de la Tarea <TResult>. Por ejemplo, cuando agregamos una nueva sobrecarga de ReadAsync de la clase Stream a .NET Core 2.1, para poder pasar la Memoria en lugar del byte [], devolvemos el tipo ValueTask <int>. De esta forma, los objetos Stream (en los que el método ReadAsync a menudo se ejecuta sincrónicamente, como en el ejemplo anterior para MemoryStream) se pueden usar con mucha menos asignación de memoria.
Sin embargo, cuando trabajamos con servicios con un ancho de banda muy alto, aún queremos evitar la asignación de memoria tanto como sea posible, lo que significa reducir y eliminar la asignación de memoria a lo largo de la ruta de ejecución asincrónica también.
En el modelo de espera, para cualquier operación que se complete de forma asíncrona, necesitamos la capacidad de devolver un objeto que represente la posible finalización de la operación: la persona que llama debe redirigir la devolución de llamada que se iniciará al final de la operación, y esto requiere un objeto único en el montón, que puede servir como un canal de transmisión para Esta operación particular. Esto, al mismo tiempo, no significa nada si este objeto se reutilizará después de que se complete la operación. Si este objeto se puede reutilizar, la API puede organizar un caché para uno o más de estos objetos, y usarlo para operaciones secuenciales, en el sentido de no usar el mismo objeto para varias operaciones asíncronas intermedias, sino usarlo para acceso no competitivo.
En .NET Core 2.1, la clase ValueTask <TResult> se ha mejorado para admitir una agrupación y reutilización similares. En lugar de simplemente ajustar TResult o Task <TResult>, una clase revisada puede ajustar una nueva interfaz IValueTaskSource <TResult>. Esta interfaz proporciona la funcionalidad básica que se requiere para acompañar una operación asincrónica con un objeto ValueTask <TResult> de la misma manera que lo hace la tarea <TResult>:
public interface IValueTaskSource<out TResult> { ValueTaskSourceStatus GetStatus(short token); void OnCompleted(Action<object> continuation, object state, short token, ValueTaskSourceOnCompletedFlags flags); TResult GetResult(short token); }
El método GetStatus se usa para implementar propiedades como ValueTask <TResult> .IsCompleted, que devuelve información sobre si una operación asincrónica se realiza o se completa, y cómo se completa (exitosa o no). El objeto de espera utiliza el método OnCompleted para adjuntar una devolución de llamada para continuar la ejecución desde el punto de espera cuando finaliza la operación. Y el método GetResult es necesario para obtener el resultado de la operación, por lo que una vez que se completa la operación, la persona que llama puede obtener el objeto TResult o pasar cualquier excepción que se haya producido.
La mayoría de los desarrolladores no necesitan esta interfaz: los métodos simplemente devuelven un objeto ValueTask <TResult>, que se puede crear como un contenedor para un objeto que implementa esta interfaz, y el método de llamada permanecerá en la oscuridad. Esta interfaz es para desarrolladores que necesitan evitar la asignación de memoria cuando usan una API de rendimiento crítico.
Hay varios ejemplos de dicha API en .NET Core 2.1. Los métodos más famosos son Socket.ReceiveAsync y Socket.SendAsync con nuevas sobrecargas agregadas en 2.1, por ejemplo
public ValueTask<int> ReceiveAsync(Memory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken = default);
Esta sobrecarga devuelve una ValueTask <int>. Si la operación se completa sincrónicamente, simplemente puede devolver una ValueTask <int> con el valor correspondiente:
int result = …; return new ValueTask<int>(result);
Cuando finaliza de forma asíncrona, puede usar un objeto del grupo que implementa la interfaz:
IValueTaskSource<int> vts = …; return new ValueTask<int>(vts);
La implementación de Socket admite uno de esos objetos en el grupo para la recepción y uno para la transmisión, ya que no puede haber más de un objeto para cada dirección esperando ser ejecutado a la vez. Estas sobrecargas no asignan memoria, incluso en el caso de una operación asincrónica. Este comportamiento es más evidente en la clase NetworkStream.
Por ejemplo, en .NET Core 2.1 Stream proporciona:
public virtual ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken);
que se redefine en NetworkStream. El método NetworkStream.ReadAsync simplemente usa el método Socket.ReceiveAsync, de modo que las ganancias en Socket se transmiten a NetworkStream, y NetworkStream.ReadAsync tampoco asigna memoria.
ValueTask no compartido
Cuando ValueTask <TResult> apareció en .NET Core 2.0, solo el caso de ejecución síncrona se optimizó para excluir la ubicación del objeto Task <TResult> si el valor TResult ya está listo. Esto significaba que la clase ValueTask no genérica no era necesaria: para el caso de ejecución síncrona, la tarea Singleton.CompletedTask simplemente podía devolverse del método, y esto lo hacía el entorno implícitamente en los métodos asíncronos que devolvían la tarea.
Sin embargo, al obtener operaciones asincrónicas sin asignar memoria, el uso de ValueTask no compartido ha vuelto a ser relevante. En .NET Core 2.1, presentamos el ValueTask genérico y IValueTaskSource. Proporcionan equivalentes directos para versiones genéricas, para uso similar, con solo un valor de retorno vacío.
Implemente IValueTaskSource / IValueTaskSource <T>
La mayoría de los desarrolladores no deberían implementar estas interfaces. Además, no es tan fácil. Si decide hacer esto, varias implementaciones en .NET Core 2.1 pueden servir como punto de partida, por ejemplo:
- AwaitableSocketAsyncEventArgs
- AsyncOperation <TResult>
- DefaultPipeReader
Para facilitar esto, en .NET Core 3.0 planeamos presentar toda la lógica necesaria incluida en el tipo ManualResetValueTaskSourceCore <TResult>, una estructura que se puede incrustar en otro objeto que implementa IValueTaskSource <TResult> y / o IValueTaskSource, para que pueda delegarse en Esta estructura es la mayor parte de la funcionalidad. Puede obtener más información sobre esto en https://github.com/dotnet/corefx/issues/32664 en el repositorio dotnet / corefx.
Patrones de aplicación de ValueTasks
A primera vista, el alcance de ValueTask y ValueTask <TResult> es mucho más limitado que Task y Task <TResult>. Esto es bueno e incluso esperado, ya que la forma principal de usarlos es simplemente usando el operador de espera.
Sin embargo, dado que pueden envolver objetos que se reutilizan, existen restricciones significativas en su uso en comparación con la Tarea y la Tarea <Resultado>, si se desvía de la forma habitual de espera simple. En casos generales, las siguientes operaciones nunca deben realizarse con ValueTask / ValueTask <TResult>:
- Espera repetida ValueTask / ValueTask <TResult> El objeto resultante ya puede eliminarse y utilizarse en otra operación. Por el contrario, Tarea / Tarea <Resultado> nunca pasa de un estado completado a uno incompleto, por lo que puede volver a esperarlo tantas veces como sea necesario y obtener el mismo resultado cada vez.
- Espera paralela ValueTask / ValueTask <TResult> El objeto de resultado espera el procesamiento con solo una devolución de llamada de un consumidor a la vez, e intentar esperar de diferentes flujos al mismo tiempo puede conducir fácilmente a carreras y errores sutiles del programa. Además, también es un caso más específico de la operación inválida anterior de "re-espera". En comparación, Task / Task <TResult> proporciona cualquier cantidad de espera paralela.
- Usando .GetAwaiter (). GetResult () cuando la operación aún no se ha completado. La implementación de IValueTaskSource / IValueTaskSource <TResult> no necesita soporte de bloqueo hasta que se complete la operación, y lo más probable es que no lo haga, por lo que dicha operación definitivamente conducirá a carreras y probablemente no se ejecutará como lo espera el método de llamada. Task / Task <TResult> bloquea el hilo de llamada hasta que se complete la tarea.
Si recibió un ValueTask o ValueTask <TResult>, pero necesita realizar una de estas tres operaciones, puede usar .AsTask (), obtener Task / Task <TResult> y luego trabajar con el objeto recibido. Después de eso, ya no puede usar esa ValueTask / ValueTask <TResult>.
En resumen, la regla es la siguiente: cuando use ValueTask / ValueTask <TResult> debe esperarlo directamente (posiblemente con .ConfigureAwait (false)) o llamar a AsTask () y no usarlo más:
// , ValueTask<int> public ValueTask<int\> SomeValueTaskReturningMethodAsync(); ... // GOOD int result = await SomeValueTaskReturningMethodAsync(); // GOOD int result = await SomeValueTaskReturningMethodAsync().ConfigureAwait(false); // GOOD Task<int> t = SomeValueTaskReturningMethodAsync().AsTask(); // WARNING ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); // , // // BAD: await ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); int result = await vt; int result2 = await vt; // BAD: await ( ) ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); Task.Run(async () => await vt); Task.Run(async () => await vt); // BAD: GetAwaiter().GetResult(), ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); int result = vt.GetAwaiter().GetResult();
Hay un patrón más avanzado que los programadores pueden aplicar, espero, solo después de una medición cuidadosa y de obtener ventajas significativas. Las clases ValueTask / ValueTask <TResult> tienen varias propiedades que informan el estado actual de la operación, por ejemplo, la propiedad IsCompleted devuelve verdadero si la operación se completó (es decir, ya no se ejecuta y completa con éxito o no con éxito), y la propiedad IsCompletedSuccessfully devuelve verdadero, solo si se completó con éxito (mientras esperaba y recibía el resultado, no arrojó una excepción). Para los subprocesos de ejecución más exigentes, donde el desarrollador desea evitar los costos que surgen en modo asíncrono, estas propiedades se pueden verificar antes de una operación que realmente destruya el objeto ValueTask / ValueTask <TResult>, por ejemplo aguarde, .AsTask (). Por ejemplo, en la implementación de SocketsHttpHandler en .NET Core 2.1, el código se lee desde la conexión y recibe un ValueTask <int>. Si esta operación se realiza sincrónicamente, no tenemos que preocuparnos por la terminación anticipada de la operación. Pero si se ejecuta de forma asíncrona, debemos conectar el proceso de interrupción para que la solicitud de interrupción rompa la conexión. Dado que este es un código muy estresante, si la elaboración de perfiles muestra la necesidad del siguiente pequeño cambio, puede estructurarse así:
int bytesRead; { ValueTask<int> readTask = _connection.ReadAsync(buffer); if (readTask.IsCompletedSuccessfully) { bytesRead = readTask.Result; } else { using (_connection.RegisterCancellation()) { bytesRead = await readTask; } } }
¿Debería cada nuevo método API asíncrono devolver un ValueTask / ValueTask <TResult>?
Para responder brevemente: no, de forma predeterminada todavía vale la pena elegir Tarea / Tarea <Resultado>.
Como se destacó anteriormente, Tarea y Tarea <Tresult> son más fáciles de usar correctamente que ValueTask y ValueTask <TResult>, y siempre que los requisitos de rendimiento no superen los requisitos prácticos, se prefieren Tarea y Tarea <TResult>. Además, hay pequeños costos asociados con la devolución de ValueTask <TResult> en lugar de una Task <TResult>, es decir, los micro puntos de referencia muestran que esperar Task <TResult> es más rápido que aguardar ValueTask <TResult>. Por lo tanto, si utiliza el almacenamiento en caché de tareas, por ejemplo, su método devuelve Tarea o Tarea, por rendimiento, vale la pena quedarse con Tarea o Tarea. Los objetos ValueTask / ValueTask <TResult> ocupan varias palabras en la memoria, por lo tanto, cuando se esperan y sus campos están reservados en la máquina de estado que llama al método asíncrono, ocuparán más memoria en él.
- ValueTask/ValueTask<TResult> : ) , await, ) , ) , . , / .
ValueTask ValueTask<TResult>?
.NET , Task/Task<TResult>, , ValueTask/ValueTask<TResult>, , . – IAsyncEnumerator<T>, .NET Core 3.0. IEnumerator<T> MoveNext, bool, IAsyncEnumerator<T> MoveNextAsync. , , Task, . , , , ( ), await foreach, ValueTask. , . C# , , , .