¿Por qué han vuelto las corutinas? [cerrado]

19

La mayor parte del trabajo preliminar para las corutinas ocurrió en los años 60/70 y luego se detuvo a favor de alternativas (por ejemplo, hilos)

¿Hay alguna sustancia en el renovado interés en las corutinas que ha estado ocurriendo en python y otros idiomas?

usuario1787812
fuente
99
No estoy seguro de que alguna vez se hayan ido.
Blrfl

Respuestas:

26

Las corutinas nunca se fueron, mientras que otras cosas las eclipsaron. El interés recientemente aumentado en la programación asincrónica y, por lo tanto, en las corutinas se debe en gran medida a tres factores: una mayor aceptación de las técnicas de programación funcional, conjuntos de herramientas con poco soporte para el verdadero paralelismo (¡JavaScript! ¡Python!) Y lo más importante: las diferentes compensaciones entre hilos y corutinas. Para algunos casos de uso, las corutinas son objetivamente mejores.

Uno de los paradigmas de programación más grandes de los años 80, 90 y hoy es OOP. Si observamos la historia de OOP y específicamente el desarrollo del lenguaje Simula, vemos que las clases evolucionaron a partir de las rutinas. Simula estaba destinado a la simulación de sistemas con eventos discretos. Cada elemento del sistema era un proceso separado que se ejecutaría en respuesta a eventos durante la duración de un paso de simulación, y luego permitiría que otros procesos hicieran su trabajo. Durante el desarrollo de Simula 67 se introdujo el concepto de clase. Ahora el estado persistente de la rutina se almacena en los miembros del objeto, y los eventos se desencadenan llamando a un método. Para más detalles, considere leer el documento El desarrollo de los idiomas SIMULA por Nygaard & Dahl.

Entonces, en un giro divertido, hemos estado usando corutinas todo el tiempo, solo los llamábamos objetos y programación dirigida por eventos.

Con respecto al paralelismo, hay dos tipos de lenguajes: los que tienen un modelo de memoria adecuado y los que no. Un modelo de memoria discute cosas como "Si escribo en una variable y luego leí esa variable en otro hilo, ¿veo el valor anterior o el nuevo valor o quizás un valor no válido? ¿Qué significa 'antes' y 'después'? ¿Qué operaciones están garantizadas para ser atómicas?

Crear un buen modelo de memoria es difícil, por lo que este esfuerzo simplemente nunca se ha hecho para la mayoría de estos lenguajes dinámicos de código abierto no especificados y definidos por la implementación: Perl, JavaScript, Python, Ruby, PHP. Por supuesto, todos esos lenguajes evolucionaron mucho más allá de los "scripts" para los que fueron creados originalmente. Bueno, algunos de estos lenguajes tienen algún tipo de documento de modelo de memoria, pero esos no son suficientes. En cambio, tenemos hacks:

  • Perl puede compilarse con soporte para subprocesos, pero cada subproceso contiene un clon separado del estado completo del intérprete, lo que hace que los subprocesos sean excesivamente caros. Como único beneficio, este enfoque de nada compartido evita las carreras de datos y obliga a los programadores a comunicarse solo a través de colas / señales / IPC. Perl no tiene una historia sólida para el procesamiento asíncrono.

  • JavaScript siempre ha tenido una gran compatibilidad con la programación funcional, por lo que los programadores codificarían manualmente las continuaciones / devoluciones de llamada en sus programas donde necesitaran operaciones asincrónicas. Por ejemplo, con solicitudes de Ajax o retrasos en la animación. Dado que la web es inherentemente asíncrona, hay mucho código asincrónico de JavaScript y administrar todas estas devoluciones de llamada es inmensamente doloroso. Por lo tanto, vemos muchos esfuerzos para organizar mejor esas devoluciones de llamada (Promesas) o para eliminarlas por completo.

  • Python tiene esta desafortunada característica llamada Global Interpreter Lock. Básicamente, el modelo de memoria de Python es “Todos los efectos aparecen secuencialmente porque no hay paralelismo. Solo un subproceso ejecutará código Python a la vez ”. Entonces, si bien Python tiene subprocesos, estos son simplemente tan poderosos como las rutinas. [1] Python puede codificar muchas corutinas a través de funciones generadoras conyield . Si se usa correctamente, esto solo puede evitar la mayor parte del infierno de devolución de llamada conocido de JavaScript. El sistema asíncrono / espera más reciente de Python 3.5 hace que las expresiones idiomáticas asíncronas sean más convenientes en Python e integra un bucle de eventos.

    [1]: Técnicamente, estas restricciones solo se aplican a CPython, la implementación de referencia de Python. Otras implementaciones como Jython ofrecen hilos reales que pueden ejecutarse en paralelo, pero tienen que pasar por un largo camino para implementar un comportamiento equivalente. Esencialmente: cada variable o miembro de objeto es una variable volátil , por lo que todos los cambios son atómicos y se ven inmediatamente en todos los hilos. Por supuesto, usar variables volátiles es mucho más costoso que usar variables normales.

  • No sé lo suficiente sobre Ruby y PHP para asarlos correctamente.

Para resumir: algunos de estos lenguajes tienen decisiones de diseño fundamentales que hacen que el subprocesamiento múltiple sea indeseable o imposible, lo que lleva a un enfoque más fuerte en alternativas como las rutinas y en formas de hacer que la programación asincrónica sea más conveniente.

Finalmente, hablemos sobre las diferencias entre corutinas e hilos:

Los subprocesos son básicamente como procesos, excepto que múltiples subprocesos dentro de un proceso comparten un espacio de memoria. Esto significa que los hilos no son "ligeros" en términos de memoria. Los hilos son programados de manera preventiva por el sistema operativo. Esto significa que los cambios de tareas tienen una alta sobrecarga y pueden ocurrir en momentos inconvenientes. Esta sobrecarga tiene dos componentes: el costo de suspender el estado del subproceso y el costo de cambiar entre el modo de usuario (para el subproceso) y el modo de núcleo (para el planificador).

Si un proceso programa sus propios subprocesos de manera directa y cooperativa, el cambio de contexto al modo kernel es innecesario y el cambio de tareas es comparablemente costoso a una llamada de función indirecta, como en: bastante barato. Estos hilos livianos pueden llamarse hilos verdes, fibras o corutinas dependiendo de varios detalles. Los usuarios notables de hilos / fibras verdes fueron las primeras implementaciones de Java, y más recientemente Goroutines en Golang. Una ventaja conceptual de las corutinas es que su ejecución puede entenderse en términos de flujo de control que pasa explícitamente de un lado a otro entre ellas. Sin embargo, estas rutinas no logran un verdadero paralelismo a menos que se programen en varios subprocesos del sistema operativo.

¿Dónde son útiles las corutinas baratas? La mayoría del software no necesita miles de millones de subprocesos, por lo que los subprocesos caros normales suelen estar bien. Sin embargo, la programación asíncrona a veces puede simplificar su código. Para ser utilizado libremente, esta abstracción tiene que ser lo suficientemente barata.

Y luego está la web. Como se mencionó anteriormente, la web es inherentemente asíncrona. Las solicitudes de red simplemente llevan mucho tiempo. Muchos servidores web mantienen un grupo de subprocesos lleno de subprocesos de trabajo. Sin embargo, la mayoría de las veces estos subprocesos estarán inactivos porque están esperando algún recurso, ya sea esperando un evento de E / S al cargar un archivo desde el disco, esperando hasta que el cliente haya reconocido parte de la respuesta o esperando hasta una base de datos consulta completa. NodeJS ha demostrado fenomenalmente que un diseño de servidor asíncrono y basado en eventos consecuente funciona extremadamente bien. Obviamente, JavaScript está lejos de ser el único lenguaje utilizado para aplicaciones web, por lo que también existe un gran incentivo para que otros lenguajes (que se notan en Python y C #) faciliten la programación web asincrónica.

amon
fuente
Recomiendo obtener su último párrafo para evitar el riesgo de plagio, es casi exactamente lo mismo que otra fuente que he leído. Además, a pesar de tener una sobrecarga de órdenes de magnitud menor que los subprocesos, el rendimiento de las rutinas no se puede simplificar a "una llamada de función indirecta". Vea los detalles de Boosts en implementaciones de rutina aquí y aquí .
cuando
1
@snb Con respecto a su edición sugerida: el GIL puede ser un detalle de implementación de CPython, pero el problema fundamental es que el lenguaje Python no tiene un modelo de memoria explícito que especifique la mutación paralela de datos. El GIL es un truco para esquivar estos problemas. Pero las implementaciones de Python con verdadero paralelismo deben pasar por grandes longitudes para proporcionar una semántica equivalente, por ejemplo, como se discute en el libro de Jython . Básicamente: cada variable u campo de objeto debe ser una variable volátil costosa .
amon
3
@snb Con respecto al plagio: el plagio presenta ideas falsas como propias, especialmente en un contexto académico. Es una acusación grave , pero estoy seguro de que no lo dijiste así. El párrafo "Los hilos son básicamente como procesos" simplemente reitera hechos conocidos como se enseña en cualquier clase o libro de texto sobre sistemas operativos. Dado que hay muchas maneras de expresar estos hechos de manera concisa, no me sorprende que el párrafo le resulte familiar.
amon
No he cambiado el significado dar a entender que Python hizo tener un modelo de memoria. Además, el uso de volátil no disminuye por sí solo el rendimiento volátil, simplemente significa que el compilador no puede optimizar la variable de una manera que puede asumir que la variable no cambiará sin operaciones explícitas en el contexto actual. En el mundo de Jython, esto podría importar, ya que usará la compilación JIT de VM, pero en el mundo de CPython no te preocupes por la optimización de JIT, tus variables volátiles existirían en el espacio de tiempo de ejecución del intérprete, donde no se podrían realizar optimizaciones .
cuando
7

Las corutinas solían ser útiles porque los sistemas operativos no realizaban una programación preventiva . Una vez que comenzaron a proporcionar una programación preventiva, ya no era necesario ceder el control periódicamente en su programa.

A medida que los procesadores multinúcleo se vuelven más frecuentes, se utilizan corutinas para lograr paralelismo de tareas y / o mantener alta la utilización de un sistema (cuando un hilo de ejecución debe esperar en un recurso, otro puede comenzar a ejecutarse en su lugar).

NodeJS es un caso especial, donde se utilizan corutinas para obtener acceso paralelo a IO. Es decir, se usan varios subprocesos para atender las solicitudes de E / S, pero se usa un solo subproceso para ejecutar el código javascript. El propósito de ejecutar un código de usuario en un hilo signle es evitar la necesidad de usar mutexes. Esto cae dentro de la categoría de tratar de mantener alta la utilización del sistema como se mencionó anteriormente.

dlasalle
fuente
44
Pero las corutinas no son administradas por el sistema operativo. El sistema operativo no sabe qué es una rutina, a diferencia de las fibras C ++
intercambio excesivo del
Muchos sistemas operativos tienen corutinas.
Jörg W Mittag
¿Las rutinas como Python y Javascript ES6 + no son multiproceso? ¿Cómo logran esos paralelismos de tareas?
cuando
1
@Mael El reciente "renacimiento" de las corutinas proviene de python y javascript, los cuales no logran paralelismo con sus corutinas, según tengo entendido. Es decir que esta respuesta es incorrecta, ya que el paralismo de la tarea no es la razón por la cual las corutinas están "de vuelta" en absoluto. ¿Luas tampoco son multiproceso? EDITAR: Acabo de darme cuenta de que no estabas hablando de paralelismo, pero ¿por qué me respondiste en primer lugar? Responda a dlasalle, ya que claramente están equivocados al respecto.
cuando
3
@dlasalle No, no pueden a pesar del hecho de que dice "ejecutar en paralelo" que no significa que ningún código se ejecute físicamente al mismo tiempo. GIL lo detendría y async no genera procesos separados requeridos para multiprocesamiento en CPython (GIL separados). Async funciona con rendimientos en un solo hilo. Cuando dicen "paralelo", en realidad significan que varias funciones se dirigen a otras funciones de trabajo e intercalan la ejecución de funciones. Los procesos asíncronos de Python no se pueden ejecutar en paralelo debido a impl. Ahora tengo tres idiomas que no hacen corutinas paralelas, Lua, Javascript y Python.
cuando
5

Los primeros sistemas usaban corutinas para proporcionar concurrencia principalmente porque son la forma más simple de hacerlo. Los subprocesos requieren una buena cantidad de soporte del sistema operativo (puede implementarlos a nivel de usuario, pero necesitará alguna forma de organizar que el sistema interrumpa periódicamente su proceso) y son más difíciles de implementar incluso cuando tiene el soporte .

Los subprocesos comenzaron a hacerse cargo más adelante porque, en los años 70 u 80, todos los sistemas operativos serios los admitían (y, en los 90, ¡incluso Windows!), Y son más generales. Y son más fáciles de usar. De repente, todos pensaron que los hilos eran la próxima gran cosa.

A finales de los años 90, comenzaron a aparecer grietas, y a principios de la década de 2000 se hizo evidente que había serios problemas con los hilos:

  1. consumen muchos recursos
  2. los cambios de contexto toman mucho tiempo, relativamente hablando, y a menudo son innecesarios
  3. destruyen la localidad de referencia
  4. escribir código correcto que coordina múltiples recursos que pueden necesitar acceso exclusivo es inesperadamente difícil

Con el tiempo, la cantidad de tareas que los programas normalmente deben realizar en cualquier momento ha estado creciendo rápidamente, aumentando los problemas causados ​​por (1) y (2) anteriores. La disparidad entre la velocidad del procesador y los tiempos de acceso a la memoria ha aumentado, exacerbando el problema (3). Y la complejidad de los programas en términos de cuántos y qué diferentes tipos de recursos requieren han ido creciendo, aumentando la relevancia del problema (4).

Pero al perder un poco de generalidad y poner un poco más de responsabilidad en el programador para que piense en cómo sus procesos pueden operar juntos, las rutinas pueden resolver todos estos problemas.

  1. Las rutinas requieren poco más recursos que un puñado de páginas para apilar, mucho menos que la mayoría de las implementaciones de hilos.
  2. Las rutinas solo cambian de contexto en los puntos definidos por el programador, lo que con suerte significa solo cuando es necesario. Tampoco suelen necesitar conservar tanta información de contexto (por ejemplo, valores de registro) como los subprocesos, lo que significa que cada cambio suele ser más rápido y necesita menos.
  3. Los patrones comunes de las rutinas, incluidas las operaciones de tipo productor / consumidor, transfieren datos entre rutinas de una manera que aumenta activamente la localidad. Además, los cambios de contexto generalmente solo ocurren entre unidades de trabajo que no están dentro de ellas, es decir, en un momento en que la localidad generalmente se minimiza de todos modos.
  4. Es menos probable que sea necesario el bloqueo de recursos cuando las rutinas saben que no se pueden interrumpir arbitrariamente en medio de una operación, lo que permite que las implementaciones más simples funcionen correctamente.
Jules
fuente
5

Prefacio

Quiero comenzar declarando una razón por la cual las corutinas no están resurgiendo, paralelizando. En general, las rutinas modernas no son un medio para lograr el paralelismo basado en tareas, ya que las implementaciones modernas no utilizan la funcionalidad de multiprocesamiento. Lo más parecido a eso son cosas como las fibras .

Uso moderno (por qué están de vuelta)

Las rutinas modernas se han convertido en una forma de lograr una evaluación diferida , algo muy útil en lenguajes funcionales como Haskell, donde, en lugar de iterar sobre un conjunto completo para realizar una operación, podrá realizar una evaluación de la operación solo lo necesario ( útil para conjuntos infinitos de elementos o conjuntos grandes con terminación temprana y subconjuntos).

Con el uso de la palabra clave Yield para crear generadores (que en sí mismos satisfacen parte de las necesidades de evaluación diferida) en lenguajes como Python y C #, en la implementación moderna no solo eran posibles, sino posibles sin una sintaxis especial en el lenguaje mismo. (aunque Python finalmente agregó algunos bits para ayudar). Las co-rutinas ayudan con la evacuación diferida con la idea de futuros s donde si no necesita el valor de una variable en ese momento, puede retrasar su adquisición hasta que solicite explícitamente ese valor (lo que le permite usar el valor y evaluarlo perezosamente en un momento diferente al de la instanciación).

Sin embargo, más allá de la evaluación perezosa, especialmente en la esfera web, estas co rutinas ayudan a solucionar el infierno de devolución de llamadas . Las rutinas se vuelven útiles en el acceso a la base de datos, las transacciones en línea, la interfaz de usuario, etc., donde el tiempo de procesamiento en la máquina del cliente no dará como resultado un acceso más rápido a lo que necesita. El enhebrado podría completar lo mismo, pero requiere mucha más sobrecarga en esta esfera, y en contraste con las rutinas, en realidad son útiles para el paralelismo de tareas .

En resumen, a medida que el desarrollo web crece y los paradigmas funcionales se fusionan más con los lenguajes imperativos, las rutinas se han convertido en una solución para los problemas asincrónicos y la evaluación perezosa. Las rutinas llegan a espacios problemáticos donde el enhebrado multiproceso y el enhebrado en general son innecesarios, inconvenientes o imposibles.

Ejemplo moderno

Las rutinas en lenguajes como Javascript, Lua, C # y Python derivan sus implementaciones mediante funciones individuales que dan el control del hilo principal a otras funciones (nada que ver con las llamadas al sistema operativo).

En este ejemplo de Python , tenemos una divertida función de Python con algo llamado awaitdentro de ella. Esto es básicamente un rendimiento, que produce ejecución al loopque luego permite que se ejecute una función diferente (en este caso, una factorialfunción diferente ). Tenga en cuenta que cuando dice "Ejecución paralela de tareas" que es un nombre inapropiado, en realidad no se está ejecutando en paralelo, su ejecución de la función de entrelazado mediante el uso de la palabra clave esperar (lo que se debe tener en cuenta es solo un tipo especial de rendimiento)

Permiten rendimientos de control únicos, no paralelos, para procesos concurrentes que no son tareas paralelas , en el sentido de que estas tareas no operan nunca al mismo tiempo. Las rutinas no son hilos en las implementaciones de lenguaje moderno. La implementación de todos estos lenguajes de las co-rutinas se derivan de estas llamadas de rendimiento de función (que el programador tiene que poner manualmente en sus rutinas co).

EDITAR: C ++ Boost coroutine2 funciona de la misma manera, y su explicación debería dar una mejor visión de lo que estoy hablando con yeilds, mira aquí . Como puede ver, no hay un "caso especial" con las implementaciones, cosas como las fibras de refuerzo son la excepción a la regla e incluso requieren una sincronización explícita.

EDIT2: dado que alguien pensó que estaba hablando del sistema basado en tareas C #, no lo estaba. Estaba hablando del sistema de Unity y las ingeniosas implementaciones de C #

cuando
fuente
@ T.Sar Nunca dije que C # tuviera ninguna rutina "natural", tampoco C ++ (podría cambiar) ni Python (y todavía las tenía), y los tres tienen implementaciones de rutina. Pero todas las implementaciones de C # de corutinas (como las de la unidad) se basan en el rendimiento, como describí. Además, su uso de "hack" aquí no tiene sentido, supongo que cada programa es un hack porque no siempre se definió en el lenguaje. De ninguna manera estoy mezclando el "sistema basado en tareas" de C # con nada, ni siquiera lo mencioné.
cuando
Sugeriría que su respuesta sea un poco más clara. C # tiene el concepto de esperar instrucciones y un sistema de paralelismo basado en tareas: usar C # y esas palabras al dar ejemplos en Python sobre cómo Python no es realmente realmente paralelo puede causar mucha confusión. Además, elimine su primera oración: no es necesario atacar directamente a otros usuarios en una respuesta como esa.
T. Sar - Restablece a Monica el