¿Por qué no todas las funciones deberían ser asincrónicas de forma predeterminada?

107

El patrón async-await de .net 4.5 está cambiando de paradigma. Es demasiado bueno para ser verdad.

He estado transfiriendo un código pesado de IO a async-await porque el bloqueo es cosa del pasado.

Muchas personas están comparando async-await con una infestación de zombis y me pareció bastante preciso. Al código asíncrono le gustan otros códigos asíncronos (necesita una función asíncrona para esperar en una función asíncrona). Entonces, cada vez más funciones se vuelven asincrónicas y esto sigue creciendo en su base de código.

Cambiar funciones a asincrónicas es un trabajo algo repetitivo y poco imaginativo. Introduzca una asyncpalabra clave en la declaración, ajuste el valor de retorno Task<>y ya está. Es bastante inquietante lo fácil que es todo el proceso, y muy pronto un script de reemplazo de texto automatizará la mayor parte de la "migración" para mí.

Y ahora la pregunta ... Si todo mi código se está volviendo asíncrono lentamente, ¿por qué no hacerlo todo asíncrono por defecto?

La razón obvia que asumo es el rendimiento. Async-await tiene una sobrecarga y un código que no necesita ser asíncrono, preferiblemente no debería. Pero si el rendimiento es el único problema, seguramente algunas optimizaciones inteligentes pueden eliminar la sobrecarga automáticamente cuando no es necesaria. He leído sobre la optimización de la "ruta rápida" y me parece que solo ella debería encargarse de la mayor parte.

Quizás esto sea comparable al cambio de paradigma provocado por los recolectores de basura. En los primeros días de GC, liberar su propia memoria era definitivamente más eficiente. Pero las masas aún eligieron la recopilación automática en favor de un código más seguro y simple que podría ser menos eficiente (e incluso eso posiblemente ya no sea cierto). ¿Quizás este debería ser el caso aquí? ¿Por qué no todas las funciones deberían ser asincrónicas?

talkol
fuente
8
Dale al equipo de C # el crédito por marcar el mapa. Como se hizo hace cientos de años, "los dragones yacen aquí". Podrías equipar un barco e ir allí, es muy probable que sobrevivas con el sol brillando y el viento en tu espalda. Y a veces no, nunca regresaron. Al igual que async / await, SO está lleno de preguntas de usuarios que no entendieron cómo salieron del mapa. A pesar de que recibieron una muy buena advertencia. Ahora es su problema, no el problema del equipo de C #. Marcaron a los dragones.
Hans Passant
1
@Sayse incluso si se quita la distinción entre las funciones síncronas y asíncronas, llamar a las funciones que se implementan de forma sincrónica todavía será sincrónica (como su ejemplo WriteLine)
talkol
2
“Envolver el valor de retorno por Task <>” A menos que su método tenga que ser async(para cumplir con algún contrato), esta probablemente sea una mala idea. Obtiene las desventajas de async (mayor costo de las llamadas a métodos; la necesidad de usarlo awaiten el código de llamada), pero ninguna de las ventajas.
svick
1
Esta es una pregunta realmente interesante, pero tal vez no sea muy adecuada para SO. Podríamos considerar migrarlo a programadores.
Eric Lippert
1
Quizás me esté perdiendo algo, pero tengo exactamente la misma pregunta. Si async / await siempre vienen en pares y el código todavía tiene que esperar a que termine de ejecutarse, ¿por qué no actualizar el marco .NET existente y hacer que los métodos que necesitan ser async - async por defecto SIN crear palabras clave adicionales? El lenguaje ya se está convirtiendo en lo que fue diseñado para escapar: espaguetis de palabras clave. Creo que deberían haber dejado de hacer eso después de que se sugirió la "var". Ahora tenemos "dinámico", asyn / await ... etc ... ¿Por qué no se limitan a .NET-ify javascript? ;))
monstro

Respuestas:

127

En primer lugar, gracias por sus amables palabras. De hecho, es una característica increíble y me alegro de haber sido una pequeña parte de ella.

Si todo mi código se está volviendo asíncrono lentamente, ¿por qué no hacerlo todo asíncrono de forma predeterminada?

Bueno, estás exagerando; todo su código no se vuelve asincrónico. Cuando sumas dos números enteros "simples", no estás esperando el resultado. Cuando sumas dos números enteros futuros juntos para obtener un tercer entero futuro , porque eso es lo que Task<int>es, es un número entero al que tendrás acceso en el futuro, por supuesto, es probable que estés esperando el resultado.

La razón principal para no hacer que todo sea asincrónico es porque el propósito de async / await es facilitar la escritura de código en un mundo con muchas operaciones de alta latencia . La gran mayoría de sus operaciones no tienen una latencia alta, por lo que no tiene sentido recibir el impacto del rendimiento que mitiga esa latencia. Más bien, algunas de sus operaciones clave son de alta latencia, y esas operaciones están causando la infestación zombie de async en todo el código.

Si el rendimiento es el único problema, seguramente algunas optimizaciones inteligentes pueden eliminar la sobrecarga automáticamente cuando no es necesaria.

En teoría, la teoría y la práctica son similares. En la práctica, nunca lo son.

Déjame darte tres puntos en contra de este tipo de transformación seguida de un pase de optimización.

El primer punto nuevamente es: async en C # / VB / F # es esencialmente una forma limitada de paso de continuación . Se ha realizado una enorme cantidad de investigación en la comunidad del lenguaje funcional para encontrar formas de identificar cómo optimizar el código que hace un uso intensivo del estilo de paso continuo. El equipo del compilador probablemente tendría que resolver problemas muy similares en un mundo donde "async" era el valor predeterminado y los métodos no async tenían que ser identificados y desincronizados. El equipo de C # no está realmente interesado en abordar problemas de investigación abiertos, por lo que hay grandes puntos en contra.

Un segundo punto en contra es que C # no tiene el nivel de "transparencia referencial" que hace que este tipo de optimizaciones sea más manejable. Por "transparencia referencial" me refiero a la propiedad de que el valor de una expresión no depende cuando se evalúa . Expresiones como 2 + 2son referencialmente transparentes; puede realizar la evaluación en tiempo de compilación si lo desea, o diferirla hasta el tiempo de ejecución y obtener la misma respuesta. Pero una expresión como x+yno se puede mover en el tiempo porque xey pueden cambiar con el tiempo .

Async hace que sea mucho más difícil razonar sobre cuándo sucederá un efecto secundario. Antes de async, si dijiste:

M();
N();

y M()fue void M() { Q(); R(); }, N()fue void N() { S(); T(); }y Ry Sproduce efectos secundarios, entonces sabes que el efecto secundario de R ocurre antes que el efecto secundario de S. Pero si es async void M() { await Q(); R(); }así, de repente eso se va por la ventana. No tiene garantía de si R()sucederá antes o después S()(a menos que, por supuesto, M()se espere; pero, por supuesto, Taskno es necesario esperarlo hasta después N()).

Ahora imagine que esta propiedad de no saber más en qué orden ocurren los efectos secundarios se aplica a cada fragmento de código en su programa, excepto a aquellos que el optimizador logra desincronizar. Básicamente, ya no tienes ni idea de qué expresiones se evaluarán en qué orden, lo que significa que todas las expresiones deben ser referencialmente transparentes, lo cual es difícil en un lenguaje como C #.

Un tercer punto en contra es que luego debe preguntarse "¿por qué la asíncrona es tan especial?" Si va a argumentar que cada operación debería ser realmente una, Task<T>entonces debe poder responder la pregunta "¿por qué no Lazy<T>?" o "¿por qué no Nullable<T>?" o "¿por qué no IEnumerable<T>?" Porque podríamos hacer eso con la misma facilidad. ¿Por qué no debería ser el caso de que todas las operaciones pasen a ser anulables ? O cada operación se calcula de forma perezosa y el resultado se almacena en caché para más tarde , o el resultado de cada operación es una secuencia de valores en lugar de un solo valor . Luego debe intentar optimizar aquellas situaciones en las que sabe "oh, esto nunca debe ser nulo, para que pueda generar un mejor código", y así sucesivamente.

El punto es: no me Task<T>queda claro que sea ​​realmente tan especial como para justificar tanto trabajo.

Si este tipo de cosas le interesan, le recomiendo que investigue lenguajes funcionales como Haskell, que tienen una transparencia referencial mucho más fuerte y permiten todo tipo de evaluación desordenada y almacenamiento en caché automático. Haskell también tiene un soporte mucho más fuerte en su sistema de tipos para los tipos de "levantamientos monádicos" a los que he aludido.

Eric Lippert
fuente
Tener funciones asíncronas llamadas sin esperar no tiene sentido para mí (en el caso común). Si elimináramos esta característica, el compilador podría decidir por sí mismo si una función es asíncrona o no (¿llama a await?). Entonces podríamos tener una sintaxis idéntica para ambos casos (async y sync), y solo usar await en las llamadas como diferenciador. Infestación de zombis resuelta :)
talkol
Continué
@EricLippert - Muy buena respuesta como siempre :) Tenía curiosidad por saber si podía aclarar "alta latencia". ¿Existe un rango general en milisegundos aquí? Solo estoy tratando de averiguar dónde está la línea de límite inferior para usar async porque no quiero abusar de ella.
Travis J
7
@TravisJ: La guía es: no bloquee el hilo de la interfaz de usuario durante más de 30 ms. Más que eso, corre el riesgo de que el usuario perciba la pausa.
Eric Lippert
1
Lo que me desafía es que si algo se hace de forma sincrónica o asincrónica es un detalle de implementación que puede cambiar. Pero el cambio en esa implementación fuerza un efecto dominó a través del código que lo llama, el código que llama a eso, etc. Terminamos cambiando el código debido al código del que depende, que es algo que normalmente hacemos todo lo posible por evitar. O usamos async/awaitporque algo oculto bajo capas de abstracción puede ser asincrónico.
Scott Hannen
23

¿Por qué no todas las funciones deberían ser asincrónicas?

El rendimiento es una de las razones, como mencionaste. Tenga en cuenta que la opción de "ruta rápida" a la que se vinculó mejora el rendimiento en el caso de una Tarea completa, pero aún requiere muchas más instrucciones y gastos generales en comparación con una sola llamada a un método. Como tal, incluso con la "ruta rápida" en su lugar, está agregando mucha complejidad y sobrecarga con cada llamada al método asíncrono.

La compatibilidad con versiones anteriores, así como la compatibilidad con otros lenguajes (incluidos los escenarios de interoperabilidad), también se volvería problemática.

El otro es una cuestión de complejidad e intención. Las operaciones asincrónicas agregan complejidad; en muchos casos, las características del lenguaje lo ocultan, pero hay muchos casos en los que la creación de métodos asyncdefinitivamente agrega complejidad a su uso. Esto es especialmente cierto si no tiene un contexto de sincronización, ya que los métodos asincrónicos pueden terminar fácilmente causando problemas de subprocesos inesperados.

Además, hay muchas rutinas que, por su naturaleza, no son asincrónicas. Esas tienen más sentido como operaciones sincrónicas. Obligar Math.Sqrta serlo Task<double> Math.SqrtAsyncsería ridículo, por ejemplo, ya que no hay ninguna razón para que eso sea asincrónico. En lugar de asyncimpulsar su aplicación, terminaría awaitpropagándose por todas partes .

Esto también rompería por completo el paradigma actual, además de causar problemas con las propiedades (que en realidad son solo pares de métodos ... ¿también se volverían asíncronos?), Y tendría otras repercusiones en todo el diseño del marco y el lenguaje.

Si está haciendo mucho trabajo vinculado a IO, tenderá a encontrarlo usando async generalizado es una gran adición, muchas de sus rutinas lo serán async. Sin embargo, cuando comienzas a hacer un trabajo vinculado a la CPU, en general, hacer las cosas asyncno es bueno: está ocultando el hecho de que estás usando ciclos de CPU bajo una API que parece ser asíncrona, pero en realidad no es necesariamente realmente asincrónica.

Reed Copsey
fuente
Exactamente lo que iba a escribir (rendimiento), la compatibilidad con versiones anteriores podría ser otra cosa, dll se usará con lenguajes más antiguos que no admiten async /
await
Hacer sqrt async no es ridículo si simplemente eliminamos la distinción entre funciones de sincronización y async
talkol
@talkol Supongo que cambiaría esto: ¿por qué cada llamada de función debería asumir la complejidad de la asincronía?
Reed Copsey
2
@talkol Yo diría que eso no es necesariamente cierto: la asincronía puede agregar errores que son peores que bloquear ...
Reed Copsey
1
@talkol ¿Cómo es await FooAsync()más simple que Foo()? ¿Y en lugar de un pequeño efecto dominó algunas veces, tienes un gran efecto dominó todo el tiempo y lo llamas una mejora?
svick
4

Dejando a un lado el rendimiento, la asíncrona puede tener un costo de productividad. En el cliente (WinForms, WPF, Windows Phone) es una bendición para la productividad. Pero en el servidor, o en otros escenarios que no sean de UI, pagas productividad. Ciertamente no querrás ir asincrónico por defecto allí. Úselo cuando necesite las ventajas de escalabilidad.

Úselo cuando esté en el punto óptimo. En otros casos, no lo haga.

usr
fuente
2
+1 - El simple intento de tener un mapa mental de código que ejecute 5 operaciones asincrónicas en paralelo con una secuencia aleatoria de finalización proporcionará a la mayoría de las personas suficiente dolor para un día. Razonar sobre el comportamiento del código asincrónico (y, por lo tanto, inherentemente paralelo) es mucho más difícil que el buen código sincrónico antiguo ...
Alexei Levenkov
2

Creo que hay una buena razón para hacer que todos los métodos sean asincrónicos si no son necesarios: extensibilidad. Los métodos de creación selectiva asíncronos solo funcionan si su código nunca evoluciona y sabe que el método A () siempre está vinculado a la CPU (lo mantiene sincronizado) y el método B () siempre está vinculado a E / S (lo marca como asíncrono).

Pero ¿y si las cosas cambian? Sí, A () está haciendo cálculos, pero en algún momento en el futuro tuvo que agregar registro allí, o informes, o devolución de llamada definida por el usuario con implementación que no puede predecir, o el algoritmo se ha extendido y ahora incluye no solo cálculos de CPU sino también algunas E / S? Deberá convertir el método a asíncrono, pero esto rompería la API y todas las personas que llaman en la pila también deberían actualizarse (e incluso pueden ser diferentes aplicaciones de diferentes proveedores). O deberá agregar la versión asíncrona junto con la versión sincronizada, pero esto no hace mucha diferencia: el uso de la versión sincronizada se bloquearía y, por lo tanto, no es aceptable.

Sería genial si fuera posible hacer que el método de sincronización existente sea asíncrono sin cambiar la API. Pero en realidad no tenemos esa opción, creo, y usar la versión asíncrona incluso si no es necesaria actualmente es la única forma de garantizar que nunca tendrá problemas de compatibilidad en el futuro.

Alex
fuente
Aunque esto parece un comentario extremo, hay mucha verdad en él. Primero, debido a este problema: "esto rompería la API y todos los llamadores subirían la pila" async / await hace que una aplicación esté más estrechamente acoplada. Podría romper fácilmente el principio de sustitución de Liskov si una subclase quiere usar async / await, por ejemplo. Además, es difícil imaginar una arquitectura de microservicios donde la mayoría de los métodos no necesitaran async / await.
ItsAllABadJoke