Guía de modelos de servidores de red multiprocesamiento

Publicado: 2022-03-11

Como alguien que ha estado escribiendo código de redes de alto rendimiento durante varios años (mi disertación doctoral fue sobre el tema de un servidor de caché para aplicaciones distribuidas adaptadas a sistemas multinúcleo), veo muchos tutoriales sobre el tema que pasan por alto por completo u omiten cualquier discusión. de los fundamentos de los modelos de servidores de red. Por lo tanto, este artículo pretende ser una descripción general útil y una comparación de los modelos de servidores de red, con el objetivo de eliminar parte del misterio de escribir código de red de alto rendimiento.

¿Qué modelo de servidor de red debo elegir?

Este artículo está destinado a "programadores de sistemas", es decir, desarrolladores de back-end que trabajarán con los detalles de bajo nivel de sus aplicaciones, implementando código de servidor de red. Esto generalmente se hará en C++ o C, aunque hoy en día la mayoría de los lenguajes y marcos modernos ofrecen una funcionalidad decente de bajo nivel, con varios niveles de eficiencia.

Tomaré como conocimiento común que dado que es más fácil escalar las CPU agregando núcleos, es natural adaptar el software para usar estos núcleos lo mejor que pueda. Por lo tanto, la pregunta es cómo particionar el software entre subprocesos (o procesos) que se pueden ejecutar en paralelo en varias CPU.

También doy por sentado que el lector es consciente de que “concurrencia” básicamente significa “multitarea”, es decir, varias instancias de código (sea el mismo código o diferente, no importa), que están activas al mismo tiempo. La simultaneidad se puede lograr en una sola CPU y, antes de la era moderna, generalmente se lograba. Específicamente, la concurrencia se puede lograr cambiando rápidamente entre múltiples procesos o subprocesos en una sola CPU. Así es como los viejos sistemas de una sola CPU lograron ejecutar muchas aplicaciones al mismo tiempo, de una manera que el usuario percibiría como aplicaciones que se ejecutan simultáneamente, aunque en realidad no lo eran. El paralelismo, por otro lado, significa específicamente que el código se ejecuta al mismo tiempo, literalmente, por múltiples CPU o núcleos de CPU.

Partición de una aplicación (en múltiples procesos o subprocesos)

A los efectos de esta discusión, en gran medida no es relevante si estamos hablando de subprocesos o procesos completos. Los sistemas operativos modernos (con la notable excepción de Windows) tratan los procesos casi tan livianos como los subprocesos (o, en algunos casos, viceversa, los subprocesos han adquirido características que los hacen tan pesados ​​como los procesos). Hoy en día, la principal diferencia entre procesos y subprocesos está en las capacidades de comunicación e intercambio de datos entre procesos o entre subprocesos. Cuando la distinción entre procesos y subprocesos sea importante, haré una nota apropiada; de lo contrario, es seguro considerar que las palabras "subproceso" y "proceso" en esta sección son intercambiables.

Tareas comunes de aplicaciones de red y modelos de servidores de red

Este artículo trata específicamente del código del servidor de red, que necesariamente implementa las siguientes tres tareas:

  • Tarea n.º 1: establecimiento (y desmantelamiento) de conexiones de red
  • Tarea #2: Comunicación de red (IO)
  • Tarea #3: Trabajo útil; es decir, la carga útil o la razón por la que existe la aplicación

Hay varios modelos generales de servidores de red para particionar estas tareas entre procesos; a saber:

  • MP: Multiproceso
  • SPED: proceso único, impulsado por eventos
  • SEDA: arquitectura impulsada por eventos escenificados
  • AMPED: multiproceso asimétrico impulsado por eventos
  • SYMPED: SYmmetric Multi-Proceso impulsado por eventos

Estos son los nombres de modelos de servidores de red utilizados en la comunidad académica, y recuerdo haber encontrado sinónimos "en la naturaleza" para al menos algunos de ellos. (Los nombres en sí mismos son, por supuesto, menos importantes: el valor real está en cómo razonar sobre lo que sucede en el código).

Cada uno de estos modelos de servidor de red se describe con más detalle en las secciones siguientes.

El modelo multiproceso (MP)

El modelo de servidor de red MP es el que todos solían aprender primero, especialmente, cuando aprendían sobre subprocesos múltiples. En el modelo MP, hay un proceso "maestro" que acepta conexiones (Tarea #1). Una vez que se establece una conexión, el proceso maestro crea un nuevo proceso y le pasa el socket de conexión, por lo que hay un proceso por conexión. Este nuevo proceso generalmente funciona con la conexión de una manera simple, secuencial y bloqueada: lee algo de ella (Tarea n.º 2), luego realiza algunos cálculos (Tarea n.º 3) y luego escribe algo en ella (Tarea n.º 2). otra vez).

El modelo MP es muy simple de implementar y, de hecho, funciona muy bien siempre que el número total de procesos se mantenga bastante bajo. ¿Qué tan bajo? La respuesta realmente depende de lo que impliquen las tareas n.° 2 y n.° 3. Como regla general, digamos que la cantidad de procesos o subprocesos no debe exceder el doble de la cantidad de núcleos de CPU. Una vez que hay demasiados procesos activos al mismo tiempo, el sistema operativo tiende a pasar demasiado tiempo acelerando (es decir, haciendo malabarismos con los procesos o subprocesos en los núcleos de CPU disponibles) y tales aplicaciones generalmente terminan gastando casi toda su CPU tiempo en el código "sys" (o kernel), haciendo poco trabajo realmente útil.

Pros: Muy simple de implementar, funciona muy bien siempre que el número de conexiones sea pequeño.

Contras: Tiende a sobrecargar el sistema operativo si la cantidad de procesos crece demasiado y puede tener fluctuaciones de latencia a medida que la E/S de la red espera hasta que finaliza la fase de carga útil (cálculo).

El modelo de proceso único impulsado por eventos (SPED)

El modelo de servidor de red SPED se hizo famoso gracias a algunas aplicaciones de servidor de red de alto perfil relativamente recientes, como Nginx. Básicamente, realiza las tres tareas en el mismo proceso, multiplexando entre ellas. Para ser eficiente, requiere algunas funciones de kernel bastante avanzadas como epoll y kqueue. En este modelo, el código es impulsado por conexiones entrantes y "eventos" de datos, e implementa un "bucle de eventos" que se ve así:

  • Pregunte al sistema operativo si hay nuevos "eventos" de red (como nuevas conexiones o datos entrantes)
  • Si hay nuevas conexiones disponibles, establecerlas (Tarea #1)
  • Si hay datos disponibles, léalos (Tarea n.º 2) y actúe en consecuencia (Tarea n.º 3)
  • Repita hasta que el servidor salga.

Todo esto se hace en un solo proceso, y se puede hacer de manera extremadamente eficiente porque evita por completo el cambio de contexto entre procesos, lo que generalmente mata el rendimiento en el modelo MP. Los únicos cambios de contexto aquí provienen de las llamadas al sistema, y ​​se minimizan al actuar solo en las conexiones específicas que tienen algunos eventos adjuntos. Este modelo puede manejar decenas de miles de conexiones al mismo tiempo, siempre que el trabajo de carga útil (tarea n.º 3) no sea demasiado complicado ni requiera muchos recursos.

Sin embargo, hay dos desventajas importantes de este enfoque:

  1. Dado que las tres tareas se realizan secuencialmente en una única iteración de ciclo, el trabajo de carga útil (Tarea n.º 3) se realiza de forma sincronizada con todo lo demás, lo que significa que si lleva mucho tiempo calcular una respuesta a los datos recibidos por el cliente, todo lo demás se detiene mientras se hace esto, introduciendo fluctuaciones potencialmente enormes en la latencia.
  2. Solo se utiliza un único núcleo de CPU. Esto tiene la ventaja, nuevamente, de limitar absolutamente la cantidad de cambios de contexto requeridos por el sistema operativo, lo que aumenta el rendimiento general, pero tiene la desventaja significativa de que cualquier otro núcleo de CPU disponible no hace nada en absoluto.

Es por estas razones que se requieren modelos más avanzados.

Pros: Puede ser de alto rendimiento y fácil en el sistema operativo (es decir, requiere una intervención mínima del sistema operativo). Solo requiere un único núcleo de CPU.

Contras: solo utiliza una sola CPU (independientemente del número que esté disponible). Si el trabajo de la carga útil no es uniforme, la latencia de las respuestas no es uniforme.

El modelo de arquitectura impulsada por eventos por etapas (SEDA)

El modelo de servidor de red SEDA es un poco complicado. Descompone una aplicación compleja impulsada por eventos en un conjunto de etapas conectadas por colas. Sin embargo, si no se implementa con cuidado, su rendimiento puede sufrir el mismo problema que el caso MP. Funciona así:

  • El trabajo de carga útil (Tarea n.º 3) se divide en tantas etapas o módulos como sea posible. Cada módulo implementa una única función específica (piense en "microservicios" o "micronúcleos") que reside en su propio proceso separado, y estos módulos se comunican entre sí a través de colas de mensajes. Esta arquitectura se puede representar como un gráfico de nodos, donde cada nodo es un proceso y los bordes son colas de mensajes.
  • Un solo proceso realiza la Tarea n.° 1 (generalmente siguiendo el modelo SPED), que descarga nuevas conexiones a nodos de puntos de entrada específicos. Esos nodos pueden ser nodos de red puros (Tarea n.º 2) que pasan los datos a otros nodos para su cálculo, o también pueden implementar el procesamiento de la carga útil (Tarea n.º 3). Por lo general, no existe un proceso "maestro" (p. ej., uno que recopile y agregue respuestas y las envíe de vuelta a través de la conexión), ya que cada nodo puede responder por sí mismo.

En teoría, este modelo puede ser arbitrariamente complejo, con la posibilidad de que el gráfico de nodos tenga bucles, conexiones a otras aplicaciones similares o donde los nodos se estén ejecutando en sistemas remotos. Sin embargo, en la práctica, incluso con mensajes bien definidos y colas eficientes, puede volverse difícil pensar y razonar sobre el comportamiento del sistema como un todo. La sobrecarga del paso de mensajes puede destruir el rendimiento de este modelo, en comparación con el modelo SPED, si el trabajo que se realiza en cada nodo es breve. La eficiencia de este modelo es significativamente menor que la del modelo SPED, por lo que generalmente se emplea en situaciones en las que el trabajo de carga útil es complejo y requiere mucho tiempo.

Pros: El sueño de todo arquitecto de software: todo está segregado en módulos independientes ordenados.

Contras: la complejidad puede explotar solo por la cantidad de módulos, y la cola de mensajes sigue siendo mucho más lenta que el uso compartido directo de la memoria.

El modelo asimétrico multiproceso impulsado por eventos (AMPED)

El servidor de red AMPED es una versión de SEDA más dócil y fácil de modelar. No hay tantos módulos y procesos diferentes, ni tantas colas de mensajes. Así es como funciona:

  • Implemente las tareas n.° 1 y n.° 2 en un solo proceso "maestro", al estilo SPED. Este es el único proceso que realiza E/S de red.
  • Implemente la Tarea #3 en un proceso de "trabajador" separado (posiblemente iniciado en múltiples instancias), conectado al proceso maestro con una cola (una cola por proceso).
  • Cuando se reciben datos en el proceso "maestro", busque un proceso de trabajo infrautilizado (o inactivo) y pase los datos a su cola de mensajes. El proceso maestro recibe un mensaje del proceso cuando una respuesta está lista, momento en el que pasa la respuesta a la conexión.

Lo importante aquí es que el trabajo de carga útil se realiza en un número fijo (normalmente configurable) de procesos, que es independiente del número de conexiones. Los beneficios aquí son que la carga útil puede ser arbitrariamente compleja y no afectará el IO de la red (lo cual es bueno para la latencia). También existe la posibilidad de una mayor seguridad, ya que solo un único proceso realiza E/S de red.

Pros: Separación muy clara del trabajo de carga útil y E/S de la red.

Contras: utiliza una cola de mensajes para pasar datos de un proceso a otro, lo que, dependiendo de la naturaleza del protocolo, puede convertirse en un cuello de botella.

El modelo SYmmetric Multi-Process Event-Driven (SYMPED)

El modelo de servidor de red SYMPED es, en muchos aspectos, el "santo grial" de los modelos de servidor de red, porque es como tener varias instancias de procesos "trabajadores" independientes de SPED. Se implementa al tener un solo proceso que acepta conexiones en un bucle y luego las pasa a los procesos de trabajo, cada uno de los cuales tiene un bucle de eventos similar a SPED. Esto tiene algunas consecuencias muy favorables:

  • Las CPU se cargan exactamente para la cantidad de procesos generados, que en cada momento están realizando E/S de red o procesamiento de carga útil. No hay forma de escalar más la utilización de la CPU.
  • Si las conexiones son independientes (como con HTTP), no hay comunicación entre procesos entre los procesos de trabajo.

Esto es, de hecho, lo que hacen las versiones más nuevas de Nginx; generan una pequeña cantidad de procesos de trabajo, cada uno de los cuales ejecuta un ciclo de eventos. Para mejorar aún más las cosas, la mayoría de los sistemas operativos proporcionan una función mediante la cual múltiples procesos pueden escuchar conexiones entrantes en un puerto TCP de forma independiente, eliminando la necesidad de un proceso específico dedicado a trabajar con conexiones de red. Si la aplicación en la que está trabajando se puede implementar de esta manera, le recomiendo que lo haga.

Pros: Estricto límite superior de uso de la CPU, con un número controlable de bucles similares a SPED.

Contras: dado que cada uno de los procesos tiene un bucle similar a SPED, si el trabajo de la carga útil no es uniforme, la latencia puede volver a variar, al igual que con el modelo SPED normal.

Algunos trucos de bajo nivel

Además de seleccionar el mejor modelo arquitectónico para su aplicación, existen algunos trucos de bajo nivel que se pueden utilizar para aumentar aún más el rendimiento del código de red. Aquí hay una breve lista de algunos de los más efectivos:

  1. Evite la asignación de memoria dinámica. Como explicación, simplemente mire el código de los asignadores de memoria populares: usan estructuras de datos complejas, mutexes y simplemente hay mucho código en ellos (¡jemalloc, por ejemplo, tiene alrededor de 450 KiB de código C!). La mayoría de los modelos anteriores se pueden implementar con redes y/o búferes completamente estáticos (o preasignados) que solo cambian la propiedad entre subprocesos cuando es necesario.
  2. Utilice el máximo que el sistema operativo puede proporcionar. La mayoría de los sistemas operativos permiten que múltiples procesos escuchen en un solo socket e implementan características en las que no se aceptará una conexión hasta que se reciba el primer byte (¡o incluso una primera solicitud completa!) en el socket. Use sendfile() si puede.
  3. ¡Comprenda el protocolo de red que está utilizando! Por ejemplo, normalmente tiene sentido deshabilitar el algoritmo de Nagle, y puede tener sentido deshabilitar la persistencia si la tasa de (re)conexión es alta. Aprenda sobre los algoritmos de control de congestión de TCP y vea si tiene sentido probar uno de los más nuevos.

Es posible que hable más sobre esto, así como técnicas y trucos adicionales para emplear, en una futura publicación de blog. Pero por ahora, esperamos que esto proporcione una base útil e informativa sobre las opciones arquitectónicas para escribir código de red de alto rendimiento y sus ventajas y desventajas relativas.