Optimizando el rendimiento de ActivityPub con concurrencia, paralelismo y asincronía

Optimizando el rendimiento de ActivityPub con concurrencia, paralelismo y asincronía

ActivityPub es un protocolo emergente para la comunicación entre aplicaciones descentralizadas. Permite la difusión de actividades e interacciones entre perfiles alojados en diferentes servidores. Sin embargo, para lograr un buen rendimiento a escala, es clave comprender y aplicar conceptos como la concurrencia, el paralelismo y la asincronía.

Particularmente estoy utilizando parte de esta definición y este protocolo en el desarrollo de Deeditt, y aunque no completo su desarrollo y su uso es parcial, hay algunas cosas que he estado pensando que pueden provocar problemas y por ende ya me estoy adelantando a resolverlos (ideas hasta ahora).

Introducción a ActivityPub

ActivityPub fue creado como parte del universo de tecnologías de ActivityStreams. Proporciona una capa de aplicación basada en el modelo de actores para permitir la comunicación asíncrona entre entidades independientes a través de APIs.

Las actividades en ActivityPub pueden representar cualquier tipo de interacción. Por ejemplo: seguir a un usuario, marcar un artículo como favorito, publicar una nota, etc. Cada actividad tiene un actor (quien la realizó), un verbo (el tipo de acción) y un objeto (sobre lo que se realizó).

Los servidores ActivityPub intercambian estas actividades para propagar la información de forma federada. Cuando un actor genera una nueva actividad, su servidor envía una solicitud POST al punto de entrega público del destinatario. Luego cada servidor retransmite la actividad a sus seguidores relevantes.

Los desafíos de escalabilidad

A medida que una red ActivityPub crece y suman usuarios e interacciones, comienzan a surgir cuellos de botella que afectan la experiencia:

  • Latencia en la entrega de actividades entre servidores.
  • Sobrecarga del servidor al manejar multiples solicitudes concurrentes.
  • Tiempos de procesamiento incrementados al validar firmas y retransmitir actividades.
  • Recursos limitados para almacenar y procesar un volumen creciente de actividades.

Para abordar estos desafíos, debemos comprender y aplicar de forma efectiva técnicas como la concurrencia, el paralelismo y la asincronía.

Concurrencia

La concurrencia nos permite manejar múltiples tareas intercalándolas en el tiempo. En lugar de bloquearnos esperando que cada tarea finalice, podemos cambiar rápidamente entre tareas concurrentes para optimizar la utilización de recursos.

Un servidor ActivityPub debe lidiar concurrentemente con múltiples solicitudes entrantes de otros servidores. También debe manejar de forma concurrente la validación de actividades, su almacenamiento en base de datos y la retransmisión a seguidores.

Algunas estrategias para la concurrencia:

  • Hilos/subprocesos: Separar el manejo de solicitudes entrantes en hilos distintos. Cada hilo obtiene una conexión de base de datos propia.

  • Colas de mensajes: Mover el trabajo intensivo como la retransmisión a seguidores a una cola de mensajería. Permite al servidor responder rápido.

  • Dividir en etapas: Separar la validación, el almacenamiento y la difusión de actividades en etapas o servicios distintos.

La concurrencia por sí sola no nos da paralelismo real, pero sí permite intercalar y multiplexar eficientemente tareas en los recursos existentes.

Paralelismo

Mientras que la concurrencia se enfoca en la división de tareas, el paralelismo tiene que ver con la ejecución simultánea aprovechando múltiples recursos.

Algunas formas de agregar paralelismo en ActivityPub:

  • Multi-núcleo: Aprovechar CPUs multi-núcleo para validar firmas o procesar solicitudes entrantes en paralelo.

  • Clúster: Escalar horizontalmente agregando más nodos backend para repartir la carga. Permite también almacenamiento y procesamiento paralelo de actividades.

  • Streams: Procesar y retransmitir actividades en paralelo mediante funciones reactivas sobre flujos de datos.

  • Actores: Implementar los procesos internos como actores independientes que se comunican de forma asíncrona.

El paralelismo real acelera las operaciones computacionalmente intensivas, permitiendo aprovechar al máximo el hardware disponible.

Asincronía

La ejecución asíncrona nos permite iniciar tareas que se completan en segundo plano sin bloquear el flujo principal. Esto es esencial para mantener la eficiencia y la capacidad de respuesta.

Algunos usos de la asincronía:

  • Llamadas asíncronas a otros servidores al retransmitir actividades.
  • Almacenar actividades en base de datos mediante queries asíncronas.
  • Delegar difusión a seguidores a un mensaje de cola asíncrono.
  • Mover procesamiento intensivo a workers asíncronos en segundo plano.

La clave es identificar qué procesos se pueden iniciar sin necesidad de esperar su finalización, manteniendo el hilo principal libre.

Patrones de diseño (algunas ideas)

Aplicar concurrencia, paralelismo y asincronía de forma aislada puede ayudar, pero el mayor beneficio se obtiene combinando estos conceptos en patrones multicapa:

Recepción reactiva de actividades

La capa que recibe solicitudes entrantes de otros servidores debería implementarse de forma completamente asíncrona y reactiva. Por ejemplo, un server Node.js con NestJS podría recibir solicitudes via streams reactivos, procesando cada una en una gorutina concurrente. El procesamiento bloqueante se delega a otros servicios.

Los streams reactivos permiten manejar un flujo asíncrono de datos de forma declarativa, como una secuencia continua de eventos ordenados en el tiempo, por ejemplo utilizando la librería RxJS, que provee Subjects, Observables y operators, entonces podríamos crear un Subject al cual se le envían las solicitudes entrantes a medida que llegan.

Luego podemos derivar Observables a partir de ese Subject, transformar el stream (validación, parsing, etc) y suscribir handlers asíncronos para procesar cada solicitud de forma no bloqueante.

El servidor de Deeditt esta basado en Nestjs, y podria utilizar un simple endpoint definido en los controllers, pero hay algunas diferencias a considerar:

Controladores en NestJS

Cada endpoint del controlador maneja una solicitud a la vez de forma síncrona/bloqueante, por lo que debemos confiar en que el servidor web (Express) maneje la concurrencia entre solicitudes, entonces el código en los controladores no está orientado a eventos ni ejecución asíncrona. Esto me funcionaría bien para miles de usuarios en una etapa inicial, pero me temo que sería un cuallo de botella a futuro.

Streams reactivos

Manejan un flujo asíncrono de solicitudes entrantes de forma concurrente, lo que permite encadenar operaciones asíncronas y reactivas declarativamente, sin bloquearse por cada solicitud ya que se reacciona a eventos, permitiendo un fácil escalado horizontal, dividiendo stream en múltiples streams paralelos.

Cola de mensajes

Una cola de mensajes como RabbitMQ desacopla la recepción de la difusión. Las actividades entrantes se almacenan rápidamente en la cola de forma asíncrona. Luego se procesan concurrentemente por workers en segundo plano que se encargan de validar, persistir y difundir a los seguidores pertinentes.

Esta solución es mucho más simple de implementar, pero puede tener algunos desafíos en escenarios de mucho tráfico entrante, debido a problemas de latencia ante una alta tasa de mensajes entrantes, lo que podría concluir en retrados en la inserción de datos.

Esto significa que aunque un sistema de procesamiento de colas podría funcionar, debería tener en mente que el tamaño del servidor podría aumentar considerablemente ante la presencia de altos volúmenes de tráfico.

Persistencia en paralelo

El almacenamiento en base de datos de las actividades debería realizarse de forma asíncrona para evitar bloqueos y algunas técnicas como:

Sharding horizontal

Particionar los datos en múltiples nodos de base de datos, de forma que las lecturas y escrituras para diferentes shards se puedan realizar en paralelo sin contender por los mismos recursos.

Replicación

Mantener copias redundantes de cada shard en replicasets distribuidos geográficamente. Permite escalar las lecturas prácticamente sin límites.

Write-behind

Acumular escrituras entrantes en memoria y peristarlas en batch de forma asíncrona para reducir la sobrecarga de la base de datos.

Pero obviamente son muchas ideas que debería analizar con cuidado teniendo en cuenta los posibles problemas o retos que conllevan su uso, aunque son claramente formas de resolver futuros dolores de cabeza con el manejo de datos.

Conclusión

La concurrencia, el paralelismo y la ejecución asíncrona son técnicas fundamentales para escalar un sistema distribuido como ActivityPub y estoy seguro que en unos meses o años regresaré a este artículo a tomar algunas de estas ideas para mejorar Deeditt.

Es importante comprender dónde y cómo aplicar cada concepto permite crear una arquitectura altamente optimizada y resistente a la carga. Actividades en las redes sociales que parecen simples, como publicar un post o dar "me gusta", pueden tener repercusiones complejas en toda la red.

Diseñar pensando en la concurrencia, el paralelismo y la asincronía nos permite manejar ese nivel de complejidad de forma eficiente. Con una implementación inteligente, podemos manejar millones de usuarios activos publicando, interactuando y compartiendo contenido en tiempo real.

Happy coding! :D


Photo by Aleksandr Popov on Unsplash

Jack Fiallos

Jack Fiallos

Te gustó este artículo?