¿Por qué se usaría la Tarea <T> sobre ValueTask <T> en C #?

168

A partir de C # 7.0, los métodos asíncronos pueden devolver ValueTask <T>. La explicación dice que debería usarse cuando tenemos un resultado almacenado en caché o simulando asíncrono a través de código sincrónico. Sin embargo, todavía no entiendo cuál es el problema con el uso de ValueTask siempre o de hecho por qué async / await no se creó con un tipo de valor desde el principio. ¿Cuándo ValueTask no podría hacer el trabajo?

Stilgar
fuente
77
Sospecho que tiene que ver con los beneficios de ValueTask<T>(en términos de asignaciones) no materializarse para operaciones que en realidad son asíncronas (porque en ese caso ValueTask<T>aún necesitará una asignación de montón). También está la cuestión de Task<T>tener mucho otro soporte dentro de las bibliotecas.
Jon Skeet
3
Las bibliotecas existentes de @JonSkeet son un problema, pero esto plantea la pregunta de si la Tarea debería ser ValueTask desde el principio Los beneficios pueden no existir cuando se usa para material asíncrono real, pero ¿es dañino?
Stilgar
8
Visite github.com/dotnet/corefx/issues/4708#issuecomment-160658188 para obtener más sabiduría de la que podría transmitir :)
Jon Skeet
3
@JoelMueller la trama se complica :)
Stilgar

Respuestas:

245

De los documentos de la API (énfasis agregado):

Los métodos pueden devolver una instancia de este tipo de valor cuando es probable que el resultado de sus operaciones esté disponible sincrónicamente y cuando se espera que el método se invoque con tanta frecuencia que el costo de asignar una nueva Task<TResult>para cada llamada sea prohibitivo.

Hay compensaciones por usar a en ValueTask<TResult>lugar de a Task<TResult>. Por ejemplo, si bien ValueTask<TResult>puede ayudar a evitar una asignación en el caso en que el resultado exitoso esté disponible sincrónicamente, también contiene dos campos, mientras Task<TResult>que un tipo de referencia es un solo campo. Esto significa que una llamada al método termina devolviendo dos campos de datos en lugar de uno, que es más datos para copiar. También significa que si se espera un método que devuelve uno de estos dentro de un asyncmétodo, la máquina de estado para ese asyncmétodo será más grande debido a la necesidad de almacenar la estructura que son dos campos en lugar de una sola referencia.

Además, para usos que no sean consumir el resultado de una operación asíncrona a través de await, ValueTask<TResult>puede conducir a un modelo de programación más complicado, que a su vez puede conducir a más asignaciones. Por ejemplo, considere un método que podría devolver a Task<TResult>con una tarea en caché como resultado común o a ValueTask<TResult>. Si el consumidor del resultado quiere usarlo como a Task<TResult>, como para usarlo en métodos como Task.WhenAlly Task.WhenAny, ValueTask<TResult>primero debería convertirse en un Task<TResult>uso AsTask, lo que lleva a una asignación que se habría evitado si se Task<TResult>hubiera utilizado un almacenamiento en caché en primer lugar.

Como tal, la opción predeterminada para cualquier método asincrónico debería ser devolver un Tasko Task<TResult>. Solo si el análisis de rendimiento demuestra que vale la pena, debe ValueTask<TResult>usarse un en lugar de Task<TResult>.

Stephen Cleary
fuente
77
@MattThomas: ahorra una sola Taskasignación (que es pequeña y barata en estos días), pero a costa de aumentar la asignación existente de la persona que llama y duplicar el tamaño del valor de retorno (afectando la asignación del registro). Si bien es una opción clara para un escenario de lectura almacenado, aplicarlo de manera predeterminada a todas las interfaces no es algo que recomendaría.
Stephen Cleary
1
Derecha, Tasko ValueTaskpuede usarse como un tipo de retorno síncrono (con Task.FromResult). Pero todavía hay valor (je) ValueTasksi tienes algo que esperas que sea sincrónico. ReadByteAsyncsiendo un ejemplo clásico Creo que ValueTask fue creado principalmente para los nuevos "canales" (flujos de bytes de bajo nivel), posiblemente también utilizados en el núcleo ASP.NET donde el rendimiento realmente importa.
Stephen Cleary
1
Oh, lo sé jajaja, me preguntaba si tenía algo que agregar a ese comentario específico;)
julealgon
2
¿ Este PR cambia el balance a preferir ValueTask? (ref: blog.marcgravell.com/2019/08/… )
stuartd
2
@stuartd: Por ahora, aún recomendaría usarlo Task<T>por defecto. Esto es solo porque la mayoría de los desarrolladores no están familiarizados con las restricciones ValueTask<T>(específicamente, la regla de "solo consumir una vez" y la regla de "no bloquear"). Dicho esto, si todos los desarrolladores de tu equipo son buenos ValueTask<T>, entonces recomendaría una pauta de preferencia a nivel de equipo ValueTask<T>.
Stephen Cleary
104

Sin embargo, todavía no entiendo cuál es el problema con el uso de ValueTask siempre

Los tipos de estructura no son gratuitos. Copiar estructuras que son más grandes que el tamaño de una referencia puede ser más lento que copiar una referencia. Almacenar estructuras que son más grandes que una referencia requiere más memoria que almacenar una referencia. Es posible que las estructuras que tienen más de 64 bits no se registren cuando se puede registrar una referencia. Los beneficios de una menor presión de recolección no pueden exceder los costos.

Los problemas de rendimiento deben abordarse con una disciplina de ingeniería. Establezca objetivos, mida su progreso frente a los objetivos y luego decida cómo modificar el programa si no se cumplen los objetivos, midiendo en el camino para asegurarse de que sus cambios sean realmente mejoras.

por qué async / await no se creó con un tipo de valor desde el principio.

awaitse agregó a C # mucho después de que el Task<T>tipo ya existiera. Hubiera sido algo perverso inventar un nuevo tipo cuando ya existía. Y awaitpasó por muchas iteraciones de diseño antes de decidirse por el que se envió en 2012. Lo perfecto es enemigo de lo bueno; es mejor enviar una solución que funcione bien con la infraestructura existente y luego, si hay una demanda del usuario, proporcionar mejoras más adelante.

También noto que la nueva característica de permitir que los tipos suministrados por el usuario sean la salida de un método generado por el compilador agrega un riesgo considerable y una carga de prueba. Cuando las únicas cosas que puede devolver son nulas o una tarea, el equipo de prueba no tiene que considerar ningún escenario en el que se devuelva algún tipo absolutamente loco. Probar un compilador significa descubrir no solo qué programas es probable que escriban las personas, sino qué programas son posibles de escribir, porque queremos que el compilador compile todos los programas legales, no solo todos los programas sensibles. Eso es caro.

¿Alguien puede explicar cuándo ValueTask no podría hacer el trabajo?

El propósito de la cosa es mejorar el rendimiento. No hace el trabajo si no mejora de manera medible y significativa el rendimiento. No hay garantía de que lo haga.

Eric Lippert
fuente
23

ValueTask<T>no es un subconjunto de Task<T>, es un superconjunto .

ValueTask<T>es una unión discriminada de T y a Task<T>, lo que lo hace libre de asignación para ReadAsync<T>devolver sincrónicamente un valor T que tiene disponible (en contraste con el uso Task.FromResult<T>, que necesita asignar una Task<T>instancia). ValueTask<T>es esperable, por lo que la mayoría del consumo de instancias será indistinguible de a Task<T>.

ValueTask, al ser una estructura, permite escribir métodos asincrónicos que no asignan memoria cuando se ejecutan sincrónicamente sin comprometer la consistencia de la API. Imagine tener una interfaz con un método de devolución de tareas. Cada clase que implemente esta interfaz debe devolver una Tarea incluso si se ejecuta sincrónicamente (con suerte usando Task.FromResult). Por supuesto, puede tener 2 métodos diferentes en la interfaz, uno sincrónico y uno asíncrono, pero esto requiere 2 implementaciones diferentes para evitar "sincronizar sobre sincronización" y "sincronizar sobre sincronización".

Por lo tanto, le permite escribir un método que sea asíncrono o sincrónico, en lugar de escribir un método idéntico para cada uno. Puede usarlo en cualquier lugar que use, Task<T>pero a menudo no agregaría nada.

Bueno, agrega una cosa: agrega una promesa implícita a la persona que llama de que el método realmente utiliza la funcionalidad adicional que ValueTask<T>proporciona. Personalmente, prefiero elegir los parámetros y los tipos de retorno que le dicen a la persona que llama tanto como sea posible. No regrese IList<T>si la enumeración no puede proporcionar un conteo; no regrese IEnumerable<T>si puede. Sus consumidores no deberían tener que buscar ninguna documentación para saber cuáles de sus métodos se pueden llamar razonablemente sincrónicamente y cuáles no.

No veo cambios futuros en el diseño como un argumento convincente allí. Todo lo contrario: si un método cambia su semántica, debería romper la compilación hasta que todas las llamadas a él se actualicen en consecuencia. Si eso se considera indeseable (y créanme, simpatizo con el deseo de no romper la compilación), considere la versión de la interfaz.

Esto es esencialmente para lo que sirve la escritura fuerte.

Si algunos de los programadores que diseñan métodos asíncronos en su tienda no pueden tomar decisiones informadas, puede ser útil asignar un mentor principal a cada uno de esos programadores menos experimentados y realizar una revisión semanal del código. Si adivinan mal, explique por qué debe hacerse de manera diferente. Es algo elevado para los muchachos mayores, pero hará que los juniors aceleren mucho más rápido que simplemente lanzarlos al fondo y darles una regla arbitraria a seguir.

Si el tipo que escribió el método no sabe si se puede llamar sincrónicamente, ¿ quién demonios lo sabe?

Si tienes tantos programadores inexpertos que escriben métodos asincrónicos, ¿los llaman también esas mismas personas? ¿Están calificados para descubrir por sí mismos cuáles son seguros para llamar asíncronos, o comenzarán a aplicar una regla arbitraria similar a cómo llaman a estas cosas?

El problema aquí no son sus tipos de retorno, sino que los programadores tienen roles para los que no están preparados. Eso debe haber sucedido por una razón, así que estoy seguro de que no puede ser trivial solucionarlo. Describirlo ciertamente no es una solución. Pero buscar una manera de escabullir el problema más allá del compilador tampoco es una solución.

15ee8f99-57ff-4f92-890c-b56153
fuente
44
Si veo que un método regresa ValueTask<T>, supongo que el tipo que escribió el método lo hizo porque el método realmente utiliza la funcionalidad que ValueTask<T>agrega. No entiendo por qué crees que es deseable que todos tus métodos tengan el mismo tipo de retorno. ¿Cuál es el objetivo allí?
15ee8f99-57ff-4f92-890c-b56153 el
2
El objetivo 1 sería permitir una implementación cambiante. ¿Qué sucede si no estoy almacenando en caché el resultado ahora pero agrego código de almacenamiento en caché más tarde? El objetivo 2 sería tener una directriz clara para un equipo donde no todos los miembros entiendan cuándo ValueTask es útil. Solo hazlo siempre si no hay daño al usarlo.
Stilgar
66
En mi opinión, eso es casi como hacer que todos sus métodos regresen objectporque tal vez algún día querrá que regresen en intlugar de hacerlo string.
15ee8f99-57ff-4f92-890c-b56153 el
3
Tal vez, pero si hace la pregunta "¿por qué devolvería una cadena en lugar de un objeto?", Puedo responderla fácilmente señalando la falta de seguridad de tipo. Las razones para no usar ValueTask en todas partes parecen ser mucho más sutiles.
Stilgar
3
@Stilgar Una cosa que diría: los problemas sutiles deberían preocuparte más que los obvios.
15ee8f99-57ff-4f92-890c-b56153
20

Hay algunos cambios en .Net Core 2.1 . A partir de .net core 2.1 ValueTask puede representar no solo las acciones sincrónicas completadas sino también la asincrónica completada. Además recibimos tipo no genérico ValueTask.

Dejaré el comentario de Stephen Toub relacionado con su pregunta:

Todavía necesitamos formalizar la orientación, pero espero que sea algo así para el área de superficie API pública:

  • La tarea proporciona la mayor usabilidad.

  • ValueTask ofrece la mayoría de las opciones para la optimización del rendimiento.

  • Si está escribiendo una interfaz / método virtual que otros anularán, ValueTask es la opción predeterminada correcta.

  • Si espera que la API se use en rutas activas donde las asignaciones serán importantes, ValueTask es una buena opción.

  • De lo contrario, donde el rendimiento no es crítico, por defecto es Task, ya que proporciona mejores garantías y facilidad de uso.

Desde una perspectiva de implementación, muchas de las instancias devueltas de ValueTask seguirán respaldadas por Task.

La función se puede usar no solo en .net core 2.1. Podrá usarlo con el paquete System.Threading.Tasks.Extensions .

inseguroPtr
fuente
1
Más de Stephen hoy: blogs.msdn.microsoft.com/dotnet/2018/11/07/…
Mike Cheel