2 puntos por GN⁺ 2023-07-18 | 1 comentarios | Compartir por WhatsApp
  • En Go hay patrones de concurrencia que resultan incómodos usando solo goroutines y canales; las corrutinas son una propuesta para ayudar a estructurar programas cediendo explícitamente el flujo de ejecución, sin paralelismo.
  • Las corrutinas se pasan el control de ejecución con resume y yield; como solo una se ejecuta a la vez, se evitan las carreras sobre datos compartidos y los puntos de cambio se vuelven puntos de sincronización.
  • Los generadores de Python y los iteradores de CLU se parecen a las corrutinas, pero la ubicación de yield está limitada; por eso, si se traslada tal cual un recorrido de árbol anidado al estilo Lua, algunos valores desaparecen.
  • coro.New de Go también puede expresarse con canales y goroutines, y coro.Pull convierte un push iterator en un pull iterator que extrae un valor por cada llamada.
  • La implementación basada en canales tardaba alrededor de 190 ns por cambio; el cambio directo en el runtime reduce eso a unos 20 ns por cambio y unos 40 ns por valor de coro.Pull, con el objetivo de evitar cuellos de botella en uso real.

Modelo de ejecución de las corrutinas

  • Una corrutina se ve como una llamada a función, pero se ejecuta en una pila distinta y no se ejecuta al mismo tiempo que otra.
    • Aunque F inicie G, G no se ejecuta de inmediato: solo lo hace cuando F le hace resume explícitamente.
    • Mientras G se ejecuta, puede devolverle el control de ejecución a F en cualquier momento con yield.
    • Cuando G retorna, se limpia, y F recibe una señal de que ya no debe hacer resume sobre G.
  • En este modelo, solo se ejecuta una corrutina a la vez, y el llamador espera en otra pila.
  • Los cambios de ejecución solo ocurren en puntos específicos del programa, por lo que varios flujos se alternan de forma coordinada.

Corrutinas vistas con un ejemplo en Lua

  • El ejemplo de Lua 5 compara si dos árboles binarios con estructuras distintas tienen la misma secuencia de valores.
    • t1 y t2 contienen 1, 2, 3, 4, 5.
    • t3 contiene 1, 2, 3, 4, 6.
  • visit(t) recorre el árbol en inorden y emite cada valor con coroutine.yield(t.value).
  • La función de comparación crea dos corrutinas visit y lee el siguiente valor alternando llamadas a coroutine.resume.
    • Si difiere si alguna corrutina terminó, o si los valores son distintos, devuelve false.
    • Si ambas terminan, devuelve true.
  • Un código Lua más idiomático obtiene con coroutine.wrap una función next que oculta el objeto corrutina.
    • Cuando la corrutina termina, la función next devuelve nil.
    • El código completo está en Gist.

Límites de los generadores de Python y los iteradores de CLU

  • Los generadores de Python se parecen a las corrutinas de Lua, pero no son el mismo modelo.
  • Si se traslada directamente el ejemplo de Lua a Python, visit(t['left']) no ejecuta el recorrido real: solo crea un objeto generator y luego lo descarta.
    • Si yield aparece en el cuerpo de la función, def visit no define una función común, sino un generador.
    • El ejemplo traducido de forma simple imprime solo el 4 del árbol y pierde 1, 2, 3, 5.
  • El código Python correcto debe iterar explícitamente sobre el generador anidado y volver a hacer yield.
    • yield from de Python 3.3 simplifica este patrón.
  • Un objeto generator de Python contiene solo el estado de una llamada a visit.
    • Los valores de las variables locales y la línea en ejecución se guardan en el objeto generator.
    • Al reanudarse, ese estado sube a la pila de llamadas y, en yield, vuelve a salir hacia el objeto generator.
    • yield solo es posible en el frame de llamada superior.
  • CLU llamaba iterator a esta abstracción y distinguía estáticamente entre iter y proc.
    • Gracias a la información de tipos, el compilador podía diagnosticar usos incorrectos, como llamar a un iterator como si fuera una función común.
    • El artículo de 1977 de Barbara Liskov y otros, “Abstraction Mechanisms in CLU”, explica que un iterator es una forma restringida de corrutina y que se implementa solo con la pila del programa.

Diferencias entre corrutinas, threads y generators

  • Los tres conceptos ofrecen alguna forma de concurrencia, pero difieren en la capacidad que brindan y en su costo.
  • Corrutinas

    • Ofrecen concurrencia sin paralelismo.
    • Si una corrutina se está ejecutando, la corrutina que la reanudó o aquella a la que cedió no se ejecuta.
    • Como los puntos de cambio son explícitos, no se producen carreras al compartir datos.
    • Cambios como una llamada a coroutine.resume o a next se vuelven puntos de sincronización y crean un happens-before edge.
    • Al programarse explícitamente sin el sistema operativo, los cambios pueden bajar aproximadamente a 10 ns o menos.
  • Threads

    • Son más potentes que las corrutinas, y esa potencia adicional es el paralelismo.
    • El costo es el overhead de scheduling, cambios de contexto más caros y la necesidad de alguna forma de preemption.
    • Un cambio típico de thread está en el orden de unos pocos microsegundos.
  • Goroutines de Go

    • En esta clasificación, se parecen más a threads baratos.
    • El runtime de Go se encarga de parte del scheduling, y los cambios están más cerca de unos cientos de ns.
    • Como los threads, ofrecen paralelismo y preemption.
    • Los nuevos lightweight threads de Java son básicamente iguales a las goroutines.
  • Generators

Casos en los que Go necesita corrutinas

  • Las bibliotecas de concurrencia existentes en Go no ofrecen directamente el patrón de corrutinas.
  • Las goroutines a menudo son suficientemente parecidas, pero por el paralelismo y la preemption pueden producir resultados distintos a los de las corrutinas.
  • La charla de Rob Pike de 2011, “Lexical Scanning in Go”, trata el diseño inicial del lexer y el parser del paquete text/template.
    • El lexer y el parser se ejecutaban en goroutines separadas y se conectaban con canales.
    • Esa estructura imitaba de forma imperfecta un par de corrutinas.
    • El lexer miraba el siguiente token con anticipación mientras el parser procesaba el token más reciente.
    • Un generator no era suficiente para un lexer que debía hacer yield de valores desde varias funciones.
    • El paralelismo de las goroutines creó carreras, y finalmente el diseño cambió para guardar el estado del lexer en un objeto.
    • Con corrutinas adecuadas, se habrían evitado las carreras y habría sido más eficiente que usar goroutines.
  • Un caso de uso futuro es el recorrido de colecciones genéricas.
    • En Go se ha discutido el soporte de range sobre funciones.
    • Esto podría impulsar a autores de colecciones y abstracciones a ofrecer funciones iterator al estilo CLU.
  • En Go, incluso hoy se pueden implementar push iterators usando valores de función.
    • Ejemplo: func (t *Tree[V]) All(yield func(v V))
    • Actualmente se puede llamar como t.All(func(v V) { fmt.Println(v) }).
    • En el futuro podría ser posible una forma como for v := range t.All.
  • El problema son los recorridos que no encajan en un único bucle for.
    • Hay casos, como comparar árboles binarios, en los que se deben intercalar dos recorridos.
    • Las corrutinas pueden convertir un push iterator como (*Tree).All en un pull iterator que devuelve un valor por llamada.

coro.New expresado en Go puro

  • Si se agregaran corrutinas a Go, debería ser posible hacerlo sin cambiar el lenguaje, y deberían poder entenderse e implementarse como código Go común
  • Un coro.New simple se expresa con canales y goroutines
    • cin entrega los valores de entrada
    • cout devuelve los valores de salida
    • resume envía un valor a cin y espera el resultado en cout
    • La nueva goroutine queda bloqueada inicialmente en <-cin, por lo que no hay oportunidad de ejecución en paralelo
  • Al agregar yield, f puede emitir un valor mientras se ejecuta, y el llamador puede volver a introducir un valor en el siguiente resume
    • yield(out) envía un valor a cout y espera la siguiente entrada en cin
    • Esto también es un par send-receive, así que no hay paralelismo
  • Este patrón de comunicación limita a la goroutine para que se comporte como una corrutina
    • En realidad es una goroutine, pero resume y yield actúan como operaciones de cambio de contexto

Ejemplo de parser de strings

  • El problema de “Storing Data in Control Flow” consiste en ejecutar func parseQuoted(read func() byte) bool en un flujo de control separado y suministrar bytes uno por uno mediante el método Write
  • Con coro.New, se puede escribir en un nivel más alto que la implementación temporal basada en canales del artículo anterior
    • Init define la función coparse
    • read hace yield de NeedMoreInput y luego devuelve el byte enviado por el llamador
    • El resultado booleano de parseQuoted(read) se convierte en BadInput o Success
    • p.resume(0) avanza hasta el primer read de parseQuoted
    • Write(c byte) se convierte en un wrapper delgado que llama a p.resume(c)
  • El código completo está en Go Playground

Ejemplo de criba de números primos

  • La criba concurrente de números primos de Doug McIlroy es un pipeline con una corrutina por cada primo p
    • Cada filtro recibe números de su vecino izquierdo y los pasa al vecino derecho si no son divisibles por p
    • El counter del extremo izquierdo suministra 2, 3, 4, ...
    • La corrutina de salida del extremo derecho lee e imprime primos y crea una nueva corrutina filtro
  • counter es una función que envuelve con coro.New un bucle que hace yield de valores
    • more bool indica si debe seguir generando
    • yield(i) emite un valor y recibe si debe continuar
  • filter(p, next) toma valores desde next(true) de la corrutina de la izquierda y hace yield(n) solo cuando n%p != 0
  • main mantiene la salida actual del pipeline en next
    • Lee un primo p
    • Imprime p
    • Agrega a la derecha del pipeline un nuevo filtro que elimina los múltiplos de p
  • La relación de llamadas entre corrutinas puede cambiar durante la ejecución
    • El primer yield de counter va a main, pero los yield posteriores van al filtro de 2
    • La primera salida de cada filtro de p va a main como el siguiente primo, y las salidas posteriores van al siguiente filtro
  • El código completo está en Go Playground

Relación entre goroutines y corrutinas

  • En rigor, el flujo de control creado aquí es una goroutine
    • Puede hacer todo lo que una goroutine común puede hacer, como esperar por mutex, canales o system calls
  • coro.New crea una goroutine que puede usar operaciones de cambio de corrutina dentro de yield y resume
  • La sentencia go crea un nuevo flujo de control concurrente y paralelo, mientras que coro.New crea un nuevo flujo de control concurrente pero no paralelo
    • Si se ejecutan 10 sentencias go, pueden ejecutarse simultáneamente 11 goroutines, incluida main
    • Si se llama 10 veces a coro.New, hay 11 flujos de control, pero el paralelismo del programa no cambia y solo se ejecuta uno a la vez
  • Qué goroutine cumple el rol de corrutina “no paralela” puede cambiar durante la ejecución
    • Es lo mismo que puede cambiar durante la ejecución qué goroutine está enviando o recibiendo por un canal

Un resume más robusto

  • El coro.New inicial entra en deadlock si se llama a resume después de que la función terminó
  • Para corregirlo, resume devuelve un bool junto con el resultado
    • true significa que el resultado vino de yield
    • Si la función retorna, resume devuelve el valor de retorno y false
    • Si se llama a resume después de que la corrutina terminó, devuelve el zero value y false
  • La variable running rastrea si f está en ejecución
    • Como resume y la corrutina se ejecutan alternadamente, compartir running no es una race
  • El ejemplo imprime "hello" true, "world" true, "done" false, "" false

Conversión a iterator con coro.Pull

  • coro.Pull convierte un push iterator en un pull iterator
  • La forma del push iterator de entrada es la siguiente
    • push func(yield func(V) bool)
    • El valor booleano de retorno de yield indica si se debe continuar
  • La forma del pull iterator objetivo es la siguiente
    • pull func() (V, bool)
    • Devuelve un valor y si la iteración terminó, como un channel receive o una búsqueda en un map
  • Para permitir interrupción anticipada, Pull devuelve no solo pull, sino también stop
  • La implementación basta con hacer un pequeño wrapper que ejecute el push iterator con coro.New
    • pull llama a resume(true)
    • stop llama a resume(false)
  • El método All del árbol se modifica para usar el resultado bool de yield
    • Propaga la interrupción anticipada encadenando con && el recorrido izquierdo, el yield del valor actual y el recorrido derecho
  • La función de comparación de árboles crea dos coro.Pull y compara los valores uno por uno
    • Con defer stop1() y defer stop2() detiene las corrutinas si termina anticipadamente
    • Si el valor o el estado de finalización difiere, devuelve false
    • Si ambos terminan, devuelve true
  • El código completo está en Go Playground

Propagación de panic y cancelación

  • Un panic ocurrido en una corrutina puede devolverse al llamador que más recientemente hizo resume de esa corrutina
    • En una goroutine normal, es difícil saber a qué goroutine notificarle y si esa goroutine está lista para recibirlo
    • En una corrutina, el llamador está bloqueado esperando en resume, por lo que está claro a quién entregarle el panic
  • La implementación envía por cout un mensaje que contiene un valor o un panic
    • El defer de la nueva corrutina captura el panic
    • El resume que está esperando vuelve a hacer panic con el mismo valor de panic
  • En el ejemplo, la corrutina hace yield de "hello" y luego hace panic con "world"
    • El panic se propaga a la goroutine main, y en el stack parece haberse producido en la llamada a resume
    • El código completo está en Go Playground
  • Se agrega una función cancel para avisarle a la corrutina cuando el llamador termina antes de tiempo
    • cancel es similar a resume, pero hace que yield haga panic en vez de devolver un valor
    • El panic de cancelación usa un wrapper de error único que satisface ErrCanceled
    • El panic provocado por cancel no se vuelve a propagar, pero si la corrutina produce otro panic durante la cancelación, sí se propaga
    • Si resume todavía no se llamó, cancel hace que f directamente no se ejecute
  • Para interrumpir iteradores, un bool explícito es más claro que un panic, por lo que Pull mantiene la interrupción basada en bool

Revisitando la criba de números primos: limpieza y propagación de errores

  • En la nueva API, counter y filter devuelven juntos una función resume y una función cancel
  • primes(n int) crea un counter y registra defer cancel()
    • Lee e imprime cada número primo
    • Cada vez que agrega un nuevo filter, también registra con defer el cancel de ese filter
  • Cuando la función obtiene n números primos y retorna, las llamadas cancel diferidas limpian las corrutinas creadas
  • Si alguna corrutina hace panic, se propaga a la corrutina que estaba esperando
    • Si es una corrutina que primes reanudó directamente con next, el panic vuelve a primes
    • Si es una corrutina que un filter reanudó con next, el panic sube por la cadena de filters hasta p := next(true) en primes
    • Después, los cancel diferidos de primes limpian las corrutinas restantes
  • El código completo está en Go Playground

Forma final de la API

  • New crea una nueva corrutina suspendida y la deja lista para ejecutar la función f
    • La nueva corrutina es una goroutine, pero no se ejecuta por sí sola
    • Solo se ejecuta mientras otra goroutine llama a resume o cancel y espera
  • resume(in) detiene la goroutine llamadora y cambia a la nueva corrutina
    • La primera llamada inicia f(in, yield)
    • resume queda bloqueado hasta que f llama a yield(out) o devuelve out
    • Cuando se llama a yield, resume devuelve out, true
    • Cuando f retorna, resume devuelve out, false
    • El siguiente resume(in) hace que el yield bloqueado devuelva in
  • cancel detiene la ejecución de f y termina la corrutina
    • Si resume nunca se llamó, f no se ejecuta
    • De lo contrario, el yield bloqueado hace panic con un error que satisface ErrCanceled
  • Si f produce un panic del que no se recupera, ese panic se mueve a la goroutine que está esperando en resume o cancel y vuelve a hacer panic con el mismo valor
    • Sin embargo, cancel no vuelve a hacer panic por el panic de cancelación que él mismo provocó
  • Si f retorna o hace panic, la corrutina deja de existir
    • Las llamadas posteriores a resume devuelven el zero value y false
    • Las llamadas posteriores a cancel simplemente retornan
  • resume, cancel y yield pueden pasarse a otras goroutines y usarse allí
    • Como resultado, qué goroutine es la “corrutina” puede cambiar dinámicamente
  • New crea una nueva goroutine, pero mantiene el invariante de que siempre hay una goroutine bloqueada en resume, cancel, yield o en el estado inicial de espera
    • Este invariante se mantiene hasta que f retorna
    • En consecuencia, coro.New crea nueva concurrencia, pero no nuevo paralelismo
  • La firma final es la siguiente
func New[In, Out any](f func(in In, yield func(Out) In) Out) (resume func(In) (Out, bool), cancel func())

Eficiencia

  • Debería ser posible definir corrutinas con una implementación en Go puro, pero para el uso real hace falta una implementación optimizada en el runtime
  • En una MacBook Pro de 2019, coro.New basado en canales tarda alrededor de 190 ns por cambio para un ida y vuelta de valores
    • En coro.Pull, eso equivale a unos 380 ns por valor
  • coro.Pull no es la forma estándar de usar iteradores
    • La forma estándar es llamar directamente al iterador, en cuyo caso no hay overhead de corrutinas
    • coro.Pull es necesario cuando se deben procesar valores de manera incremental, no en un único bucle for
  • El primer intento de optimización consiste en que el compilador marque pares send-receive y deje una pista para que el runtime los combine en una sola operación
    • El runtime de canales puede evitar el scheduler y saltar directamente a otra corrutina
    • Tarda alrededor de 118 ns por cambio y 236 ns por pulled value
    • Es 38% más rápido que la implementación original con canales
  • La segunda implementación evita por completo los canales y agrega directamente el cambio de corrutina al runtime
    • El cambio de corrutina se reduce a 3 atomic compare-and-swap
    • Uno se usa para la estructura de datos de la corrutina, otro para el scheduler status de la corrutina que se bloquea y otro para el scheduler status de la corrutina que se reanuda
    • Tarda alrededor de 20 ns por cambio y 40 ns por pulled value
    • Es aproximadamente 10 veces más rápido que la implementación original con canales
  • Un costo de 40 ns por valor se considera lo bastante bajo en términos absolutos como para no convertirse en un cuello de botella en el código que necesita coro.Pull

1 comentarios

 
GN⁺ 2023-07-18
Opiniones de Hacker News
  • Parece que mucha gente está pasando por alto el punto central aquí. Es cierto que una biblioteca de corrutinas es una forma peor y más engorrosa de manejar la concurrencia que la palabra clave go.
    El caso de uso real que introduce esta complejidad son los iteradores de función, es decir, permitir usar range sobre funciones de tipo func() (T, bool). Esto se ha discutido durante mucho tiempo en la comunidad de Go y su significado debería ser intuitivo para la mayoría de los programadores de Go.
    Este artículo aborda el siguiente problema: si se agregan iteradores de función al lenguaje, cómo escribir iteradores para usarlos en bucles for. Parte de que los iteradores push suelen ser fáciles de escribir, lo extiende a un adaptador push-pull, y ese adaptador se construye sobre corrutinas.
    Si todo eso entra, creo que usar corrutinas para algo que no sea iteración será una mala práctica, como usar canales/goroutines donde bastaría con mutexes.

    • También vale la pena señalar que, para ciertos casos de uso, las corrutinas son mucho más eficientes que las goroutines completas. Esto se debe a que al cambiar a una corrutina no hace falta un cambio de contexto ni replanificación.
      Cuando dos tareas cooperan de forma lógicamente síncrona, por ejemplo en un iterador, es mucho más eficiente ejecutarlas todas en la misma CPU. El kernel no tiene que replanificar nada ni apagar y despertar núcleos de CPU, y los datos permanecen ajustados a la caché de la CPU, lo que mejora la latencia de caché y la tasa de aciertos.
      Con goroutines esto también puede ocurrir por casualidad, pero no está garantizado, y como mínimo existe el costo de pasar por el planificador de goroutines del runtime de Go. Es rápido, pero no tanto como ejecutar otro contexto de código dentro de la misma goroutine.
      Las corrutinas permiten saber que la tarea A cambia directamente a la tarea B, por lo que el comportamiento de planificación es más predecible. En la parte final del artículo, Russ muestra que una implementación optimizada de corrutinas en el runtime es 10 veces más rápida que una emulada con goroutines.
      En Google existe internamente un parche del kernel que implementa este tipo de multithreading cooperativo, y dentro de la empresa lo llaman fibers. Existe para obtener mejor latencia y planificación predecible. Paul Turner también dio una charla en LPC hace unos 10 años explicando esa motivación: https://www.youtube.com/watch?v=KXuZi9aeGTw
    • No veo cuál es el problema de escribirlo como for { next := getNext(); ... }. Me pregunto cuál es la ventaja de escribirlo como for next := range getNext { ... }.
    • Creo que las corrutinas en Go podrían permitir usar Go como lenguaje anfitrión para definir de forma limpia simulaciones de eventos discretos. Ahora mismo, ceder el control a los actores resulta incómodo.
    • No entiendo por qué no se puede hacer range o switch sobre canales y ejecutar una goroutine que empuje valores al canal. Sigo sin estar convencido de por qué hacen falta corrutinas.
    • Aun así, al final me parece una adición tipo fregadero de cocina.
      Esta vez no parece pensamiento cuidadoso ni una buena solución 80/20, sino algo como “para hacer esto bien vamos a necesitar corrutinas, metámoslas y ya”.
      Cuando incorporaron genéricos, lo pensaron de verdad durante mucho tiempo y con profundidad, y produjeron un compromiso innovador y muy equilibrado.
      Aquí habría esperado un enfoque como “agregar una función a las goroutines para poder controlarlas en ciertas situaciones”. Habría parecido mejor que “vamos a hacerlo de forma amplia como Rust para este problema y simplemente agregarlo”.
  • He usado Go profesionalmente durante varios años, pero no quiero que termine pareciéndose a Twisted / Tornado / otros frameworks de Python.
    La palabra clave go evita bastante bien el doloroso problema del coloreado de funciones.
    En contextos de alto rendimiento, a veces uno quiere hacer cosas como particionar datos por núcleo de CPU, pero esta propuesta no resuelve ese tipo de necesidad.

    • Las corrutinas y las goroutines ocupan nichos distintos. Las goroutines ya ocupan el espacio que cubrían cosas como Twisted. Aquí no hay nada que intente traer a Go algo parecido a async/await.
      Las corrutinas ocuparían otro espacio, más cercano a los generadores de Python. En lugares donde se necesitan conectar pipelines de componentes componibles, a menudo pueden reducir mucho el uso de memoria y la complejidad del código. El diseño parece completamente síncrono.
    • En ninguna parte del artículo hay una propuesta que corresponda al problema del coloreado de funciones.
    • Si tienes que escribir una goroutine y usar Context, eso es literalmente coloreado de funciones.
    • El “problema” de otros frameworks de asincronía/threading puede ser que construyen el threading encima de iteradores/corrutinas. En este caso son ortogonales entre sí, así que probablemente no sea tan malo como parece.
      Claro que es casi seguro que algún desarrollador inteligente en algún lugar del mundo hará alguna tontería con esto, y que esa tontería se volverá muy popular. Mi predicción es abstraer goroutines y corrutinas como una sola cosa.
    • Me gustaría saber si puedes compartir pistas sobre mejores patrones de gestión de canales o frameworks.
      Normalmente el código relacionado con goroutines se siente desordenado por culpa de los canales, y no sé cómo hacerlo más “limpio”. Agradecería mucho cualquier pista sobre patrones probados como mantenibles.
  • Los sistemas multitarea nos dieron los procesos
    Pero eran demasiado pesados
    Así que aparecieron los hilos, procesos que comparten el espacio de direcciones, la tabla de archivos y algunas cosas más. El planificador puede cambiar entre hilos más fácilmente que entre procesos, y compartir datos entre hilos no requiere serialización
    Pero eso también era demasiado pesado
    Así que aparecieron los hilos en espacio de usuario. Son hilos lógicos de ejecución que el runtime ejecuta completamente en espacio de usuario. El runtime inserta hooks de planificación en todas las funciones de entrada/salida de la biblioteca estándar, o expropia los hilos lógicos con APIs del sistema como las señales de Unix. No hace falta un cambio de contexto a nivel del sistema y pueden hacerse muy pequeños
    Pero eso también era demasiado pesado
    Así que aparecieron las corrutinas. Permiten que el programador defina “hilos” lógicos que interactúan entre sí de forma cooperativa. No suponen que exista un planificador. El programador escribe directamente el event loop, o llama al event loop de una biblioteca desde un hilo lógico “real”
    Me pregunto qué vendrá después. Desde el punto de vista de los [procesos secuenciales comunicantes][1], quizá las corrutinas cooperativas sean el nivel más bajo al que se puede bajar
    [1]: https://www.cs.cmu.edu/~crary/819-f09/Hoare78.pdf

    • Las corrutinas tampoco salvan al programador del terror de tener que explicarle a la computadora todos los detalles posibles. En especial, hay que explicar que la ejecución no tarde para siempre si la entrada difiere aunque sea un poco de lo que el programador tenía en mente
    • Me sorprende un poco que no haya muchos lenguajes que paralelicen el código automáticamente tanto como sea posible, pero solo hasta el punto en que el rendimiento medido muestre beneficios
      Hace falta una semántica que admita concurrencia. Por ejemplo, las iteraciones deberían no tener orden por defecto; también depende del análisis de flujo de todo el programa, pero no veo obstáculos de principio
      ¿Microsoft no estaba investigando un lenguaje así hace algunos años? También está ParaSail, creado por alguien de la comunidad Ada. Me pregunto qué pasó con esos proyectos, si nadie los usa
    • Creo que ya no hay más abajo adonde ir. En cambio, parece haber margen para subir hacia la computación distribuida. Si no recuerdo mal, en algunas de las primeras versiones alfa de Go los canales también funcionaban entre máquinas
    • El siguiente paso podría tener una forma como “procesa esta colección de elementos de trabajo de la manera que quieras”. Si uno entrecierra los ojos, toda ejecución concurrente puede verse como una secuencia ordenada de tareas, y las tareas en sí también pueden ser colecciones. Lo mismo aplica aunque solo haya una o dos tareas
  • Pensaba que el punto central de los hilos verdes era obtener una buena planificación cooperativa sin usar una palabra clave como yield de Python
    Me pareció que la decisión de diseño de Go de insertar puntos de reanudación en puntos de llamada y ubicaciones específicas era un muy buen compromiso
    Cada vez se está exponiendo más control cercano al hardware. A partir de cierto punto no sé si simplemente se está recreando Zig. ¿Lo siguiente será un recolector de basura opcional?

    • Aquí yield y resume no son palabras clave, sino variables normales. Son solo referencias a closures comunes llamadas así con fines educativos
      Lo peculiar es la parte donde se crean y usan callbacks de cancelación. No he usado mucho Go, así que no sé si es una mejora de rendimiento para recolectar más rápido el estado de un iterador descartado, o si es necesario porque Go no hace garbage collection de una goroutine que está esperando en un canal cuando esa goroutine tiene la única referencia a ese canal
      En Lua no hace falta algo así. Las corrutinas/hilos se recolectan como cualquier otro objeto, así que cuando desaparecen todas las referencias se recolectan aunque la última operación haya sido yield y no el retorno de la función de entrada
    • El recolector de basura de Go es opcional. Si configuras la variable de entorno GOGC=off, puedes desactivar la garbage collection
      Más detalles sobre GOGC: https://dave.cheney.net/tag/gogc
  • Creo que preferiría que las corrutinas entraran como soporte del lenguaje antes que como biblioteca
    Pienso en algo como x := co func(){ var z int; for { z++; yield z } }, o una forma equivalente
    Es genial que sea posible solo con Go puro, y entiendo el atractivo de ofrecerlo como un paquete de la biblioteca estándar con un runtime optimizado, en lugar de complicar la especificación del lenguaje. Al fin y al cabo, si es posible en Go puro, otras implementaciones también pueden bootstrapping rápidamente
    Como alguien que usa Go todos los días en $work, recibiría bien cualquiera de las dos opciones, pero prefiero que esté integrado en el lenguaje. Las primitivas de concurrencia de Go siempre fueron una fortaleza, así que conviene empujar en esa dirección

    • Las corrutinas necesitan soporte del lenguaje. ¿Qué se hace si una corrutina agota la pila?
      Con una solución de biblioteca habría que matar la corrutina o matar el programa. Si quieres que la pila crezca de forma transparente, solo el código generado puede hacerlo, porque tiene que vigilar el uso de la pila y ampliarla cuando sea necesario. Tengo entendido que las goroutines tienen algo así
      Tal vez una solución de biblioteca también podría poner una página de guarda al final de la pila. Al alcanzarla, un manejador de errores podría intentar ampliar la pila. Pero probablemente no funcione si se guardó un puntero a una variable de la pila
    • Preferiría agregar a las funciones un parámetro yield para que interactúen mejor con el sistema de tipos actual
      La idea sería que una función que quiera ceder valores tenga que tener un llamado parámetro yield, y que solo pueda ceder dentro de funciones con la misma firma de cesión o dentro de funciones sin firma
      El ejemplo podría cambiar a algo como x := func(:z int) { for { z++; :- z } }. : agrega la firma de cesión, y :- cede un valor
      Una función que solo cede valores X solo necesitaría : X o : name X. Si al reanudarse recibe un valor de tipo Y, la firma cambia a :[Y] X o :[Y] name X
      La aceptación debería ser débil. En un lugar donde se espera una función que se reanuda con Y y cede X, también debería aceptarse una función que solo cede X sin reanudación
      Si las funciones especiales del paquete co ofrecen las funcionalidades de resume y New, se puede mantener el estilo de Go. La sintaxis range podría extenderse para pasar valores de reanudación con -:, y si no se pasa ningún valor, se reanudaría con el valor cero predeterminado
    • Esto es realmente horrible. No es nada intuitivo y tampoco encaja con una de las fortalezas de Go. Para escribir o leer código así habría que aprender por separado la semántica de corrutinas de Go
  • No me gustó mucho. Viendo los ejemplos, parece que hacen que el lenguaje sea mucho más difícil de leer y seguir. Claro, puede ser por mi cabeza y mis prejuicios
    Además, tampoco parece que permita hacer algo que no se pueda hacer con los canales bloqueantes o el estado actuales

    • Tal cual. Quienes defienden esto parecen no ver más allá de lo que tienen enfrente
    • No sé de qué cambio del lenguaje hablas. Esto es solo una propuesta para formalizar y hacer eficiente algo que la gente ya hace con “estado”
      He usado iteradores parecidos a los de este artículo para evitar asignaciones en rutas críticas de código. Con este enfoque, ese código sería mucho menos incómodo. Sobre todo junto con el cambio del lenguaje para iteradores con range que está por llegar
    • Tampoco es más fácil tener docenas de implementaciones de iteradores incompatibles entre sí, como en la biblioteca estándar actual
      Los canales son lentos sin mucha razón cuando no están envolviendo operaciones bloqueantes
  • Me deja un sabor amargo leer los comentarios
    Mucha gente considera que las corrutinas y los green threads son casi lo mismo, pero ambos tienen ventajas y desventajas
    Me entristece que en la comunidad de Go pueda aceptarse la ausencia de iteradores. Parece que, con el pretexto de la simplicidad, se rechaza deliberadamente cualquier función que pueda volver el lenguaje aunque sea un poco más complejo. Aun así, al menos dieron marcha atrás con su postura sobre los genéricos
    De nuevo me queda la sensación de que Go no es mi lenguaje

    • Uso Go todos los días, pero, sinceramente, los genéricos no cambiaron mucho mi código
      Ahora los uso un poco, porque en el punto de llamada la comodidad sintáctica es algo mejor. En el punto de definición, como era de esperarse, siguen siendo feos, aunque la sintaxis de Go está entre las mejores que he visto en otros lenguajes
      Al final, creo que se perdió bastante simplicidad para acallar las quejas de “no hay genéricos”. No fue un buen trato
    • No hay que confundir HN con la “comunidad de Go”. La persona que escribió este artículo es el líder del equipo de Go
      ¿Se agregará tal cual el paquete coro propuesto en el artículo? Podría ser, pero probablemente no exactamente así. ¿Se agregará algo parecido? Si pudiera apostar dinero, diría que sí
      ¿Cuánto tardará? Diría que al menos 1 año, es decir, alrededor del lanzamiento de Go 1.23 en agosto de 2024. Podría tardar un poco más. Creo que difícilmente sea mucho menos que eso
    • No dieron marcha atrás con su postura sobre los genéricos
      Los genéricos fueron aceptados por la comunidad porque son completamente retrocompatibles con el código existente y, si no los necesitas, puedes ignorarlos con seguridad
      Como era de esperarse, la mayor parte del código Go sigue haciendo eso. Salvo por varios tipos de “colecciones”, no es tan fácil encontrar usos prácticos para los genéricos. Para empezar, la mayoría del código nunca tiene que ver más de un tipo, y más de dos tipos ya es algo poco común
      Más bien, la incorporación de genéricos le mostró a una comunidad más amplia lo mucho menos necesarias que son, en la práctica, esas funciones tan elogiadas que la gente exige como “imprescindibles” en un lenguaje excelente y ampliamente aceptado
    • Los genéricos no cambiaron ni el 20% de mi base de código, y dentro de ese 20% también había bibliotecas. No sé si es una costumbre de Go o de C, pero los genéricos me parecen otra biblioteca más con la que algún programador en algún lado resolvió algún problema
    • Pensé que la comunidad de Go recibiría bien una interfaz de iteración unificada
  • Me parece bueno que recién ahora se le preste atención a lenguajes de programación como CLU
    Por otro lado, por mi experiencia usando corrutinas de .NET y C++, y los Active Object de Symbian C++ y Active Oberon, no estoy seguro de que realmente valga la pena agregar esto a Go
    Como el equipo de .NET también reconoció en BUILD este año, si pudieran volver el tiempo atrás, habría sido mejor que el runtime lo manejara al estilo de Go. Muchos desarrolladores siguen teniendo dificultades para entender async/await

  • No sé si esto sea realmente necesario. La mayoría de los casos en Go se resuelven suficientemente bien con goroutines, y para la semántica de yield/resume bastan 2 canales bloqueantes
    Parece agregar complejidad por complejidad, y tampoco está claro que realmente añada una capacidad nueva que Go no tuviera ya

    • Las goroutines y los canales agregan un overhead enorme. Usarlos como iteradores básicamente no tiene sentido
    • El artículo trata ese punto
  • Como comparación, en una presentación reciente levanté 1 millón de threads en Elixir (BEAM VM), les envié a todos el mensaje "Hello!" y luego cada thread esperaba un tiempo aleatorio de entre 0 y 2 segundos antes de devolver "Process received message !"
    Al mismo tiempo, abrí Erlang observer al lado para observar el consumo de CPU y memoria, y qué tan rápido se recuperaba después de la recolección de basura
    El mayor cuello de botella aquí es la capacidad de la terminal para seguir el ritmo, pero observer parece reflejar bastante bien la situación real
    https://www.youtube.com/watch?v=yxyYKnashR0
    Código usado: https://gist.github.com/pmarreck/4cc8f2f55a561ebce2012085a3a...
    Este tipo de funcionalidad está incorporada en Erlang, y por lo tanto también en Elixir, desde la década de 1980. Muchos habrán oído hablar del modelo de actores o de la implementación “legendaria” de Erlang, pero no sé cuántos la han visto en acción con herramientas de monitoreo abiertas al mismo tiempo
    Sería bueno que Go ofreciera este tipo de soporte a nivel de lenguaje, pero la implementación de threads de la BEAM VM es extremadamente eficiente en recursos tanto en la creación como en el consumo en runtime, y además se combina con la facilidad de concurrencia que surge de permitir solo valores inmutables, así que parece difícil que pueda alcanzarla