¿Se utiliza una estructura de pila para procesos asíncronos?

10

Esta pregunta tiene una excelente respuesta de Eric Lippert que describe para qué se utiliza la pila. Durante años, he sabido, en términos generales, qué es la pila y cómo se usa, pero partes de sus respuestas me hacen preguntarme si esta estructura de pila se usa menos hoy en día, donde la programación asincrónica es la norma.

De su respuesta:

La pila es parte de la reificación de la continuación en un lenguaje sin rutinas.

Específicamente, la parte sin corutinas de esto me tiene preguntándome.

Él explica un poco más aquí:

Las corutinas son funciones que pueden recordar dónde estaban, ceder el control a otra corutina por un tiempo y luego reanudar donde se quedaron más tarde, pero no necesariamente inmediatamente después de los rendimientos de corutina llamados. Piense en "retorno de rendimiento" o "espera" en C #, que debe recordar dónde estaban cuando se solicita el siguiente elemento o se completa la operación asincrónica. Los idiomas con corutinas o características de lenguaje similares requieren estructuras de datos más avanzadas que una pila para implementar la continuación.

Esto es excelente en lo que respecta a la pila, pero me deja con una pregunta sin respuesta sobre qué estructura se usa cuando una pila es demasiado simple para manejar estas características del lenguaje que requieren estructuras de datos más avanzadas.

¿La pila desaparece a medida que avanza la tecnología? ¿Qué lo reemplaza? ¿Es un tipo híbrido de cosas? (por ejemplo, ¿mi programa .NET usa una pila hasta que llega a una llamada asíncrona y luego cambia a otra estructura hasta que se completa, en cuyo punto la pila se desenrolla a un estado en el que puede estar seguro de los siguientes elementos, etc.? )

Que estos escenarios son demasiado avanzados para una pila tiene mucho sentido, pero ¿qué reemplaza a la pila? Cuando me enteré de esto hace años, la pila estaba allí porque era ultrarrápida y liviana, una pieza de memoria asignada en la aplicación lejos del montón porque admitía una administración altamente eficiente para la tarea en cuestión (¿juego de palabras?). Que ha cambiado

jleach
fuente

Respuestas:

14

¿Es un tipo híbrido de cosas? (por ejemplo, ¿mi programa .NET usa una pila hasta que llega a una llamada asíncrona y luego cambia a otra estructura hasta que se completa, en cuyo punto la pila se desenrolla a un estado en el que puede estar seguro de los siguientes elementos, etc.? )

Básicamente sí.

Supongamos que tenemos

async void MyButton_OnClick() { await Foo(); Bar(); }
async Task Foo() { await Task.Delay(123); Blah(); }

Aquí hay una explicación extremadamente simplificada de cómo se reifican las continuaciones. El código real es considerablemente más complejo, pero esto transmite la idea.

Haces clic en el botón. Un mensaje está en cola. El bucle de mensajes procesa el mensaje y llama al controlador de clics, colocando la dirección de retorno de la cola de mensajes en la pila. Es decir, lo que sucede después de que se realiza el controlador es que el bucle de mensajes debe seguir ejecutándose. Entonces la continuación del controlador es el ciclo.

El controlador de clics llama a Foo (), colocando la dirección de retorno de sí mismo en la pila. Es decir, la continuación de Foo es el resto del controlador de clics.

Foo llama a Task.Delay, colocando la dirección de retorno de sí mismo en la pila.

Task.Delay hace lo que sea necesario para devolver inmediatamente una tarea. La pila está abierta y estamos de vuelta en Foo.

Foo comprueba la tarea devuelta para ver si se ha completado. No lo es. La continuación de la espera es llamar a Blah (), por lo que Foo crea un delegado que llama a Blah (), y firma ese delegado como la continuación de la tarea. (Acabo de hacer una pequeña declaración errónea; ¿lo entendiste? Si no, lo revelaremos en un momento).

Luego, Foo crea su propio objeto Tarea, lo marca como incompleto y lo devuelve a la pila al controlador de clics.

El controlador de clics examina la tarea de Foo y descubre que está incompleta. La continuación de la espera en el controlador es llamar a Bar (), por lo que el controlador de clic crea un delegado que llama a Bar () y lo establece como la continuación de la tarea devuelta por Foo (). Luego devuelve la pila al bucle de mensajes.

El bucle de mensajes sigue procesando mensajes. Finalmente, la magia del temporizador creada por la tarea de retraso hace lo suyo y publica un mensaje en la cola que dice que la continuación de la tarea de retraso ahora se puede ejecutar. Entonces, el bucle de mensajes llama a la continuación de la tarea, poniéndose en la pila como de costumbre. Ese delegado llama a Blah (). Blah () hace lo que hace y regresa a la pila.

Ahora que pasa? Aquí está la parte difícil. La continuación de la tarea de retraso no solo llama a Blah (). También tiene que activar una llamada a Bar () , ¡pero esa tarea no sabe sobre Bar!

Foo en realidad creó un delegado que (1) llama a Blah () y (2) llama a la continuación de la tarea que Foo creó y devolvió al controlador de eventos. Así es como llamamos a un delegado que llama a Bar ().

Y ahora hemos hecho todo lo que teníamos que hacer, en el orden correcto. Pero nunca dejamos de procesar mensajes en el bucle de mensajes durante mucho tiempo, por lo que la aplicación siguió respondiendo.

Que estos escenarios son demasiado avanzados para una pila tiene mucho sentido, pero ¿qué reemplaza a la pila?

Un gráfico de objetos de tareas que contienen referencias entre sí a través de las clases de cierre de los delegados. Esas clases de cierre son máquinas de estado que realizan un seguimiento de la posición de la espera ejecutada más recientemente y los valores de los locales. Además, en el ejemplo dado, una cola de acciones de estado global implementada por el sistema operativo y el bucle de mensajes que ejecuta esas acciones.

Ejercicio: ¿cómo supones que todo esto funciona en un mundo sin bucles de mensajes? Por ejemplo, aplicaciones de consola. esperar en una aplicación de consola es bastante diferente; ¿Puedes deducir cómo funciona a partir de lo que sabes hasta ahora?

Cuando me enteré de esto hace años, la pila estaba allí porque era ultrarrápida y liviana, una pieza de memoria asignada en la aplicación lejos del montón porque admitía una administración altamente eficiente para la tarea en cuestión (¿juego de palabras?). Que ha cambiado

Las pilas son una estructura de datos útil cuando las vidas de las activaciones de métodos forman una pila, pero en mi ejemplo las activaciones del controlador de clic, Foo, Bar y Blah no forman una pila. Y, por lo tanto, la estructura de datos que representa ese flujo de trabajo no puede ser una pila; más bien es un gráfico de tareas y delegados asignados al montón que representa un flujo de trabajo. Las esperas son los puntos en el flujo de trabajo donde no se puede avanzar más en el flujo de trabajo hasta que se haya completado el trabajo iniciado anteriormente; mientras esperamos, podemos ejecutar otro trabajo que no dependa de que esas tareas iniciadas en particular se hayan completado.

La pila es solo una matriz de cuadros, donde los cuadros contienen (1) punteros al centro de las funciones (donde ocurrió la llamada) y (2) valores de variables locales y temps. Las continuaciones de tareas son lo mismo: el delegado es un puntero a la función y tiene un estado que hace referencia a un punto específico en el medio de la función (donde ocurrió la espera), y el cierre tiene campos para cada variable local o temporal . Los marcos simplemente ya no forman una buena matriz ordenada, pero toda la información es la misma.

Eric Lippert
fuente
1
Muy servicial, gracias. Si pudiera marcar ambas respuestas como aceptadas, lo haría, pero como no puedo, las dejaré en blanco (pero no quería que nadie pensara que no se apreciaba el momento de responder)
Jleach
3
@ jdl134679: Sugeriría que marque algo como respuesta si considera que su pregunta ha sido respondida; eso envía una señal de que las personas deberían venir aquí si quieren leer una buena respuesta en lugar de escribir una. (Por supuesto, siempre se recomienda escribir buenas respuestas). No me importa quién obtenga la marca de verificación.
Eric Lippert el
8

Appel escribió que una vieja recolección de basura de papel puede ser más rápida que la asignación de la pila . Lea también su libro Compilación con continuaciones y el manual de recolección de basura . Algunas técnicas de GC son (intuitivamente) muy eficientes. El estilo de continuación de paso define una transformación canónica de todo el programa (la transformación CPS ) para deshacerse de las pilas (reemplazando conceptualmente los marcos de llamadas con cierres asignados en el montón , en otras palabras "reificando" marcos de llamadas individuales como "valores" u "objetos" individuales )

Pero la pila de llamadas todavía se usa ampliamente, y los procesadores actuales tienen hardware dedicado (registro de pila, maquinaria de caché, ...) dedicado a las pilas de llamadas (y eso es así porque la mayoría de los lenguajes de programación de bajo nivel, especialmente C, son más fáciles de usar). implementar con una pila de llamadas). Nótese también que las pilas son caché amable (y lo que importa una gran cantidad de rendimiento).

Prácticamente hablando, las pilas de llamadas todavía están aquí. Pero ahora tenemos muchos de ellos, y a veces la pila de llamadas se divide en muchos segmentos más pequeños (por ejemplo, de unas pocas páginas de 4Kbytes cada uno), que a veces se recolectan basura o se asignan en montón. Estos segmentos de la pila podrían organizarse en alguna lista vinculada (o alguna estructura de datos más compleja, cuando sea necesario). Por ejemplo, los compiladores de GCC tienen una -fsplit-stackopción (notablemente útil para Go, y sus "goroutines" y sus "procesos asíncronos"). Con las pilas divididas, puede tener miles de pilas (y las co-rutinas se vuelven más fáciles de implementar) formadas por millones de pequeños segmentos de pila, y el "desenrollado" de la pila puede ser más rápido (o al menos casi tan rápido como con una sola porción) apilar).

(en otras palabras, la distinción entre pila y montón es borrosa, pero puede requerir la transformación de todo el programa o cambiar incompatiblemente la convención de llamada y el compilador)

Vea también this & that y muchos artículos (por ejemplo, this ) que tratan la transformación de CPS. Lea también sobre ASLR y call / cc . Lea (y STFW) más sobre las continuaciones .

Las implementaciones de .CLR y .NET pueden no tener una transformación de GC y CPS de última generación, por muchas razones pragmáticas. Es una compensación relacionada con las transformaciones de todo el programa (y la facilidad de usar rutinas C de bajo nivel y tener un tiempo de ejecución codificado en C o C ++).

Chicken Scheme está utilizando la pila de la máquina (o C) de una manera poco convencional con la transformación CPS: cada asignación ocurre en la pila, y cuando se vuelve demasiado grande, un paso generacional de copia y reenvío GC pasa a mover los valores asignados de la pila reciente (y probablemente el continuación actual) al montón, y luego la pila se reduce drásticamente con un gran setjmp.


Lea también SICP , Pragmática del lenguaje de programación , el Libro del Dragón , Lisp In Small Pieces .

Basile Starynkevitch
fuente
1
Muy servicial, gracias. Si pudiera marcar ambas respuestas como aceptadas, lo haría, pero como no puedo, las dejaré en blanco (pero no quería que nadie pensara que no se apreciaba el momento de responder)
Jleach