¿Las pautas de uso asíncrono / en espera en C # no contradicen los conceptos de buena arquitectura y estratificación de abstracción?

103

Esta pregunta se refiere al lenguaje C #, pero espero que cubra otros lenguajes como Java o TypeScript.

Microsoft recomienda las mejores prácticas sobre el uso de llamadas asincrónicas en .NET. Entre estas recomendaciones, escojamos dos:

  • cambiar la firma de los métodos asíncronos para que devuelvan Tarea o Tarea <> (en TypeScript, eso sería una Promesa <>)
  • cambiar los nombres de los métodos asíncronos para terminar con xxxAsync ()

Ahora, cuando se reemplaza un componente síncrono de bajo nivel por uno asíncrono, esto afecta la pila completa de la aplicación. Dado que async / await tiene un impacto positivo solo si se usa "completamente", significa que se deben cambiar los nombres de firma y método de cada capa de la aplicación.

Una buena arquitectura a menudo implica colocar abstracciones entre cada capa, de modo que el reemplazo de componentes de bajo nivel por otros no sea visto por los componentes de nivel superior. En C #, las abstracciones toman la forma de interfaces. Si presentamos un nuevo componente asíncrono de bajo nivel, cada interfaz en la pila de llamadas debe ser modificada o reemplazada por una nueva interfaz. La forma en que se resuelve un problema (asíncrono o sincronización) en una clase de implementación ya no se oculta (abstrae) a las personas que llaman. Las personas que llaman tienen que saber si es sincronizado o asíncrono.

¿Acaso las mejores prácticas asíncronas / en espera no contradicen los principios de "buena arquitectura"?

¿Significa que cada interfaz (por ejemplo, IEnumerable, IDataAccessLayer) necesita su contraparte asíncrona (IAsyncEnumerable, IAsyncDataAccessLayer) de modo que puedan reemplazarse en la pila al cambiar a dependencias asíncronas?

Si llevamos el problema un poco más lejos, ¿no sería más sencillo asumir que todos los métodos son asíncronos (para devolver una Tarea <> o Promesa <>), y que los métodos sincronicen las llamadas asíncronas cuando en realidad no están asíncrono? ¿Es esto algo que se espera de los futuros lenguajes de programación?

Correntinaltepe
fuente
55
Si bien esto parece una gran pregunta de discusión, creo que esto está demasiado basado en la opinión para ser respondido aquí.
Eufórico
22
@Euphoric: Creo que el problema esbozado aquí va más allá de las pautas de C #, eso es solo un síntoma del hecho de que cambiar partes de una aplicación a un comportamiento asincrónico puede tener efectos no locales en el sistema en general. Entonces mi instinto me dice que debe haber una respuesta no obvia para esto, basada en hechos técnicos. Por lo tanto, animo a todos los presentes para que no cierren esta pregunta demasiado pronto, sino que esperemos qué respuestas vendrán (y si son demasiado obstinadas, aún podemos votar por el cierre).
Doc Brown
25
@DocBrown Creo que la pregunta más profunda aquí es "¿Se puede cambiar parte del sistema de síncrono a asíncrono sin que las partes que dependen de él también tengan que cambiar?" Creo que la respuesta a eso es clara "no". En ese caso, no veo cómo se aplican aquí los "conceptos de buena arquitectura y estratificación".
Eufórico el
66
@Euphoric: suena como una buena base para una respuesta no obstinada ;-)
Doc Brown
55
@Gherman: porque C #, como muchos lenguajes, no puede hacer una sobrecarga basada solo en el tipo de retorno. Terminaría con métodos asíncronos que tienen la misma firma que sus contrapartes de sincronización (no todos pueden tomar un CancellationToken, y aquellos que lo hacen pueden desear proporcionar un valor predeterminado). La eliminación de los métodos de sincronización existentes (y la ruptura proactiva de todo el código) es un obvio obvio.
Jeroen Mostert

Respuestas:

111

¿De qué color es tu función?

Quizás te interese Bob Nystrom's What Color Is Your Function 1 .

En este artículo, describe un lenguaje ficticio donde:

  • Cada función tiene un color: azul o rojo.
  • Una función roja puede llamar a funciones azules o rojas, no hay problema.
  • Una función azul solo puede llamar a funciones azules.

Si bien es ficticio, esto sucede con bastante frecuencia en los lenguajes de programación:

  • En C ++, un método "const" solo puede llamar a otros métodos "const" this.
  • En Haskell, una función no IO solo puede llamar a funciones no IO.
  • En C #, una función de sincronización solo puede llamar a las funciones de sincronización 2 .

Como se habrá dado cuenta, debido a estas reglas, las funciones rojas tienden a extenderse por la base del código. Inserta uno y, poco a poco, coloniza todo el código base.

1 Bob Nystrom, además de bloguear, también es parte del equipo Dart y ha escrito esta pequeña serie de Crafting Interpreters; Muy recomendable para cualquier lenguaje de programación / compilador afficionado.

2 No es del todo cierto, ya que puede llamar a una función asíncrona y bloquear hasta que regrese, pero ...

Limitación de lenguaje

Esto es, esencialmente, una limitación de idioma / tiempo de ejecución.

El lenguaje con subprocesos M: N, por ejemplo, como Erlang y Go, no tiene asyncfunciones: cada función es potencialmente asíncrona y su "fibra" simplemente se suspenderá, se intercambiará y se volverá a activar cuando esté lista nuevamente.

C # optó por un modelo de subprocesos 1: 1 y, por lo tanto, decidió sacar a la superficie la sincronía en el lenguaje para evitar el bloqueo accidental de subprocesos.

En presencia de limitaciones de lenguaje, las pautas de codificación deben adaptarse.

Matthieu M.
fuente
44
Las funciones IO tienen una tendencia a extenderse, pero con diligencia, puede aislarlas en su mayoría a las funciones cercanas (en la pila al llamar) a los puntos de entrada de su código. Puede hacer esto haciendo que esas funciones llamen a las funciones IO y luego que otras funciones procesen la salida de ellas y devuelvan los resultados necesarios para otras IO. Creo que este estilo hace que mis bases de códigos sean mucho más fáciles de administrar y trabajar. Me pregunto si hay un corolario con sincronicidad.
jpmc26
16
¿Qué quieres decir con "M: N" y "1: 1" subprocesos?
Capitán Man
14
@CaptainMan: el subproceso 1: 1 significa asignar un subproceso de aplicación a un subproceso del sistema operativo, este es el caso en lenguajes como C, C ++, Java o C #. Por el contrario, el subproceso M: N significa mapear subprocesos de aplicaciones M a subprocesos N OS; en el caso de Go, un hilo de aplicación se llama "gorutina", en el caso de Erlang, se llama "actor", y es posible que también haya oído hablar de ellos como "hilos verdes" o "fibras". Proporcionan concurrencia sin requerir paralelismo. Lamentablemente, el artículo de Wikipedia sobre el tema es bastante escaso.
Matthieu M.
2
Esto está algo relacionado, pero también creo que esta idea del "color de la función" también se aplica a las funciones que bloquean la entrada del usuario, por ejemplo, cuadros de diálogo modales, cuadros de mensajes, algunas formas de E / S de consola, etc., que son herramientas que Framework ha tenido desde el principio.
jrh
2
@MatthieuM. C # no tiene un subproceso de aplicación por cada subproceso del sistema operativo y nunca lo tuvo. Esto es muy obvio cuando interactúa con código nativo, y especialmente obvio cuando se ejecuta en MS SQL. Y, por supuesto, las rutinas cooperativas siempre fueron posibles (y son aún más simples con async); de hecho, era un patrón bastante común para construir una interfaz de usuario receptiva. ¿Era tan bonita como Erlang? No. Pero eso sigue muy lejos de C :)
Luaan
82

Tienes razón, hay una contradicción aquí, pero no es que las "mejores prácticas" sean malas. Esto se debe a que la función asincrónica hace algo esencialmente diferente que una síncrona. En lugar de esperar el resultado de sus dependencias (generalmente algunas E / S), crea una tarea que debe manejar el bucle de eventos principal. Esta no es una diferencia que pueda estar bien oculta bajo abstracción.

max630
fuente
27
La respuesta es tan simple como esta OMI. La diferencia entre un proceso sincrónico y asincrónico no es un detalle de implementación, es un contrato semánticamente diferente.
Ant P
11
@AntP: No estoy de acuerdo con que sea así de simple; aparece en el lenguaje C #, pero no en el lenguaje Go, por ejemplo. Entonces, esta no es una propiedad inherente de los procesos asincrónicos, se trata de cómo se modelan los procesos asincrónicos en el lenguaje dado.
Matthieu M.
1
@MatthieuM. Sí, pero puede usar asyncmétodos en C # para proporcionar contratos sincrónicos también, si así lo desea. La única diferencia es que Go es asíncrono por defecto, mientras que C # es sincrónico por defecto. asyncle ofrece el segundo modelo de programación: async es la abstracción (lo que realmente hace depende del tiempo de ejecución, el programador de tareas, el contexto de sincronización, la implementación del camarero ...).
Luaan
6

Un método asincrónico se comporta de manera diferente a uno que es sincrónico, como estoy seguro de que sabe. En tiempo de ejecución, convertir una llamada asíncrona en una sincrónica es trivial, pero no se puede decir lo contrario. Por lo tanto, la lógica se convierte entonces, ¿por qué no hacemos métodos asíncronos de cada método que lo requiera y dejamos que la persona que llama "convierta" según sea necesario a un método sincrónico?

En cierto sentido, es como tener un método que arroja excepciones y otro que es "seguro" y no arrojará incluso en caso de error. ¿En qué punto es excesivo el codificador para proporcionar estos métodos que de otro modo se pueden convertir uno a otro?

En esto hay dos escuelas de pensamiento: una es crear múltiples métodos, cada uno de los cuales llama a otro método posiblemente privado que permite la posibilidad de proporcionar parámetros opcionales o alteraciones menores al comportamiento, como ser asíncrono. El otro es minimizar los métodos de interfaz para mostrar lo esencial, dejando que la persona que llama realice las modificaciones necesarias por sí mismo.

Si eres de la primera escuela, hay una cierta lógica en dedicar una clase a llamadas síncronas y asíncronas para evitar duplicar cada llamada. Microsoft tiende a favorecer esta escuela de pensamiento y, por convención, para mantenerse consistente con el estilo favorecido por Microsoft, usted también debería tener una versión Async, de la misma manera que las interfaces casi siempre comienzan con un "I". Permítanme enfatizar que no está mal , per se, porque es mejor mantener un estilo consistente en un proyecto en lugar de hacerlo "de la manera correcta" y cambiar radicalmente el estilo para el desarrollo que agrega a un proyecto.

Dicho esto, tiendo a favorecer la segunda escuela, que es minimizar los métodos de interfaz. Si creo que un método puede llamarse de forma asincrónica, el método para mí es asincrónico. La persona que llama puede decidir si esperar o no a que termine esa tarea antes de continuar. Si esta interfaz es una interfaz para una biblioteca, es más razonable hacerlo de esta manera para minimizar la cantidad de métodos que necesitaría desaprobar o ajustar. Si la interfaz es para uso interno en mi proyecto, agregaré un método para cada llamada necesaria en mi proyecto para los parámetros proporcionados y no hay métodos "adicionales", e incluso entonces, solo si el comportamiento del método no está cubierto por un método existente.

Sin embargo, como muchas cosas en este campo, es en gran parte subjetivo. Ambos enfoques tienen sus pros y sus contras. Microsoft también comenzó la convención de agregar letras indicativas de tipo al comienzo del nombre de la variable y "m_" para indicar que es un miembro, lo que lleva a nombres de variables como m_pUser. Mi punto es que ni siquiera Microsoft es infalible, y también puede cometer errores.

Dicho esto, si su proyecto sigue esta convención Async, le aconsejaría que lo respete y continúe con el estilo. Y solo una vez que tenga un proyecto propio, puede escribirlo de la mejor manera que mejor le parezca.

Neil
fuente
66
"En tiempo de ejecución, convertir una llamada asíncrona en una sincrónica es trivial" No estoy seguro de que sea exactamente así. En .NET, el uso de .Wait()métodos y similares puede causar consecuencias negativas, y en js, que yo sepa, no es posible en absoluto.
max630
2
@ max630 No dije que no hay problemas concurrentes a considerar, pero si inicialmente era una tarea sincrónica , es probable que no esté creando puntos muertos. Dicho esto, trivial no significa "hacer doble clic aquí para convertir a sincrónico". En js, devuelve una instancia de Promise y llama a resolver en ella.
Neil
2
Sí, es totalmente un fastidio convertir el asíncrono de nuevo a sincronización
Ewan
44
@Neil En JavaScript, incluso si llama Promise.resolve(x)y luego le agrega devoluciones de llamada, esas devoluciones de llamada no se ejecutarán de inmediato.
NickL
1
@Neil si una interfaz expone un método asincrónico, esperar que la Tarea no cree un punto muerto no es una buena suposición. Es mucho mejor que la interfaz muestre que en realidad es sincrónica en la firma del método, que una promesa en la documentación que podría cambiar en una versión posterior.
Carl Walsh
2

Imaginemos que hay una manera de permitirle llamar a las funciones de forma asíncrona sin cambiar su firma.

Eso sería genial y nadie recomendaría que cambies sus nombres.

Pero, las funciones asincrónicas reales, no solo las que esperan otra función asincrónica, sino que el nivel más bajo tiene alguna estructura específica para ellas según su naturaleza asincrónica. p.ej

public class HTTPClient
{
    public HTTPResponse GET()
    {
        //send data
        while(!timedOut)
        {
            //check for response
            if(response) { 
                this.GotResponse(response); 
            }
            this.YouCanWait();
        }
    }

    //tell calling code that they should watch for this event
    public EventHander GotResponse
    //indicate to calling code that they can go and do something else for a bit
    public EventHander YouCanWait;
}

Es los dos bits de información que necesita el código de llamada con el fin de ejecutar el código de forma asíncrona que cosas como Tasky asyncencapsular.

Hay más de una forma de hacer funciones asincrónicas, async Taskes solo un patrón integrado en el compilador a través de tipos de retorno para que no tenga que vincular manualmente los eventos

Ewan
fuente
0

Abordaré el punto principal de una manera menos C # ness y más genérica:

¿Acaso las mejores prácticas asíncronas / en espera no contradicen los principios de "buena arquitectura"?

Diría que solo depende de la elección que haga en el diseño de su API y de lo que le permita al usuario.

Si desea que una función de su API sea solo asíncrona, hay poco interés en seguir la convención de nomenclatura. Simplemente siempre devuelva la Tarea <> / Promesa <> / Futuro <> / ... como tipo de retorno, se auto documenta. Si quiere una respuesta sincronizada, aún podrá hacerlo esperando, pero si siempre lo hace, será un poco repetitivo.

Sin embargo, si realiza solo su sincronización API, eso significa que si un usuario quiere que sea asíncrono, tendrá que administrar la parte asíncrona de él mismo.

Esto puede hacer mucho trabajo extra, sin embargo, también puede dar más control al usuario sobre cuántas llamadas simultáneas permite, tiempo de espera, devoluciones, etc.

En un sistema grande con una API enorme, implementar la mayoría de ellos para que se sincronicen de manera predeterminada podría ser más fácil y más eficiente que administrar de forma independiente cada parte de su API, especialmente si comparten recursos (sistema de archivos, CPU, base de datos, ...).

De hecho, para las partes más complejas, podría tener perfectamente dos implementaciones de la misma parte de su API, una sincrónica haciendo las cosas útiles, una asincrónica confiando en la sincrónica sí maneja las cosas y solo administra la concurrencia, las cargas, los tiempos de espera y los reintentos .

Quizás alguien más pueda compartir su experiencia con eso porque me falta experiencia con tales sistemas.

Walfrat
fuente
2
@Miral Ha utilizado "llamar a un método asíncrono desde un método de sincronización" en ambas posibilidades.
Adrian Wragg
@AdrianWragg Así lo hice; mi cerebro debe haber tenido una condición de carrera. Lo arreglaré.
Miral
Es al revés; es trivial llamar a un método asíncrono desde un método de sincronización, pero es imposible llamar a un método de sincronización desde un método asíncrono. (Y donde las cosas se desmoronan por completo es cuando alguien intenta hacer esto último de todos modos, lo que puede conducir a puntos muertos). Entonces, si tuviera que elegir uno, async por defecto es la mejor opción. Desafortunadamente, también es la opción más difícil, porque una implementación asincrónica solo puede llamar a métodos asincrónicos.
Miral
(Y con esto, por supuesto, me refiero a un método de sincronización de bloqueo . Puede llamar a algo que hace un cálculo puro vinculado a la CPU de forma síncrona desde un método asíncrono, aunque debe intentar evitar hacerlo a menos que sepa que está en un contexto de trabajo en lugar de un contexto de IU, pero bloquear las llamadas que esperan inactivas en un bloqueo o para E / S o para otra operación es una mala idea)
Miral