- 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
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
rangesobre funciones de tipofunc() (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.
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
for { next := getNext(); ... }. Me pregunto cuál es la ventaja de escribirlo comofor next := range getNext { ... }.rangeoswitchsobre canales y ejecutar una goroutine que empuje valores al canal. Sigo sin estar convencido de por qué hacen falta corrutinas.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
goevita 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.
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.
Context, eso es literalmente coloreado de funciones.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.
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
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
Pensaba que el punto central de los hilos verdes era obtener una buena planificación cooperativa sin usar una palabra clave como
yieldde PythonMe pareció que la decisión de diseño de Go de
insertar puntos de reanudación en puntos de llamada y ubicaciones específicasera un muy buen compromisoCada 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?
yieldyresumeno son palabras clave, sino variables normales. Son solo referencias a closures comunes llamadas así con fines educativosLo 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
yieldy no el retorno de la función de entradaGOGC=off, puedes desactivar la garbage collectionMá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 equivalenteEs 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ónCon 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
yieldpara que interactúen mejor con el sistema de tipos actualLa 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 firmaEl ejemplo podría cambiar a algo como
x := func(:z int) { for { z++; :- z } }.:agrega la firma de cesión, y:-cede un valorUna función que solo cede valores
Xsolo necesitaría: Xo: name X. Si al reanudarse recibe un valor de tipoY, la firma cambia a:[Y] Xo:[Y] name XLa aceptación debería ser débil. En un lugar donde se espera una función que se reanuda con
Yy cedeX, también debería aceptarse una función que solo cedeXsin reanudaciónSi las funciones especiales del paquete
coofrecen las funcionalidades deresumeyNew, se puede mantener el estilo de Go. La sintaxisrangepodría extenderse para pasar valores de reanudación con-:, y si no se pasa ningún valor, se reanudaría con el valor cero predeterminadoNo 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
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
rangeque está por llegarLos 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
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
¿Se agregará tal cual el paquete
coropropuesto 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
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
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/resumebastan 2 canales bloqueantesParece agregar complejidad por complejidad, y tampoco está claro que realmente añada una capacidad nueva que Go no tuviera ya
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