Thread-per-core (un hilo por núcleo)
(without.boats)- El debate sobre los runtimes async de Rust tiene menos que ver con si hay “hilos del SO por núcleo” y más con la elección entre equilibrar la carga mediante work-stealing (robo de trabajo) o reducir el movimiento de datos con un enfoque share-nothing
- async Rust ya cumple las condiciones centrales de thread-per-core: concurrencia en espacio de usuario e I/O asíncrona; el punto pendiente es cuánto se puede evitar compartir estado entre núcleos
- El work-stealing intenta reducir el tiempo ocioso de los hilos para mejorar la latencia de cola (tail latency) y el uso de CPU, pero en Rust el estado que atraviesa puntos de yield debe ser seguro entre hilos, lo que implica la restricción
Send - share-nothing puede reducir la latencia al mantener los datos en la caché de un núcleo específico, pero la dificultad cambia mucho entre casos donde es fácil particionar el estado, como un almacén clave-valor, y casos que requieren transacciones o cambios atómicos
- El paper de Enberg compara share-nothing con shared-state, pero como ninguno de los dos usa work-stealing, es difícil conectar directamente sus resultados con una crítica a los ejecutores con work-stealing de Rust
El núcleo del debate sobre los runtimes async de Rust
- En la comunidad de Rust sigue el debate sobre la elección de runtimes async importantes como tokio de usar por defecto un ejecutor multihilo y equilibrar dinámicamente varias tareas mediante work-stealing
- Algunos usuarios critican que este valor por defecto perjudica la experiencia de escribir código por las restricciones
Send + 'staticoSend + Sync + 'static - También hay quienes prefieren servidores de un solo hilo, pero aquí se parte del supuesto de que un sistema Rust quiere usar tiempo de uno o más núcleos de CPU
- La alternativa llamada “thread-per-core” suele esperarse que sea más rápida y más fácil de implementar, pero es difícil obtener al mismo tiempo rendimiento y facilidad de implementación
La confusión que crea el nombre “thread-per-core”
- Los ejecutores async multihilo existentes también entran, en un sentido amplio, en thread-per-core
- Crean hilos del SO por núcleo
- Programan sobre esos hilos una cantidad de tareas mucho mayor que el número de núcleos
- Pekka Enberg resume thread-per-core como la combinación de tres ideas
- Manejar la concurrencia en espacio de usuario en lugar de usar costosos hilos del kernel
- Usar I/O asíncrona para que los hilos por núcleo no se bloqueen
- Particionar los datos entre núcleos de CPU para eliminar costos de sincronización y movimiento de datos entre cachés de CPU
- Enberg considera que las dos primeras son importantes para construir sistemas de alto throughput, pero que la tercera quizá solo sea necesaria en máquinas multinúcleo muy grandes
- Si se usa async Rust, las dos primeras condiciones —concurrencia en espacio de usuario e I/O asíncrona— ya están cumplidas
- Por lo tanto, el centro del debate no es thread-per-core en sí, sino qué optimización elegir entre work-stealing y share-nothing
El objetivo y el costo del work-stealing
- Work-stealing es una optimización que hace que todos los hilos sigan teniendo trabajo por hacer, reduciendo la latencia de cola
- En sistemas reales, cada tarea requiere una cantidad distinta de trabajo
- Una solicitud HTTP puede requerir mucho más trabajo que otra
- Aunque al inicio el trabajo se reparta de forma equilibrada entre hilos, las diferencias impredecibles entre tareas pueden hacer que la carga cambie con el tiempo
- Bajo carga máxima, algunos hilos pueden recibir más trabajo del que pueden procesar, mientras otros quedan ociosos
- tokio, async-std y smol implementan work-stealing con el objetivo de reducir la latencia de cola y mejorar el uso de CPU
- El costo es que una tarea puede detenerse en un hilo y luego reanudarse en otro
- El estado usado a través de puntos de yield debe ser seguro entre hilos
- En la API de Rust, esto aparece como la necesidad de que el future sea
Send - Si no se entiende con claridad el estado del sistema, es difícil decidir cómo garantizar
Send
- Cuando el estado se mueve a otro hilo, aparecen costos de sincronización y fallos de caché, lo que choca con el principio share-nothing, donde cada CPU solo maneja su propio estado
La lógica de rendimiento de share-nothing
- Share-nothing es un diseño que busca reducir la latencia de cola manteniendo los datos en la caché más rápida de un núcleo de CPU específico, en lugar de una caché lenta compartida por varios núcleos
- El paper de Enberg The Impact of Thread-Per-Core Architecture on Application Tail Latency benchmarkea un almacén clave-valor share-nothing y una estructura memcached de shared-state, y muestra mejoras en la latencia de cola
- La forma en que en la comunidad de Rust se cita este paper de manera simplista, como “71% de mejora de rendimiento”, es superficial y poco útil
- El almacén clave-valor de Enberg divide estado y conexiones para lograr una arquitectura share-nothing
- Particiona el keyspace entre varios hilos mediante una función hash
- Particiona las conexiones TCP entrantes entre hilos con
SO_REUSEPORT - Enruta solicitudes mediante canales de paso de mensajes desde el hilo que administra la conexión hacia el hilo que administra el keyspace correspondiente
- memcached comparte entre todos los hilos la propiedad del keyspace particionado, y cada partición está protegida por un mutex
- Los resultados de Enberg muestran que una estructura que usa canales puede lograr menor latencia de cola que una basada en mutex
- Esto puede interpretarse como una reducción de fallos de caché, porque se accede repetidamente a la misma partición y esta permanece en la caché de un núcleo
- Aun así, es difícil decir que un diseño que evita el movimiento de datos con funciones avanzadas del kernel y una estructura cuidadosamente planificada sea más fácil de implementar que envolver los datos en un mutex
Casos donde particionar el estado es fácil y casos donde es difícil
- Un almacén clave-valor es un caso que encaja bien con share-nothing, porque es fácil dividir el estado de la aplicación entre varios hilos
- Si en una aplicación más compleja hay que cambiar el estado de varias particiones de forma transaccional o atómica, implementarlo correctamente requiere mucha más atención
- La defensa de share-nothing se parece en algunos aspectos al entusiasmo excesivo que hubo alrededor de las bases de datos con consistencia eventual
- El rendimiento puede mejorar
- Pero para evitar bugs causados por inconsistencias de datos se necesita un diseño cuidadoso
La distancia entre el paper de Enberg y el debate sobre work-stealing en Rust
- Tanto la implementación de Enberg como memcached no usan work-stealing
- Por eso es difícil conectar directamente los principales resultados de rendimiento del paper con la arquitectura de work-stealing de Rust
- Si se agregara work-stealing a la arquitectura de Enberg, podría aumentar en cierta medida el movimiento de datos, pero también podría ser una forma de mejorar el uso de CPU
- En el caso de agregar work-stealing a memcached, se considera difícil imaginar por qué no ayudaría
- La implementación del paper está diseñada para repartir el trabajo de forma anticipada y equilibrada mediante una partición balanceada del keyspace y
SO_REUSEPORT - En entornos reales pueden aparecer desequilibrios dinámicos
- Una hot key recibe más lecturas y escrituras, aumentando la carga del hilo que administra ese keyspace
- Algunas conexiones realizan más solicitudes que otras, aumentando la carga del hilo que administra esa conexión
- Se entiende que el benchmark del paper no reproduce estas condiciones de desequilibrio, porque cada conexión realiza una cantidad constante de trabajo sobre claves aleatorias
- Incluso dentro de un sistema share-nothing es posible diseñar formas de mitigar el desequilibrio, como cachear una hot key en particiones adicionales
- Algunas tareas también podrían fijarse a núcleos específicos para evitar mover estado, mientras se usa otra forma de work-stealing como optimización
Conclusión práctica
- Si un sistema se diseña cuidadosamente para evitar el movimiento de datos entre cachés de CPU, puede obtener mejor rendimiento que si no lo hace
- Pero si la mayor queja es tener que agregar restricciones
Senda generics, es difícil creer que se esté haciendo ese tipo de ingeniería de rendimiento detallada - Si el sistema va a usar shared state de todos modos, es difícil sostener que el work-stealing no mejore el uso de CPU bajo carga
1 comentarios
Opiniones en Hacker News
Personalmente, este artículo me da la sensación de que se pierde el panorama general y se queda atrapado en los detalles.
El punto central del debate no es el ejecutor con work stealing de un hilo por núcleo, sino si async/await en Rust es una buena abstracción para eso.
Cuanto más código async uso, más siento que la abstracción tiene fugas y que es difícil programar contra ella.
El modelo alternativo de concurrencia que la gente quiere es concurrencia estructurada mediante corrutinas con stack y canales sobre un ejecutor con work stealing.
Hasta que alguien implemente eso y lo compare con async/await y el modelo basado en futures, será difícil tener una discusión productiva; quienes odian async lo evitarán, y quienes no se preocupan por
Send + Sync + 'staticlo seguirán usando.Creo que los casos que necesitan esa escala son muy raros en la mayoría de las situaciones.
Estos artículos solo dicen “los hilos del kernel son caros” y lo dejan pasar como si fuera una verdad esencial, pero en realidad no lo es.
Si el trabajo no consiste en generar continuamente tareas que no hacen nada, es muy probable que el overhead de los “hilos reales” sea pequeño, y la simplicidad que se obtiene a cambio es enorme.
La cita que trató withoutboats es una parte del artículo enlazado que critica específicamente el multithreading por defecto y el work stealing.
[1] https://www.reddit.com/r/rust/comments/16p47f1/the_state_of_...
Este artículo simplemente trata otro debate, no el debate que yo hubiera querido que escribiera.
También se pueden crear servidores al estilo Erlang que posean tipos mutables y se comuniquen por canales, o seguir adelante con
Arc.Rust te da el poder de hacer ambas cosas.
Las corrutinas con stack no parecen tener mucho sentido en ese punto, porque ahí simplemente se podrían usar hilos separados.
El problema original que el modelo de un hilo por núcleo intentaba resolver hace unos 15 años era lograr escalabilidad y eficiencia de cómputo en servidores multinúcleo de propósito general.
A diferencia de algunas afirmaciones, el modelo de un hilo por núcleo estaba pensado explícitamente para optimizar cargas de trabajo centradas en CPU, y aunque después requirió un manejo de I/O más sofisticado, resultó sobresalir también en cargas centradas en I/O de alto throughput.
Al ver artículos como este, parece que se están repitiendo rápidamente varios errores de diseño de software que existían cuando se introdujo la arquitectura de un hilo por núcleo.
Para ser justos, la ciencia de la computación relacionada con un hilo por núcleo proviene principalmente de HPC y está poco documentada.
Este artículo se enfoca en un problema difícil de la arquitectura de un hilo por núcleo: el balanceo de trabajo entre núcleos.
El modelo básico tiene cuatro variantes: push/pull de datos/carga, y el work stealing es, en esencia, un modelo de pull de carga (load pull).
Este enfoque solo tiene poco overhead cuando casi no hace falta usarlo, es decir, cuando existe un equilibrio natural, algo poco común en problemas reales.
En cargas de trabajo más interesantes, donde los desbalances dinámicos de carga entre núcleos son frecuentes, el work stealing se vuelve un cuello de botella de rendimiento por el overhead de coordinación.
Aun así, como es fácil de entender, se sigue usando en cargas de trabajo para las que encaja, pero no se generaliza bien.
Entre las cargas de trabajo raras que el artículo no menciona, probablemente haya casos en los que sea la mejor opción.
El modelo que parece estar ganando más impulso hoy es el de push de datos (data push), que es menos intuitivo, pero requiere mucha menos coordinación entre hilos.
Este modelo tampoco encaja con todas las cargas de trabajo, pero se generaliza bien para la mayoría de las cargas comunes.
La arquitectura de un hilo por núcleo llegó para quedarse. En escalabilidad y eficiencia no se le puede ganar.
Sin embargo, la mayoría de los ingenieros de software carecen de intuición sobre cómo se ve un diseño moderno e idiomático de un hilo por núcleo, y eso empeora porque hay pocos artículos o papers que traten el tema en profundidad.
Por la biografía del perfil y esta explicación, sospecho que ese no es el tipo de aplicaciones con las que trabajas principalmente.
Me gustaría ver enlaces a literatura relacionada, si tienes.
Estoy de acuerdo con la frase: “Decir que está centrado en I/O en realidad significa que, al escribirlo en Rust, no hay suficiente trabajo como para saturar un solo core. Entonces, naturalmente, escribe un sistema de un solo thread”.
Muchas de las aplicaciones que uso son daemons que reaccionan a eventos en segundo plano, y si las hago de un solo thread puedo eliminar el overhead de
ArcyMutex.Ese overhead, en ese punto, es más que nada una carga sintáctica, pero facilita la depuración y el mantenimiento.
Algo que me gusta de Rust es que pagas solo por lo que necesitas.
El artículo original al que responde este texto critica que tokio y otras bibliotecas async dificultan volver a una arquitectura simple de un solo thread.
Aunque hay algo de exageración, en general estoy de acuerdo con esa crítica.
Hacer que los valores por defecto sean más complejos porque son mejores para aplicaciones de alto throughput parece ir en contra de los ideales de Rust.
No están limitados por throughput; más bien pasan la mayor parte del tiempo inactivos y, cuando aparece trabajo, intentan terminar rápido para reducir el uso de recursos del sistema.
Salvo que ocasionalmente haya una enorme avalancha de trabajo y en ese momento la latencia no sea muy importante, usar más de un thread solo agrega complejidad y overhead sin beneficios.
En un sistema operativo, todos los servicios del sistema tienen que atender solicitudes IPC de forma concurrente, pero la mayoría las procesa en un solo thread para reducir el consumo total de CPU.
En un dispositivo de 4 cores, hacer que decenas de servicios usen un thread por core desperdicia CPU y RAM.
En la API de tokio se llama
LocalSet.https://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html...
Aunque un solo thread en una sola CPU sea suficiente, igual podrías querer concurrencia.
ArcyMutex, acabarías usandoRcyRefCell, y creo que en términos de código es igual de complejo y verboso.Entiendo que sea menos eficiente, pero en el caso descrito, ¿no sería de todos modos despreciable el costo extra de unas cuantas operaciones atómicas?
Sobre la cita, estoy de acuerdo en que decir “el pecado original de la programación async en Rust fue hacer que el valor por defecto fuera multithread…
Send + 'static, o peor,Send + Sync + 'static, mata la alegría de usar Rust” suena demasiado melodramático.No creo que quitar
Send + Synchaga una diferencia tan grande.Lo que más molesta es
'static, y eso no se debe al work stealing.Lo que quiero es concurrencia con scope.
Por ejemplo, algo como <https://github.com/tokio-rs/tokio/issues/2596>.
Otra cosa que realmente odio del async actual en Rust es la falta de instrumentación.
En la empresa tenemos un problema de producción donde algunas tasks simplemente se quedan detenidas, y quisiera poder hacer el equivalente a
gdb; thread apply all bt.Al menos espero que entre <https://github.com/tokio-rs/tokio/issues/5638>.
Ya existe de forma experimental, pero en mi experiencia a veces produce pánicos.
Hoy, de hecho, estoy escribiendo un PR para probar la versión experimental al recibir SIGTERM.
Como de todos modos el proceso se está cerrando, considero que no importa si crashea.
Ninguna de estas quejas se resuelve eliminando el work stealing.
Podría seguir enumerando cosas, pero eliminar el work stealing no ayudaría en casi nada.
Ni siquiera necesitas un depurador: envías SIGQUIT a la JVM, vuelca a stderr los stack traces de todos los threads y sigue ejecutándose.
También incluye qué locks tiene tomado cada thread o por cuáles está esperando.
Cada vez que uso otro lenguaje extraño esta función.
También sirve para hacer profiling ad hoc en producción.
Tomas varias snapshots y luego usas
grep/sed/sort/uniqpara encontrar hotspots.Hay que insertar manualmente
r.set_location(file!(), line!());antes de cada punto deawait, pero varias veces nos ayudó a explicar por qué el sistema parecía estar colgado.[1] https://github.com/antialize/tokio-tasks/blob/main/src/run_t... tiene
set_location(), ytask.rstienelist_tasks().Este problema no tiene una respuesta correcta; todo depende del caso de uso.
Al final, es una cuestión de cargas de trabajo centradas en I/O frente a centradas en CPU, y de cuánto perjudican cosas como la expulsión de caché o la contención de locks.
Si estás montando en un servidor virtual compartido un servidor HTTP que se comunica con una base de datos externa y tiene lógica de negocio liviana en medio, el robo de trabajo y la reutilización de threads tienen sentido intuitivamente.
Por supuesto, siempre hay que hacer benchmarks.
En cambio, si estás creando una base de datos o un sistema similar, y bajo carga una alta concurrencia y muchos cambios de contexto provocan expulsión de caché y contención por todos lados, la vas a pasar mal.
En ese caso, thread por core tiene mucho sentido, y puede que el framework async en sí ni siquiera tenga sentido.
No hay una respuesta dogmática sobre qué es “mejor”.
Hay que perfilar la aplicación.
Como dije antes, siento que todo el foco de Rust se está distorsionando por la llegada masiva del desarrollo de servicios web.
Todavía no estoy seguro de que Rust sea el lenguaje adecuado para ese tipo de trabajo, pero parece funcionarles bastante bien, así que está bien.
Sin embargo, la discusión pública sobre el lenguaje y los crates que se empujan al frente reflejan hoy en general ese sesgo.
También es el sesgo de muchos ingenieros de software de este foro.
Aplicaciones de sistemas como motores de juegos, bibliotecas de criptografía, kernels, herramientas de línea de comandos, compiladores, etc., se están creando con éxito sin tocar async.
Mantengo una biblioteca de criptografía grande y no me ha afectado en absoluto lo que pasa del lado de async.
Sería una lástima si, como resultado, se arruinara la utilidad de Rust para la programación de sistemas.
La aplicación en sí casi no tiene estado fuera de las solicitudes, y el socket listener y las conexiones a la base de datos pueden dividirse por thread.
Es probable que el estado restante también sea en su mayoría estático entre solicitudes, así que no habría invalidación de caché frecuente.
Como no hay estado compartido, el manejo de ownership también debería ser sencillo.
No digo que eso sea bueno o malo, pero ahora Rust tiene que vivir con un flujo interminable de bibliotecas y frameworks web de calidad dispar.
Y como una buena parte de las bibliotecas y crates de infraestructura centrales adoptaron un enfoque async primero, async seguirá siendo tema de discusión.
Hoy ya es difícil escribir código síncrono común para problemas de negocio, salvo que un desarrollador común declare explícitamente que no usará async como lineamiento principal del proyecto.
Considero que exigir el límite
Sendpara permitir mover tareas entre threads del ejecutor es una falla clara del sistema async de Rust en sí.Porque, junto con el problema fundamental de async Drop, impide implementar APIs con scope.
Igual que con los threads, debería bastar con que el límite
Sendexista solo en funciones como spawn o el envío de datos por canales.El enfoque sin compartir no suele ser más que un workaround para ocultar esta falla.
Fijar opcionalmente una tarea a un thread/core específico tiene ventajas y puede ser realmente útil en algunas situaciones, pero esa es una discusión más fina y tiene poco que ver con las quejas de los usuarios de async relacionadas con
Send.Es un buen artículo, y recomiendo leer más que solo el título.
La frase que más me gustó fue: “si tu mayor queja es agregar un límite
Senda algún genérico, me cuesta creer que esa persona esté haciendo ingeniería de ese nivel”.Edición: estoy totalmente de acuerdo con el comentario de “duped”.
Como no conocía el contexto más amplio de esta discusión, puede que haya citado esa frase demasiado rápido.
La parte de “las personas que no ven bien el estado del sistema pueden tener dificultades para encontrar la mejor forma de garantizar que el future sea
Send” me suena un poco arrogante.Los problemas con lifetimes
'staticy restriccionesSend/Syncles resultan muy familiares a los desarrolladores, y no me dan la impresión de que sean tontos.La gente dice que no hacer work stealing es más fácil y más rápido.
Mi argumento es que es una de dos.
Para que el enfoque sin compartir sea más rápido, hay que diseñar el código de una forma que no es más fácil que hacer thread-safe una arquitectura de estado compartido.
En el siguiente párrafo hay una frase paralela que responde a “lento”.
No creo que las personas que tienen dificultades para hacer compilar Rust paralelo y concurrente sean tontas.
Pero no me gusta que actúen como si la API que les dimos les hubiera arruinado la vida.
Si es algo que de todos modos había que hacer, entonces no es más difícil.
Más que “la gente sobrerreacciona ante tareas triviales”, diría que async te obliga a resolver antes un problema que algún día ibas a tener que resolver.
Se siente parecido al borrow checker.
A veces es demasiado restrictivo, pero otras veces sí existía un caso límite que no habías considerado cuando asumiste que todo estaba bien.
Sendo no.Por eso la frase citada me resuena.
Este texto se pierde en los detalles y deja pasar el panorama general.
No existe una forma siempre correcta de obtener el mejor rendimiento en todos los programas.
Se puede debatir todo lo que se quiera, pero las ventajas y desventajas de thread por core son el típico “depende”.
El problema es que, para empezar, usar
asyncya es una optimización prematura.El 99% de los programas en Rust no son redis ni linkerd.
Son herramientas CLI o apps web que serían lo suficientemente rápidas incluso escritas en Python o Ruby.
Entonces me pregunto por qué la comunidad abandonó el I/O bloqueante en Rust, por qué todo se volvió async y por qué los desarrolladores ponen
#[tokio::main]en todo como si fuera el valor por defecto.Hay lenguajes con una mejor experiencia de programación a cambio de velocidad, y Python es un ejemplo.
Si quieres usar Rust, probablemente necesites ese rendimiento adicional; y, dado que ya aceptaste un lenguaje menos cómodo, también puedes aceptar un estilo menos cómodo a cambio de mejor rendimiento.
Nunca he usado Rust, pero entiendo la molestia.
Sería frustrante tener que escribir el código de una forma especial para que el estado pueda moverse entre threads, solo para redistribuir carga que quizá ni siquiera hace falta y que, en una situación con mucho margen de CPU, podría incluso aumentar la latencia de extremo a extremo de una sola solicitud.
Si fuera una plataforma donde el estado transferible es el valor por defecto y casi nunca se rompe, este enfoque podría tener sentido, pero parece que en Rust no es así.
Lo que me da curiosidad es cómo se siente al usarlo.
Me pregunto si es más parecido a “si no agregas el conjuro mágico llamado
Send, el código no compila”, o a “durante el work stealing, el estado se corrompe y falla de forma intermitente y difícil de depurar”.tienes un
Servery enservelees mensajes, y luego hacesspawn(async move { ... })de cada handler de mensaje como una nueva task.Al principio todo funciona bien.
Pero un día cambias la implementación de
do_thisde modo que el tipo dethisya no esSend, y entoncesspawn(...)te lanza un error de compilación intimidante diciendo que el tipo creado por el scope anónimoasync move { }no esSend.La razón no siempre es clara y el mensaje de error tampoco ayuda.
Si
thisno esSend, no puedes conservarlo a través del.awaitdedo_that(arg).await.Eso es porque cada
.awaites un punto de ejecución donde el future puede ceder y ser programado por el ejecutor en otro thread.Si puedes hacer que el tipo sea
Send, todo bien.Pero hay bastantes casos en los que no puedes, y entonces tienes que cambiar la programación del future a algo como
spawn_local.Para poder llamar eso, quizá tengas que agregar bastante boilerplate.
Ese es el problema de
Send.No se trata simplemente de agregar una anotación de tipo: como no siempre está claro si un tipo implementa
Send, puede infiltrarse sutilmente en el código y romperse más adelante de una forma poco clara.SendySync.Si escribes el programa de forma thread-safe, no hay problema.
El punto clave es este:
la gente se queja de que Tokio async es difícil porque exige
SendySyncpor todos lados, pero en realidad lo difícil es escribir código concurrente seguro de cualquier tipo.No es intuitivo, y el problema es que async hace que parezca que automáticamente “se encarga” de eso.
Pero en realidad no es así.
Tienes que saber qué estás haciendo, y el compilador solo te ayuda en ese punto.
Con thread-per-core puedes ocultar la necesidad de
Senden algunas situaciones, pero no en todas.Y a largo plazo puede volver a morderte desde el punto de vista de la arquitectura.
Send/Syncdonde haga falta.Puede que me equivoque, pero la forma perezosa de lograrlo suele ser envolver lo que puede compartirse con
ArcoMutex.Si está centrado en CPU, el work stealing será mejor en la mayoría de los casos.
Si está centrado en I/O, thread-per-core puede funcionar mejor, pero de todos modos hay suficiente margen de CPU, así que el rendimiento no importa tanto.
En mi opinión, work stealing es un mejor valor por defecto para poner en la API del lenguaje.