Rendimiento de E/S del lado del servidor: Nodo frente a PHP frente a Java frente a Go
Publicado: 2022-03-11Comprender el modelo de entrada/salida (E/S) de su aplicación puede significar la diferencia entre una aplicación que se ocupa de la carga a la que está sujeta y una que se derrumba ante los casos de uso del mundo real. Quizás si bien su aplicación es pequeña y no sirve para grandes cargas, puede importar mucho menos. Pero a medida que aumenta la carga de tráfico de su aplicación, trabajar con el modelo de E/S incorrecto puede llevarlo a un mundo de sufrimiento.
Y como en la mayoría de las situaciones en las que son posibles múltiples enfoques, no se trata solo de cuál es mejor, sino de comprender las ventajas y desventajas. Demos un paseo por el paisaje de E/S y veamos qué podemos espiar.
En este artículo, compararemos Node, Java, Go y PHP con Apache, discutiremos cómo los diferentes lenguajes modelan su E/S, las ventajas y desventajas de cada modelo y concluiremos con algunos puntos de referencia rudimentarios. Si le preocupa el rendimiento de E/S de su próxima aplicación web, este artículo es para usted.
Conceptos básicos de E/S: una actualización rápida
Para comprender los factores relacionados con la E/S, primero debemos revisar los conceptos hasta el nivel del sistema operativo. Si bien es poco probable que tenga que lidiar con muchos de estos conceptos directamente, los trata indirectamente a través del entorno de tiempo de ejecución de su aplicación todo el tiempo. Y los detalles importan.
Llamadas al sistema
En primer lugar, tenemos llamadas al sistema, que se pueden describir de la siguiente manera:
- Su programa (en "tierra de usuario", como dicen) debe pedirle al núcleo del sistema operativo que realice una operación de E/S en su nombre.
- Una "llamada al sistema" es el medio por el cual su programa le pide al kernel que haga algo. Los detalles de cómo se implementa esto varían entre los sistemas operativos, pero el concepto básico es el mismo. Habrá alguna instrucción específica que transfiera el control de su programa al kernel (como una llamada de función pero con algo de salsa especial específicamente para lidiar con esta situación). En términos generales, las llamadas al sistema se bloquean, lo que significa que su programa espera a que el kernel regrese a su código.
- El núcleo realiza la operación de E/S subyacente en el dispositivo físico en cuestión (disco, tarjeta de red, etc.) y responde a la llamada al sistema. En el mundo real, el kernel podría tener que hacer una serie de cosas para cumplir con su solicitud, como esperar a que el dispositivo esté listo, actualizar su estado interno, etc., pero como desarrollador de aplicaciones, eso no le importa. Ese es el trabajo del núcleo.
Llamadas con bloqueo y sin bloqueo
Ahora, acabo de decir anteriormente que las llamadas al sistema se están bloqueando, y eso es cierto en un sentido general. Sin embargo, algunas llamadas se clasifican como "sin bloqueo", lo que significa que el kernel toma su solicitud, la pone en cola o en un búfer en algún lugar y luego regresa inmediatamente sin esperar a que ocurra la E/S real. Por lo tanto, se "bloquea" solo por un período de tiempo muy breve, lo suficiente como para poner en cola su solicitud.
Algunos ejemplos (de llamadas al sistema de Linux) pueden ayudar a aclarar: - read()
es una llamada de bloqueo - le pasa un identificador que dice qué archivo y un búfer de dónde entregar los datos que lee, y la llamada regresa cuando los datos están allí. Tenga en cuenta que esto tiene la ventaja de ser agradable y simple. - epoll_create()
, epoll_ctl()
y epoll_wait()
son llamadas que, respectivamente, le permiten crear un grupo de controladores para escuchar, agregar/eliminar controladores de ese grupo y luego bloquear hasta que haya actividad. Esto le permite controlar de manera eficiente una gran cantidad de operaciones de E/S con un solo hilo, pero me estoy adelantando. Esto es excelente si necesita la funcionalidad, pero como puede ver, ciertamente es más complejo de usar.
Es importante comprender el orden de magnitud de la diferencia en el tiempo aquí. Si un núcleo de CPU se ejecuta a 3 GHz, sin entrar en las optimizaciones que puede hacer la CPU, está realizando 3 mil millones de ciclos por segundo (o 3 ciclos por nanosegundo). Una llamada al sistema sin bloqueo puede tardar del orden de decenas de ciclos en completarse, o "unos relativamente pocos nanosegundos". Una llamada que bloquea la información que se recibe a través de la red puede tardar mucho más, digamos, por ejemplo, 200 milisegundos (1/5 de segundo). Y digamos, por ejemplo, que la llamada sin bloqueo tomó 20 nanosegundos y la llamada con bloqueo tomó 200 000 000 nanosegundos. Su proceso solo esperó 10 millones de veces más por la llamada de bloqueo.
El kernel proporciona los medios para realizar E/S de bloqueo ("leer de esta conexión de red y darme los datos") y E/S de no bloqueo ("avísame cuando alguna de estas conexiones de red tenga datos nuevos"). Y qué mecanismo se utiliza bloqueará el proceso de llamada durante períodos de tiempo dramáticamente diferentes.
Planificación
La tercera cosa que es fundamental seguir es lo que sucede cuando tiene muchos subprocesos o procesos que comienzan a bloquearse.
Para nuestros propósitos, no hay una gran diferencia entre un hilo y un proceso. En la vida real, la diferencia más notable relacionada con el rendimiento es que, dado que los subprocesos comparten la misma memoria y cada proceso tiene su propio espacio de memoria, la creación de procesos separados tiende a ocupar mucha más memoria. Pero cuando hablamos de programación, lo que realmente se reduce a una lista de cosas (hilos y procesos por igual) que cada uno necesita para obtener una porción del tiempo de ejecución en los núcleos de CPU disponibles. Si tiene 300 subprocesos en ejecución y 8 núcleos para ejecutarlos, debe dividir el tiempo para que cada uno obtenga su parte, con cada núcleo ejecutándose durante un período corto de tiempo y luego pasando al siguiente subproceso. Esto se hace a través de un "cambio de contexto", lo que hace que la CPU cambie de ejecutar un subproceso/proceso al siguiente.
Estos cambios de contexto tienen un costo asociado: toman algún tiempo. En algunos casos rápidos, puede ser menos de 100 nanosegundos, pero no es raro que tarde 1000 nanosegundos o más dependiendo de los detalles de implementación, la arquitectura/velocidad del procesador, la memoria caché de la CPU, etc.
Y cuantos más hilos (o procesos), más cambio de contexto. Cuando hablamos de miles de subprocesos y cientos de nanosegundos para cada uno, las cosas pueden volverse muy lentas.
Sin embargo, las llamadas sin bloqueo, en esencia, le dicen al kernel "solo llámame cuando tengas nuevos datos o eventos en una de estas conexiones". Estas llamadas sin bloqueo están diseñadas para manejar de manera eficiente grandes cargas de E/S y reducir el cambio de contexto.
Conmigo hasta ahora? Porque ahora viene la parte divertida: veamos qué hacen algunos lenguajes populares con estas herramientas y saquemos algunas conclusiones sobre las compensaciones entre la facilidad de uso y el rendimiento... y otros datos interesantes.
Como nota, si bien los ejemplos que se muestran en este artículo son triviales (y parciales, solo se muestran los bits relevantes); el acceso a la base de datos, los sistemas de almacenamiento en caché externos (memcache, etc.) y cualquier cosa que requiera E/S terminará realizando algún tipo de llamada de E/S bajo el capó que tendrá el mismo efecto que los ejemplos simples que se muestran. Además, para los escenarios en los que la E/S se describe como "bloqueante" (PHP, Java), las lecturas y escrituras de solicitud y respuesta HTTP son en sí mismas llamadas de bloqueo: nuevamente, más E/S ocultas en el sistema con sus problemas de rendimiento concomitantes. tener en cuenta.
Hay muchos factores que intervienen en la elección de un lenguaje de programación para un proyecto. Incluso hay muchos factores cuando solo consideras el rendimiento. Pero, si le preocupa que su programa esté limitado principalmente por E/S, si el rendimiento de E/S es fundamental para su proyecto, estas son cosas que necesita saber.
El enfoque de “mantenlo simple”: PHP
En los años 90, mucha gente usaba zapatos Converse y escribía guiones CGI en Perl. Luego apareció PHP y, por mucho que a algunas personas les guste criticarlo, hizo que las páginas web dinámicas fueran mucho más fáciles.
El modelo que usa PHP es bastante simple. Hay algunas variaciones, pero su servidor PHP promedio se ve así:
Una solicitud HTTP proviene del navegador de un usuario y llega a su servidor web Apache. Apache crea un proceso separado para cada solicitud, con algunas optimizaciones para reutilizarlos con el fin de minimizar la cantidad que tiene que hacer (la creación de procesos es, en términos relativos, lenta). Apache llama a PHP y le dice que ejecute el archivo .php
apropiado en el disco. El código PHP se ejecuta y bloquea las llamadas de E/S. Usted llama a file_get_contents()
en PHP y, bajo el capó, hace llamadas al sistema read()
y espera los resultados.
Y, por supuesto, el código real simplemente se incrusta directamente en su página y las operaciones se bloquean:
<?php // blocking file I/O $file_data = file_get_contents('/path/to/file.dat'); // blocking network I/O $curl = curl_init('http://example.com/example-microservice'); $result = curl_exec($curl); // some more blocking network I/O $result = $db->query('SELECT id, data FROM examples ORDER BY id DESC limit 100'); ?>
En términos de cómo esto se integra con el sistema, es así:
Bastante simple: un proceso por solicitud. Las llamadas de E/S simplemente se bloquean. ¿Ventaja? Es simple y funciona. ¿Desventaja? Hágalo con 20.000 clientes al mismo tiempo y su servidor estallará en llamas. Este enfoque no se escala bien porque no se utilizan las herramientas proporcionadas por el núcleo para manejar E/S de alto volumen (epoll, etc.). Y para colmo de males, ejecutar un proceso separado para cada solicitud tiende a usar una gran cantidad de recursos del sistema, especialmente la memoria, que a menudo es lo primero que se queda sin en un escenario como este.
Nota: el enfoque utilizado para Ruby es muy similar al de PHP, y en un sentido amplio, general y manual, se pueden considerar iguales para nuestros propósitos.
El enfoque de subprocesos múltiples: Java
Entonces aparece Java, justo en el momento en que compraste tu primer nombre de dominio y fue genial decir al azar "punto com" después de una oración. Y Java tiene subprocesos múltiples integrados en el lenguaje, lo cual (especialmente para cuando se creó) es bastante impresionante.
La mayoría de los servidores web de Java funcionan iniciando un nuevo subproceso de ejecución para cada solicitud que llega y luego, en este subproceso, finalmente llama a la función que usted, como desarrollador de la aplicación, escribió.
Hacer E/S en un servlet de Java tiende a parecerse a:
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // blocking file I/O InputStream fileIs = new FileInputStream("/path/to/file"); // blocking network I/O URLConnection urlConnection = (new URL("http://example.com/example-microservice")).openConnection(); InputStream netIs = urlConnection.getInputStream(); // some more blocking network I/O out.println("..."); }
Dado que nuestro método doGet
anterior corresponde a una solicitud y se ejecuta en su propio subproceso, en lugar de un proceso separado para cada solicitud que requiere su propia memoria, tenemos un subproceso separado. Esto tiene algunas ventajas agradables, como poder compartir estado, datos almacenados en caché, etc. entre subprocesos porque pueden acceder a la memoria de los demás, pero el impacto en la forma en que interactúa con el cronograma sigue siendo casi idéntico a lo que se está haciendo en PHP. ejemplo anteriormente. Cada solicitud recibe un nuevo subproceso y las diversas operaciones de E/S se bloquean dentro de ese subproceso hasta que la solicitud se maneja por completo. Los subprocesos se agrupan para minimizar el costo de crearlos y destruirlos, pero aún así, miles de conexiones significan miles de subprocesos, lo que es malo para el programador.
Un hito importante es que en la versión 1.4 Java (y una actualización significativa nuevamente en 1.7) obtuvo la capacidad de realizar llamadas de E/S sin bloqueo. La mayoría de las aplicaciones, web y de otro tipo, no lo utilizan, pero al menos está disponible. Algunos servidores web de Java intentan aprovechar esto de varias maneras; sin embargo, la gran mayoría de las aplicaciones Java implementadas aún funcionan como se describe anteriormente.
Java nos acerca y ciertamente tiene una buena funcionalidad lista para usar para E/S, pero todavía no resuelve realmente el problema de lo que sucede cuando tiene una aplicación muy vinculada a E/S que está siendo golpeada. el suelo con muchos miles de hilos de bloqueo.
E/S sin bloqueo como ciudadano de primera clase: nodo
El chico popular en el bloque cuando se trata de una mejor E/S es Node.js. A cualquiera que haya tenido la más breve introducción a Node se le ha dicho que es "sin bloqueo" y que maneja la E/S de manera eficiente. Y esto es cierto en un sentido general. Pero el diablo está en los detalles y los medios por los cuales se logró esta brujería importan cuando se trata de rendimiento.
Esencialmente, el cambio de paradigma que implementa Node es que, en lugar de decir esencialmente "escribe tu código aquí para manejar la solicitud", en su lugar dicen "escribe el código aquí para comenzar a manejar la solicitud". Cada vez que necesita hacer algo que involucre E/S, realiza la solicitud y proporciona una función de devolución de llamada a la que Node llamará cuando haya terminado.

El código de nodo típico para realizar una operación de E/S en una solicitud es así:
http.createServer(function(request, response) { fs.readFile('/path/to/file', 'utf8', function(err, data) { response.end(data); }); });
Como puede ver, hay dos funciones de devolución de llamada aquí. El primero se llama cuando se inicia una solicitud y el segundo se llama cuando los datos del archivo están disponibles.
Lo que esto hace es básicamente darle a Node la oportunidad de manejar de manera eficiente la E/S entre estas devoluciones de llamada. Un escenario en el que sería aún más relevante es cuando estás haciendo una llamada a la base de datos en Node, pero no me molestaré con el ejemplo porque es exactamente el mismo principio: inicias la llamada a la base de datos y le das a Node una función de devolución de llamada, realiza las operaciones de E/S por separado utilizando llamadas sin bloqueo y luego invoca su función de devolución de llamada cuando los datos que solicitó están disponibles. Este mecanismo de poner en cola las llamadas de E/S y dejar que Node las maneje y luego obtener una devolución de llamada se denomina "bucle de eventos". Y funciona bastante bien.
Sin embargo, hay una trampa para este modelo. Debajo del capó, la razón de esto tiene mucho más que ver con la forma en que se implementa el motor JavaScript V8 (el motor JS de Chrome que usa Node) 1 que cualquier otra cosa. El código JS que escribe se ejecuta en un solo hilo. Piense en eso por un momento. Significa que, si bien la E/S se realiza utilizando técnicas eficientes de no bloqueo, su JS que realiza operaciones vinculadas a la CPU se ejecuta en un solo subproceso, cada fragmento de código bloquea al siguiente. Un ejemplo común de dónde podría surgir esto es recorrer los registros de la base de datos para procesarlos de alguna manera antes de enviarlos al cliente. He aquí un ejemplo que muestra cómo funciona:
var handler = function(request, response) { connection.query('SELECT ...', function (err, rows) { if (err) { throw err }; for (var i = 0; i < rows.length; i++) { // do processing on each row } response.end(...); // write out the results }) };
Si bien Node maneja la E/S de manera eficiente, el ciclo for
en el ejemplo anterior usa ciclos de CPU dentro de su único hilo principal. Esto significa que si tiene 10,000 conexiones, ese ciclo podría hacer que toda su aplicación se detuviera, dependiendo de cuánto tiempo tome. Cada solicitud debe compartir un segmento de tiempo, uno a la vez, en su hilo principal.
La premisa en la que se basa todo este concepto es que las operaciones de E/S son la parte más lenta, por lo que es más importante manejarlas de manera eficiente, incluso si eso significa realizar otro procesamiento en serie. Esto es cierto en algunos casos, pero no en todos.
El otro punto es que, y si bien esto es solo una opinión, puede ser bastante tedioso escribir un montón de devoluciones de llamada anidadas y algunos argumentan que hace que el código sea significativamente más difícil de seguir. No es raro ver devoluciones de llamadas anidadas en cuatro, cinco o incluso más niveles en el interior del código de Node.
Volvemos de nuevo a las compensaciones. El modelo Node funciona bien si su principal problema de rendimiento es la E/S. Sin embargo, su talón de Aquiles es que puede ingresar a una función que está manejando una solicitud HTTP y colocar un código de uso intensivo de CPU y hacer que cada conexión se deslice si no tiene cuidado.
Naturalmente sin bloqueo: Ir
Antes de entrar en la sección de Go, es apropiado que revele que soy un fanático de Go. Lo he usado para muchos proyectos y soy un defensor abierto de sus ventajas de productividad, y las veo en mi trabajo cuando lo uso.
Dicho esto, veamos cómo trata la E/S. Una característica clave del lenguaje Go es que contiene su propio planificador. En lugar de que cada subproceso de ejecución corresponda a un solo subproceso del sistema operativo, funciona con el concepto de "goroutines". Y el tiempo de ejecución de Go puede asignar una gorutina a un subproceso del sistema operativo y hacer que se ejecute, o suspenderla y hacer que no esté asociada con un subproceso del sistema operativo, según lo que esté haciendo esa gorutina. Cada solicitud que proviene del servidor HTTP de Go se maneja en un Goroutine separado.
El diagrama de cómo funciona el programador se ve así:
Bajo el capó, esto se implementa mediante varios puntos en el tiempo de ejecución de Go que implementan la llamada de E/S al realizar la solicitud de escritura/lectura/conexión/etc. cuando se puedan tomar nuevas medidas.
En efecto, el tiempo de ejecución de Go está haciendo algo no muy diferente a lo que está haciendo Node, excepto que el mecanismo de devolución de llamada está integrado en la implementación de la llamada de E/S e interactúa con el programador automáticamente. Tampoco sufre la restricción de tener que ejecutar todo el código de su controlador en el mismo hilo, Go asignará automáticamente sus Goroutines a tantos hilos del sistema operativo que considere apropiados según la lógica de su programador. El resultado es un código como este:
func ServeHTTP(w http.ResponseWriter, r *http.Request) { // the underlying network call here is non-blocking rows, err := db.Query("SELECT ...") for _, row := range rows { // do something with the rows, // each request in its own goroutine } w.Write(...) // write the response, also non-blocking }
Como puede ver arriba, la estructura de código básica de lo que estamos haciendo se asemeja a la de los enfoques más simples y, sin embargo, logra E/S sin bloqueo debajo del capó.
En la mayoría de los casos, esto termina siendo “lo mejor de ambos mundos”. La E/S sin bloqueo se usa para todas las cosas importantes, pero su código parece estar bloqueando y, por lo tanto, tiende a ser más simple de entender y mantener. La interacción entre el programador de Go y el programador del sistema operativo se encarga del resto. No es magia completa, y si construye un sistema grande, vale la pena dedicar tiempo para comprender más detalles sobre cómo funciona; pero al mismo tiempo, el entorno que obtienes "listo para usar" funciona y escala bastante bien.
Go puede tener sus fallas, pero en términos generales, la forma en que maneja la E/S no está entre ellas.
Mentiras, malditas mentiras y puntos de referencia
Es difícil dar tiempos exactos en el cambio de contexto involucrado con estos diversos modelos. También podría argumentar que es menos útil para ti. Entonces, en su lugar, le daré algunos puntos de referencia básicos que comparan el rendimiento general del servidor HTTP de estos entornos de servidor. Tenga en cuenta que muchos factores están involucrados en el rendimiento de toda la ruta de solicitud/respuesta HTTP de extremo a extremo, y los números presentados aquí son solo algunos ejemplos que reuní para brindar una comparación básica.
Para cada uno de estos entornos, escribí el código apropiado para leer en un archivo de 64k con bytes aleatorios, ejecuté un hash SHA-256 en él N número de veces (se especificó N en la cadena de consulta de la URL, por ejemplo, .../test.php?n=100
) e imprima el hash resultante en hexadecimal. Elegí esto porque es una forma muy simple de ejecutar los mismos puntos de referencia con algunas E/S consistentes y una forma controlada de aumentar el uso de la CPU.
Consulte estas notas comparativas para obtener un poco más de detalles sobre los entornos utilizados.
Primero, veamos algunos ejemplos de baja concurrencia. Ejecutar 2000 iteraciones con 300 solicitudes simultáneas y solo un hash por solicitud (N=1) nos da esto:
Es difícil sacar una conclusión solo de este gráfico, pero me parece que, en este volumen de conexión y cálculo, estamos viendo momentos que tienen más que ver con la ejecución general de los lenguajes mismos, mucho más que el E/S. Tenga en cuenta que los lenguajes que se consideran "lenguajes de secuencias de comandos" (escritura suelta, interpretación dinámica) son los más lentos.
Pero, ¿qué sucede si aumentamos N a 1000, aún con 300 solicitudes simultáneas: la misma carga pero 100 veces más iteraciones de hash (significativamente más carga de CPU):
De repente, el rendimiento del nodo cae significativamente, porque las operaciones que hacen un uso intensivo de la CPU en cada solicitud se bloquean entre sí. Y, curiosamente, el rendimiento de PHP mejora mucho (en relación con los demás) y supera a Java en esta prueba. (Vale la pena señalar que en PHP, la implementación SHA-256 está escrita en C y la ruta de ejecución pasa mucho más tiempo en ese ciclo, ya que ahora estamos haciendo 1000 iteraciones hash).
Ahora intentemos 5000 conexiones simultáneas (con N = 1), o lo más cerca posible. Desafortunadamente, para la mayoría de estos entornos, la tasa de fallas no fue insignificante. Para este gráfico, veremos el número total de solicitudes por segundo. Cuanto más alto, mejor :
Y la imagen se ve muy diferente. Es una conjetura, pero parece que con un alto volumen de conexión, la sobrecarga por conexión involucrada con la generación de nuevos procesos y la memoria adicional asociada con él en PHP+Apache parece convertirse en un factor dominante y reduce el rendimiento de PHP. Claramente, Go es el ganador aquí, seguido de Java, Node y finalmente PHP.
Si bien los factores involucrados con su rendimiento general son muchos y también varían ampliamente de una aplicación a otra, cuanto más entienda acerca de lo que sucede debajo del capó y las compensaciones involucradas, mejor estará.
En resumen
Con todo lo anterior, está bastante claro que a medida que los lenguajes han evolucionado, las soluciones para manejar aplicaciones a gran escala que realizan muchas operaciones de E/S también han evolucionado.
Para ser justos, tanto PHP como Java, a pesar de las descripciones de este artículo, tienen implementaciones de E/S sin bloqueo disponibles para su uso en aplicaciones web. Pero estos no son tan comunes como los enfoques descritos anteriormente, y sería necesario tener en cuenta la sobrecarga operativa concomitante del mantenimiento de los servidores que utilizan dichos enfoques. Sin mencionar que su código debe estar estructurado de una manera que funcione con dichos entornos; su aplicación web PHP o Java "normal" generalmente no se ejecutará sin modificaciones significativas en dicho entorno.
A modo de comparación, si consideramos algunos factores significativos que afectan el rendimiento y la facilidad de uso, obtenemos esto:
Idioma | Hilos frente a procesos | E/S sin bloqueo | Facilidad de uso |
---|---|---|---|
PHP | Procesos | No | |
Java | Hilos | Disponible | Requiere devoluciones de llamada |
Nodo.js | Hilos | sí | Requiere devoluciones de llamada |
Ir | Subprocesos (Gorutinas) | sí | No se necesitan devoluciones de llamada |
Los subprocesos generalmente serán mucho más eficientes con la memoria que los procesos, ya que comparten el mismo espacio de memoria mientras que los procesos no lo hacen. Al combinar eso con los factores relacionados con la E/S sin bloqueo, podemos ver que al menos con los factores considerados anteriormente, a medida que avanzamos en la lista, mejora la configuración general relacionada con la E/S. Entonces, si tuviera que elegir un ganador en el concurso anterior, sin duda sería Go.
Aun así, en la práctica, elegir un entorno en el que construir su aplicación está estrechamente relacionado con la familiaridad que su equipo tenga con dicho entorno y la productividad general que pueda lograr con él. Por lo tanto, puede que no tenga sentido que todos los equipos simplemente se sumerjan y comiencen a desarrollar aplicaciones y servicios web en Node o Go. De hecho, encontrar desarrolladores o la familiaridad de su equipo interno a menudo se mencionan como la razón principal para no usar un lenguaje y/o entorno diferente. Dicho esto, los tiempos han cambiado en los últimos quince años, mucho.
Esperemos que lo anterior ayude a pintar una imagen más clara de lo que sucede debajo del capó y le brinde algunas ideas sobre cómo lidiar con la escalabilidad del mundo real para su aplicación. ¡Feliz entrada y salida!