Prefacio a la traducción
A diferencia de los artículos científicos, los artículos de este tipo son difíciles de traducir "cerca del texto", y se debe hacer una adaptación bastante fuerte. Por esta razón, pido disculpas por algunas libertades, por mi parte, al tratar con el texto del artículo original. Me guío por un solo objetivo: hacer que la traducción sea comprensible, incluso si en algunos lugares se desvía fuertemente del artículo original. Agradecería las críticas constructivas y las correcciones / adiciones a la traducción.
Introduccion
El System.Threading.Tasks
nombres System.Threading.Tasks
y la clase Task
se introdujeron por primera vez en .NET Framework 4. Desde entonces, este tipo y su clase derivada Task<TResult>
, han entrado firmemente en la práctica de la programación en .NET y se han convertido en aspectos clave del modelo asincrónico. implementado en C # 5, con su async/await
. En este artículo, hablaré sobre los nuevos tipos de ValueTask/ValueTask<TResult>
que se introdujeron para mejorar el rendimiento del código asíncrono, en los casos en que la sobrecarga de memoria juega un papel clave.

Tarea
Task
tiene varios propósitos, pero el principal es "promesa": un objeto que representa la capacidad de esperar la finalización de una operación. Inicia la operación y obtiene Task
. Esta Task
se completará cuando se complete la operación en sí. En este caso, hay tres opciones:
- La operación se completa sincrónicamente en el subproceso iniciador. Por ejemplo, al acceder a algunos datos que ya están en el búfer .
- La operación se realiza de forma asincrónica, pero logra completarse cuando el iniciador recibe la
Task
. Por ejemplo, cuando se realiza un acceso rápido a datos que aún no se han almacenado - La operación se realiza de forma asíncrona y finaliza después de que el iniciador recibe la
Task
Un ejemplo es la recepción de datos a través de una red .
Para obtener el resultado de una llamada asincrónica, el cliente puede bloquear el hilo de llamada mientras espera la finalización, lo que a menudo contradice la idea de asincronía, o proporcionar un método de devolución de llamada que se ejecutará al finalizar la operación asincrónica. El modelo de devolución de llamada en .NET 4 se presentó explícitamente, utilizando el método ContinueWith
de un objeto de la clase Task
, que recibió un delegado al que se llamó al finalizar la operación asincrónica.
SomeOperationAsync().ContinueWith(task => { try { TResult result = task.Result; UseResult(result); } catch (Exception e) { HandleException(e); } });
Con .NET Frmaework 4.5 y C # 5, se simplificó la obtención del resultado de una operación asincrónica al introducir las palabras clave async/await
y el mecanismo detrás de ellas. Este mecanismo, el código generado, puede optimizar todos los casos mencionados anteriormente, manejando correctamente la finalización a pesar de la ruta en la que se alcanzó.
TResult result = await SomeOperationAsync(); UseResult(result);
La clase Task
es bastante flexible y tiene varias ventajas. Por ejemplo, puede "esperar" un objeto de esta clase varias veces, puede esperar el resultado de forma competitiva, por cualquier número de consumidores. Las instancias de una clase se pueden almacenar en un diccionario para cualquier número de llamadas posteriores, con el objetivo de "esperar" en el futuro. Los escenarios descritos le permiten considerar los objetos de Task
como una especie de caché de resultados obtenidos de forma asincrónica. Además, Task
proporciona la capacidad de bloquear el hilo de espera hasta que se complete la operación si el script lo requiere. También está el llamado. combinadores para diversas estrategias para esperar la finalización de conjuntos de tareas, por ejemplo, "Tarea.CuandoAny" - esperando asincrónicamente la finalización de la primera de muchas tareas.
Pero, sin embargo, el caso de uso más común es simplemente comenzar una operación asincrónica y luego esperar el resultado de su ejecución. Un caso tan simple, bastante común, no requiere la flexibilidad anterior:
TResult result = await SomeOperationAsync(); UseResult(result);
Esto es muy similar a cómo escribimos código síncrono (por ejemplo, TResult result = SomeOperation();
). Esta opción se traduce naturalmente en async/await
.
Además, a pesar de todos sus méritos, el tipo de Task
tiene un defecto potencial. Task
es una clase, lo que significa que cada operación que crea una instancia de una tarea asigna un objeto en el montón. Cuantos más objetos creamos, más trabajo se requiere del GC, y más recursos se gastan en el trabajo del recolector de basura, recursos que podrían usarse para otros fines. Esto se convierte en un problema claro para el código, en el que, por un lado, las instancias de Task
se crean a menudo, y por otro lado, lo que ha aumentado los requisitos de rendimiento y rendimiento.
El tiempo de ejecución y las bibliotecas principales, en muchas situaciones, logran mitigar este efecto. Por ejemplo, si escribe un método como el siguiente:
public async Task WriteAsync(byte value) { if (_bufferedCount == _buffer.Length) { await FlushAsync(); } _buffer[_bufferedCount++] = value; }
y, la mayoría de las veces, habrá suficiente espacio en el búfer, la operación finalizará sincrónicamente. Si es así, la tarea devuelta no tiene nada de especial, no hay valor de retorno y la operación ya se ha completado. En otras palabras, estamos tratando con Task
, el equivalente de una operación de void
síncrono. En tales situaciones, el tiempo de ejecución simplemente almacena en caché el objeto Task
y lo usa cada vez como resultado de cualquier async Task
, un método que finaliza sincrónicamente ( Task.ComletedTask
). Otro ejemplo, digamos que escribes:
public async Task<bool> MoveNextAsync() { if (_bufferedCount == 0) { await FillBuffer(); } return _bufferedCount > 0; }
Suponga, de la misma manera, que en la mayoría de los casos, hay algunos datos en el búfer. El método verifica _bufferedCount
, ve que la variable es mayor que cero y devuelve true
. Solo si en el momento de la verificación no se almacenaron los datos, se requiere una operación asincrónica. Sea como fuere, solo hay dos posibles resultados lógicos ( true
y false
), y solo dos posibles estados de retorno a través de la Task<bool>
. Basado en la finalización sincrónica o asincrónica, pero antes de salir del método, el tiempo de ejecución almacena en caché dos instancias de la Task<bool>
(una para true
y otra para false
), y devuelve la deseada, evitando asignaciones adicionales. La única opción cuando tiene que crear un nuevo objeto Task<bool>
es un caso de ejecución asincrónica, que finaliza después del "retorno". En este caso, el método tiene que crear un nuevo objeto Task<bool>
, porque en el momento de la salida del método, el resultado de la operación aún no se conoce. El objeto devuelto debe ser único, porque finalmente almacenará el resultado de la operación asincrónica.
Hay otros ejemplos de almacenamiento en caché similar del tiempo de ejecución. Pero tal estrategia no es aplicable en todas partes. Por ejemplo, el método:
public async Task<int> ReadNextByteAsync() { if (_bufferedCount == 0) { await FillBuffer(); } if (_bufferedCount == 0) { return -1; } _bufferedCount--; return _buffer[_position++]; }
También a menudo termina sincrónicamente. Pero, a diferencia del ejemplo anterior, este método devuelve un resultado entero que tiene aproximadamente cuatro mil millones de valores posibles. Para almacenar en caché la Task<int>
, en esta situación, se necesitarían cientos de gigabytes de memoria. El entorno aquí también admite un caché pequeño para la Task<int>
, para varios valores pequeños. Entonces, por ejemplo, si la operación se completa sincrónicamente (los datos están presentes en el búfer), con un resultado de 4, se usará el caché. Pero si el resultado, aunque sincrónico, la finalización es 42, se creará un nuevo objeto Task<int>
, similar a llamar a Task.FromResult(42)
.
Muchas implementaciones de bibliotecas intentan mitigar estas situaciones mediante el soporte de sus propios cachés. Un ejemplo es la sobrecarga de MemoryStream.ReadAsync
. Esta operación, introducida en .NET Framework 4.5, siempre finaliza sincrónicamente porque Es solo una lectura de memoria. ReadAsync
devuelve una Task<int>
donde el resultado entero representa el número de bytes leídos. Muy a menudo, en el código, ocurre una situación cuando ReadAsync
usa en un bucle. Además, si hay los siguientes síntomas:
- El número de bytes solicitados no cambia para la mayoría de las iteraciones del bucle;
- En la mayoría de las iteraciones,
ReadAsync
puede leer el número de bytes solicitado.
Es decir, para llamadas repetidas, ReadAsync
ejecuta sincrónicamente y devuelve un objeto Task<int>
, con el mismo resultado de iteración a iteración. Es lógico que MemoryStream
caché la última tarea completada con éxito, y para todas las llamadas posteriores, si el nuevo resultado coincide con el anterior, devuelve una instancia del caché. Si el resultado no coincide, Task.FromResult
usa para crear una nueva instancia, que, a su vez, también se almacena en caché antes de regresar.
Pero, sin embargo, hay muchos casos en que una operación se ve obligada a crear nuevos objetos de Task<TResult>
, incluso cuando se completa sincrónicamente.
ValueTask <TResult> y finalización sincrónica
Todo esto, en última instancia, sirvió como motivación para introducir un nuevo tipo de ValueTask<TResult>
en .NET Core 2.0. Además, a través del sistema nuget-package System.Threading.Tasks.Extensions
, este tipo estuvo disponible en otras versiones de .NET.
ValueTask<TResult>
se introdujo en .NET Core 2.0 como una estructura capaz de TResult
o Task<TResult>
. Esto significa que los objetos de este tipo se pueden devolver desde el método async
. La primera ventaja de la introducción de este tipo es inmediatamente visible: si el método se completó con éxito y sincrónicamente, no hay necesidad de crear nada en el montón, solo lo suficiente para crear una instancia de ValueTask<TResult>
con el valor del resultado. Solo si el método sale de forma asincrónica, necesitamos crear una Task<TResult>
. En este caso, ValueTask<TResult>
usa como envoltorio sobre la Task<TResult>
. La decisión de hacer que ValueTask<TResult>
pueda agregar la Task<TResult>
se tomó para la optimización: en caso de éxito y en caso de falla, el método asincrónico crea la Task<TResult>
, desde el punto de vista de la optimización de la memoria, es mejor agregar el objeto Task<TResult>
que mantener campos adicionales en ValueTask<TResult>
para varios casos de finalización (por ejemplo, para almacenar una excepción).
Dado lo anterior, ya no es necesario el almacenamiento en caché de métodos como el MemoryStream.ReadAsync
anterior, sino que se puede implementar de la siguiente manera:
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 terminación asincrónica
Tener la capacidad de escribir métodos asincrónicos que no requieren asignaciones de memoria adicionales para el resultado, con finalización sincrónica, es realmente una gran ventaja. Como se indicó anteriormente, este era el objetivo principal para introducir el nuevo tipo ValueTask<TResult>
en .NET Core 2.0. Todos los métodos nuevos que se espera utilizar en las "carreteras ValueTask<TResult>
" ahora usan ValueTask<TResult>
lugar de Task<TResult>
como el tipo de retorno. Por ejemplo, una nueva sobrecarga del método ReadAsync
para Stream
, en .NET Core 2.1 (que toma Memory<byte>
lugar de byte[]
como parámetro), devuelve una instancia de ValueTask<int>
. Esto permitió reducir significativamente el número de asignaciones cuando se trabaja con flujos (muy a menudo el método ReadAsync
finaliza sincrónicamente, como en el ejemplo con MemoryStream
).
Sin embargo, al desarrollar servicios con gran ancho de banda, en los que la terminación asincrónica no es infrecuente, debemos hacer todo lo posible para evitar asignaciones adicionales.
Como se mencionó anteriormente, en el modelo async/await
, cualquier operación que se complete de forma asíncrona debe devolver un objeto único para esperar a que se complete. Único porque servirá como un canal para realizar devoluciones de llamada. Sin embargo, tenga en cuenta que esta construcción no dice nada acerca de si el objeto de espera devuelto puede reutilizarse después de la finalización de la operación asincrónica. Si se puede reutilizar un objeto, la API puede mantener un grupo para este tipo de objetos. Pero, en este caso, este grupo no puede admitir el acceso concurrente: un objeto del grupo pasará del estado "completado" al estado "no completado" y viceversa.
Para admitir la capacidad de trabajar con dichos grupos, la IValueTaskSource<TResult>
se agregó a .NET Core 2.1, y la ValueTask<TResult>
se expandió: ahora los objetos de este tipo pueden envolver no solo objetos del tipo TResult
o Task<TResult>
, sino también instancias de IValueTaskSource<TResult>
. La nueva interfaz proporciona una funcionalidad básica que permite que los ValueTask<TResult>
trabajen con IValueTaskSource<TResult>
de la misma manera que con la Task<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); }
GetStatus
destinado para su uso en la ValueTask<TResult>.IsCompleted/IsCompletedSuccessfully
: le permite saber si la operación se completó o no (con éxito o no). OnCompleted
usa en ValueTask<TResult>
para activar una devolución de llamada. GetResult
usa para obtener el resultado o para lanzar una excepción.
Es poco probable que la mayoría de los desarrolladores necesiten tratar con la IValueTaskSource<TResult>
, porque Los métodos asincrónicos, cuando se devuelven, lo ocultan detrás de la ValueTask<TResult>
. La interfaz en sí está destinada principalmente a aquellos que desarrollan API de alto rendimiento y busca evitar el trabajo innecesario con un grupo.
En .NET Core 2.1, hay varios ejemplos de este tipo de API. La más famosa de ellas es la nueva sobrecarga de los métodos Socket.ReceiveAsync
y Socket.SendAsync
. Por ejemplo:
public ValueTask<int> ReceiveAsync( Memory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken = default);
Los objetos de tipo ValueTask<int>
se utilizan como valor de retorno.
Si el método sale sincrónicamente, devuelve una ValueTask<int>
con el valor correspondiente:
int result = …; return new ValueTask<int>(result);
Si la operación se completa de forma asíncrona, se utiliza un objeto en caché que implementa la IValueTaskSource<TResult>
:
IValueTaskSource<int> vts = …; return new ValueTask<int>(vts);
La implementación de Socket
admite un objeto en caché para recibir y otro para enviar datos, siempre que cada uno de ellos se use sin competencia (no, por ejemplo, envío de datos competitivos). Esta estrategia reduce la cantidad de memoria adicional asignada, incluso en el caso de ejecución asincrónica.
La optimización descrita de Socket
en .NET Core 2.1 tuvo un impacto positivo en el rendimiento de NetworkStream
. Su sobrecarga es el método ReadAsync
de la clase Stream
:
public virtual ValueTask<int> ReadAsync( Memory<byte> buffer, CancellationToken cancellationToken);
simplemente delega el trabajo al método Socket.ReceiveAsync
. Aumentar la eficiencia del método de socket, en términos de trabajar con memoria, aumenta la eficiencia del método NetworkStream
.
ValueTask no genérico
Anteriormente, noté varias veces que el objetivo original de ValueTask<T>
, en .NET Core 2.0, era optimizar los casos de finalización sincrónica de métodos con un resultado "no vacío". Esto significa que no había necesidad de una ValueTask
no ValueTask
: en casos de finalización sincrónica, los métodos usan un singleton a través de la propiedad Task.CompletedTask
, y el tiempo de ejecución para los métodos de async Task
también se recibe implícitamente.
Pero, con el advenimiento de la capacidad de evitar asignaciones innecesarias y con una ejecución asincrónica, la necesidad de un ValueTask
no tipado volvió a ser relevante. Por esta razón, en .NET Core 2.1, introdujimos ValueTask
y IValueTaskSource
no IValueTaskSource
. Son análogos de los tipos genéricos correspondientes, y se usan de la misma manera, pero para métodos con un retorno vacío ( void
).
Implemente IValueTaskSource / IValueTaskSource <T>
La mayoría de los desarrolladores no necesitarán implementar estas interfaces. Y su implementación no es una tarea fácil. Si decide que necesita implementarlos usted mismo, entonces, dentro de .NET Core 2.1, hay varias implementaciones que pueden servir como ejemplos:
Para simplificar estas tareas (implementaciones de IValueTaskSource / IValueTaskSource<T>
), planeamos presentar el tipo ManualResetValueTaskSourceCore<TResult>
en .NET Core 3.0. Esta estructura encapsulará toda la lógica necesaria. La ManualResetValueTaskSourceCore<TResult>
puede usarse en otro objeto que implemente IValueTaskSource<TResult>
y / o IValueTaskSource
, y delegarle la mayor parte del trabajo. Puede obtener más información sobre esto en ttps: //github.com/dotnet/corefx/issues/32664.
El modelo correcto para usar ValueTasks
Incluso un examen superficial ValueTask
que ValueTask
y ValueTask<TResult>
más limitados que Task
y Task<TResult>
. Y esto es normal, incluso deseable, porque su objetivo principal es esperar la finalización de la ejecución asincrónica.
En particular, surgen limitaciones significativas debido al hecho de que ValueTask
y ValueTask<TResult>
pueden agregar objetos reutilizables. En general, las siguientes operaciones * NUNCA deben realizarse cuando se utiliza ValueTask
/ ValueTask<TResult>
* ( déjeme reformular a través de "Nunca" *):
- Nunca use el mismo objeto
ValueTask
/ ValueTask<TResult>
repetidamente
Motivación: las instancias de Task
y Task<TResult>
nunca pasan del estado "completado" al estado "incompleto", podemos usarlas para esperar el resultado tantas veces como queramos; una vez finalizado, siempre obtendremos el mismo resultado. Por el contrario, desde ValueTask
/ ValueTask<TResult>
, pueden actuar como envoltorios sobre objetos reutilizados, lo que significa que su estado puede cambiar, porque el estado de los objetos reutilizados cambia por definición, para pasar de "completado" a "incompleto" y viceversa.
- Nunca
ValueTask
/ ValueTask<TResult>
en modo competitivo
Motivación: un objeto envuelto espera funcionar con una sola devolución de llamada, de un solo consumidor a la vez, y tratar de competir anticipadamente puede conducir fácilmente a condiciones de carrera y sutiles errores de programación. Expectativas competitivas, esta es una de las opciones descritas anteriormente con múltiples expectativas . Tenga en cuenta que Task
/ Task<TResult>
permite cualquier cantidad de expectativas competitivas.
- Nunca use
.GetAwaiter().GetResult()
hasta que se complete la operación .
Motivación: las implementaciones de IValueTaskSource
/ IValueTaskSource<TResult>
no deberían admitir el bloqueo hasta que se complete la operación. El bloqueo, de hecho, conduce a una condición de carrera, es poco probable que este sea el comportamiento esperado por parte del consumidor. Mientras Task
/ Task<TResult>
permite hacer esto, bloqueando el hilo de llamada hasta que se complete la operación.
Pero, ¿qué sucede si, sin embargo, necesita realizar una de las operaciones descritas anteriormente y el método llamado devuelve instancias de ValueTask
/ ValueTask<TResult>
? Para tales casos, ValueTask
/ ValueTask<TResult>
proporciona el método .AsTask()
. Al llamar a este método, obtendrá una instancia de Task
/ Task<TResult>
, y ya puede realizar la operación necesaria con él. No se permite reutilizar el objeto original después de llamar a .AsTask()
.
Una regla corta dice : Cuando trabaje con una instancia de ValueTask
/, ValueTask<TResult>
debe esperar ( await
) directamente (o, si es necesario .ConfigureAwait(false)
), o llamarlo .AsTask()
y nunca usar el objeto original ValueTask
/ nuevamente ValueTask<TResult>
.
// Given this ValueTask<int>-returning method… 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(); ... // storing the instance into a local makes it much more likely it'll be misused, // but it could still be ok // BAD: awaits multiple times ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); int result = await vt; int result2 = await vt; // BAD: awaits concurrently (and, by definition then, multiple times) ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); Task.Run(async () => await vt); Task.Run(async () => await vt); // BAD: uses GetAwaiter().GetResult() when it's not known to be done ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); int result = vt.GetAwaiter().GetResult();
Hay otro patrón de uso "avanzado" adicional que algunos programadores pueden decidir aplicar (espero que solo después de mediciones cuidadosas, con justificación de los beneficios de su uso).
ValueTask
/ ValueTask<TResult>
, . , IsCompleted
true
, ( , ), — false
, IsCompletedSuccessfully
true
. " " , , , , , . await
/ .AsTask()
.Result
. , SocketsHttpHandler
.NET Core 2.1, .ReadAsync
, ValueTask<int>
. , , , . , .. . Porque , , , , :
int bytesRead; { ValueTask<int> readTask = _connection.ReadAsync(buffer); if (readTask.IsCompletedSuccessfully) { bytesRead = readTask.Result; } else { using (_connection.RegisterCancellation()) { bytesRead = await readTask; } } }
, .. ValueTask<int>
, .Result
, await
, .
API ValueTask / ValueTask<TResult>?
, . Task
/ ValueTask<TResult>
.
, Task
/ Task<TResult>
. , "" / , Task
/ Task<TResult>
. , , ValueTask<TResult>
Task<TResult>
: , , await
Task<TResult>
ValueTask<TResult>
. , (, API Task
Task<bool>
), , , Task
( Task<bool>
). , ValueTask
/ ValueTask<TResult>
. , async-, ValueTask
/ ValueTask<TResult>
, .
, ValueTask
/ ValueTask<TResult>
, :
- , API ,
- API ,
- , , , .
, abstract
/ virtual
, , / ?
Que sigue
.NET, API, Task
/ Task<TResult>
. , , API c ValueTask
/ ValueTask<TResult>
, . IAsyncEnumerator<T>
, .NET Core 3.0. IEnumerator<T>
MoveNext
, . — IAsyncEnumerator<T>
MoveNextAsync
. , Task<bool>
, , . , , , ( ), , , await foreach
-, , MoveNextAsync
, ValueTask<bool>
. , , , " " , . , C# , .
