Rendimiento de E/S del lado del servidor: Node vs. PHP vs. Java vs. Go

Oct 22, 2021
admin

Entender el modelo de Entrada/Salida (E/S) de tu aplicación puede significar la diferencia entre una aplicación que hace frente a la carga a la que está sometida, y una que se desmorona ante los casos de uso del mundo real. Tal vez, mientras su aplicación sea pequeña y no tenga cargas elevadas, esto puede importar mucho menos. Pero a medida que la carga de tráfico de tu aplicación aumenta, trabajar con el modelo de E/S incorrecto puede llevarte a un mundo de dolor.

Y como en la mayoría de las situaciones en las que son posibles múltiples enfoques, no es sólo una cuestión de cuál es mejor, es una cuestión de entender las compensaciones. Vamos a dar un paseo por el paisaje de E/S y ver lo que podemos espiar.

En este artículo, vamos a comparar Node, Java, Go y PHP con Apache, discutiendo cómo los diferentes lenguajes modelan su E/S, las ventajas y desventajas de cada modelo, y concluir con algunos puntos de referencia rudimentarios. Si te preocupa el rendimiento de E/S de tu próxima aplicación web, este artículo es para ti.

Básicos de E/S: Un rápido repaso

Para entender los factores que intervienen en la E/S, primero debemos revisar los conceptos a nivel del sistema operativo. Aunque es poco probable que tenga que lidiar con muchos de estos conceptos directamente, usted trata con ellos indirectamente a través del entorno de ejecución de su aplicación todo el tiempo. Y los detalles importan.

Llamadas al sistema

En primer lugar, tenemos las llamadas al sistema, que se pueden describir de la siguiente manera:

  • Tu programa (en «tierra de usuario», como se dice) debe pedir al núcleo del sistema operativo que realice una operación de E/S en su nombre.
  • Una «syscall» es el medio por el cual tu programa pide al núcleo 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 tu programa al núcleo (como una llamada a una función pero con alguna salsa especial para tratar esta situación). En general, las syscalls son de bloqueo, lo que significa que su programa espera a que el kernel regrese a su código.
  • El kernel 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 syscall. En el mundo real, el kernel podría tener que hacer una serie de cosas para cumplir con su solicitud, incluyendo la espera de que el dispositivo esté listo, la actualización de su estado interno, etc., pero como desarrollador de aplicaciones, usted no se preocupa por eso. Ese es el trabajo del kernel.

Llamadas de bloqueo vs. llamadas sin bloqueo

Ahora, acabo de decir arriba que las syscalls son de bloqueo, y eso es cierto en un sentido general. Sin embargo, algunas llamadas están categorizadas como «no bloqueantes», lo que significa que el kernel toma su solicitud, la pone en cola o en un buffer en algún lugar, y luego regresa inmediatamente sin esperar a que ocurra la E/S real. Así que se «bloquea» sólo por un breve período de tiempo, sólo el tiempo suficiente para poner en cola su solicitud.

Algunos ejemplos (de syscalls de Linux) podrían ayudar a aclarar:- read() es una llamada de bloqueo – usted le pasa una manija diciendo qué archivo y un búfer de donde 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 manijas para escuchar, agregar / quitar manejadores de ese grupo y luego bloquear hasta que haya alguna actividad. Esto te permite controlar eficientemente un gran número de operaciones de E/S con un solo hilo, pero me estoy adelantando. Esto es genial si necesitas la funcionalidad, pero como puedes ver es ciertamente más complejo de usar.

Es importante entender el orden de magnitud de la diferencia en el tiempo aquí. Si un núcleo de la CPU está funcionando a 3GHz, sin entrar en las optimizaciones que la CPU puede hacer, está realizando 3 mil millones de ciclos por segundo (o 3 ciclos por nanosegundo). Una llamada al sistema que no se bloquea puede tardar del orden de 10 ciclos en completarse, es decir, «relativamente pocos nanosegundos». Una llamada que se bloquea por la información que se recibe en 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 tardó 20 nanosegundos, y la llamada con bloqueo tardó 200.000.000 nanosegundos. Su proceso acaba de esperar 10 millones de veces más por la llamada bloqueante.

El kernel proporciona los medios para hacer tanto E/S bloqueante («lee de esta conexión de red y dame los datos») como E/S no bloqueante («dime cuando alguna de estas conexiones de red tenga nuevos datos»). Y el mecanismo que se utilice bloqueará el proceso de llamada durante periodos de tiempo drásticamente diferentes.

Programación

La tercera cosa que es fundamental seguir es lo que ocurre cuando se tienen muchos hilos o procesos que empiezan 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 como los hilos comparten la misma memoria, y los procesos tienen cada uno su propio espacio de memoria, hacer 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 obtener una porción de tiempo de ejecución en los núcleos disponibles de la CPU. Si tienes 300 hilos en ejecución y 8 núcleos para ejecutarlos, tienes que dividir el tiempo para que cada uno obtenga su parte, con cada núcleo en ejecución durante un corto período de tiempo y luego pasar al siguiente hilo. Esto se hace a través de un «cambio de contexto», haciendo que la CPU pase de ejecutar un hilo/proceso al siguiente.

Estos cambios de contexto tienen un coste asociado: llevan algo de tiempo. En algunos casos rápidos, pueden ser menos de 100 nanosegundos, pero no es raro que tarden 1000 nanosegundos o más, dependiendo de los detalles de la implementación, la velocidad/arquitectura del procesador, la caché de la CPU, etc.

Y cuantos más hilos (o procesos), más cambios de contexto. Cuando hablamos de miles de hilos, y cientos de nanosegundos para cada uno, las cosas pueden volverse muy lentas.

Sin embargo, las llamadas no bloqueantes en esencia le dicen al kernel «sólo llámame cuando tengas algún dato o evento nuevo en alguna de estas conexiones». Estas llamadas no bloqueantes están diseñadas para manejar eficientemente grandes cargas de E/S y reducir el cambio de contexto.

¿Hasta aquí? Porque ahora viene la parte divertida: Veamos lo que hacen algunos lenguajes populares con estas herramientas y saquemos algunas conclusiones sobre las compensaciones entre la facilidad de uso y el rendimiento… y otras cositas interesantes.

Como nota, mientras que los ejemplos mostrados en este artículo son triviales (y parciales, con sólo las partes relevantes mostradas); el acceso a la base de datos, los sistemas de caché externos (memcache, etc.) y cualquier cosa que requiera E/S va a terminar realizando algún tipo de llamada de E/S bajo el capó que tendrá el mismo efecto que los ejemplos simples mostrados. Además, para los escenarios en los que la E/S se describe como «bloqueante» (PHP, Java), las lecturas y escrituras de solicitudes y respuestas HTTP son en sí mismas llamadas bloqueantes: De nuevo, más E/S oculta en el sistema con sus correspondientes problemas de rendimiento a 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 sólo se considera el rendimiento. Pero, si le preocupa que su programa se vea limitado principalmente por la E/S, si el rendimiento de la E/S es decisivo para su proyecto, estas son las cosas que necesita saber.

El enfoque de «mantenerlo simple»: PHP

En los años 90, mucha gente usaba zapatos Converse y escribía scripts CGI en Perl. Entonces llegó PHP y, por mucho que a algunos les guste criticarlo, hizo que hacer páginas web dinámicas fuera mucho más fácil.

El modelo que utiliza PHP es bastante simple. Hay algunas variaciones, pero su servidor PHP promedio se parece a:

Una solicitud HTTP viene del navegador de un usuario y golpea su servidor web Apache. Apache crea un proceso separado para cada petición, con algunas optimizaciones para reutilizarlos con el fin de minimizar cuántos tiene que hacer (crear procesos es, relativamente, lento).Apache llama a PHP y le dice que ejecute el archivo .php apropiado en el disco.El código PHP se ejecuta y hace llamadas de E/S de bloqueo. Usted llama a file_get_contents() en PHP y bajo el capó hace read() llamadas al sistema y espera los resultados.

Y, por supuesto, el código real está simplemente incrustado en su página, y las operaciones son de bloqueo:

<?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í:

Muy simple: un proceso por solicitud. Las llamadas de E/S sólo se bloquean. ¿Ventaja? Es simple y funciona. ¿Desventaja? Si se le da a 20.000 clientes simultáneamente, el servidor estallará en llamas. Este enfoque no escala bien porque las herramientas proporcionadas por el kernel para tratar con un alto volumen de E/S (epoll, etc.) no están siendo utilizadas. Y para añadir el insulto a la herida, la ejecución de un proceso separado para cada solicitud tiende a utilizar una gran cantidad de recursos del sistema, especialmente la memoria, que a menudo es lo primero que se agota en un escenario como este.

Nota: El enfoque utilizado para Ruby es muy similar al de PHP, y de una manera amplia, general, de la mano pueden ser considerados iguales para nuestros propósitos.

El Enfoque Multihilo: Java

Así que Java aparece, justo en el momento en que compraste tu primer nombre de dominio y era genial decir al azar «punto com» después de una frase. Y Java tiene multithreading incorporado en el lenguaje, que (especialmente para cuando fue creado) es bastante impresionante.

La mayoría de los servidores web de Java funcionan iniciando un nuevo hilo de ejecución para cada solicitud que entra y luego en este hilo eventualmente llamar a la función que usted, como el desarrollador de la aplicación, escribió.

Hacer E/S en un Java Servlet tiende a ser algo así:

public void doGet(HttpServletRequest request,HttpServletResponse response) throws ServletException, IOException{// blocking file I/OInputStream fileIs = new FileInputStream("/path/to/file");// blocking network I/OURLConnection urlConnection = (new URL("http://example.com/example-microservice")).openConnection();InputStream netIs = urlConnection.getInputStream();// some more blocking network I/Oout.println("...");}

Como nuestro método doGetde arriba corresponde a una petición y se ejecuta en su propio hilo, en lugar de un proceso separado para cada petición que requiere su propia memoria, tenemos un hilo separado. Esto tiene algunas ventajas, como poder compartir el estado, los datos en caché, etc. entre hilos porque pueden acceder a la memoria de cada uno, pero el impacto en la forma en que interactúa con el programa sigue siendo casi idéntico a lo que se está haciendo en el ejemplo de PHP anteriormente. Cada solicitud obtiene un nuevo hilo y las diversas operaciones de E/S se bloquean dentro de ese hilo hasta que la solicitud es manejada completamente. Los hilos se agrupan para minimizar el coste de crearlos y destruirlos, pero aún así, miles de conexiones significan miles de hilos, lo que es malo para el programador.

Un hito importante es que en la versión 1.4 Java (y una actualización significativa de nuevo en la 1.7) obtuvo la capacidad de hacer 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 tratan de aprovechar esto de varias maneras; sin embargo, la gran mayoría de las aplicaciones Java desplegadas todavía funcionan como se ha descrito anteriormente.

Java nos acerca y ciertamente tiene una buena funcionalidad out-of-the-box para E/S, pero todavía no resuelve realmente el problema de lo que sucede cuando se tiene una aplicación fuertemente ligada a la E/S que está siendo machacada con muchos miles de hilos de bloqueo.

La E/S sin bloqueo como ciudadano de primera clase: Node

El chico popular del barrio cuando se trata de mejorar la E/S es Node.js. A cualquiera que haya tenido la más breve introducción a Node se le ha dicho que es «no bloqueante» y que maneja la E/S eficientemente. Y esto es cierto en un sentido general. Pero el diablo está en los detalles y los medios por los que 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 código aquí para empezar a manejar la solicitud.» Cada vez que necesites hacer algo que implique E/S, haces la petición y das una función de callback que Node llamará cuando haya terminado.

El código típico de Node para hacer una operación de E/S en una petición va así:

http.createServer(function(request, response) {fs.readFile('/path/to/file', 'utf8', function(err, data) {response.end(data);});});

Como puedes ver, hay dos funciones de callback aquí. La primera se llama cuando se inicia una petición, y la segunda se llama cuando los datos del archivo están disponibles.

Lo que hace esto es básicamente dar a Node una oportunidad de manejar eficientemente 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 callback, éste realiza las operaciones de E/S por separado usando llamadas sin bloqueo y luego invoca tu función de callback cuando los datos que pediste 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 llama «Bucle de Eventos». Y funciona bastante bien.

Sin embargo, hay una trampa en este modelo. Bajo el capó, la razón tiene mucho más que ver con la forma en que el motor V8 JavaScript (el motor JS de Chrome que es utilizado por Node) se implementa 1 que cualquier otra cosa. El código JS que escribes se ejecuta en un solo hilo. Piensa en eso por un momento. Significa que mientras la E/S se realiza usando técnicas eficientes de no-bloqueo, tu lata de JS que está haciendo operaciones ligadas a la CPU se ejecuta en un solo hilo, cada trozo de código bloqueando al siguiente. Un ejemplo común en el que esto podría surgir es en el bucle de los registros de la base de datos para procesarlos de alguna manera antes de enviarlos al cliente. Aquí hay 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})};

Aunque Node maneja la E/S eficientemente, ese bucle for en el ejemplo anterior está usando ciclos de CPU dentro de su único hilo principal. Esto significa que si tienes 10.000 conexiones, ese bucle podría hacer que toda tu aplicación se ralentizara, dependiendo de lo que tardara. Cada petición debe compartir una porción de tiempo, una 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 eficientemente, incluso si significa hacer otros procesamientos en serie. Esto es cierto en algunos casos, pero no en todos.

El otro punto es que, y aunque esto es sólo una opinión, puede ser bastante cansado escribir un montón de callbacks anidados y algunos argumentan que hace el código significativamente más difícil de seguir. No es raro ver devoluciones de llamada anidadas a cuatro, cinco o incluso más niveles de profundidad dentro del código Node.

Volvemos a las compensaciones. El modelo Node funciona bien si tu principal problema de rendimiento es la E/S. Sin embargo, su talón de Aquiles es que se puede entrar en una función que está manejando una solicitud HTTP y poner en el código de uso intensivo de la CPU y traer cada conexión a un rastreo si usted no es cuidadoso.

Naturalmente no-bloqueo: Go

Antes de entrar en la sección de Go, es conveniente que revele que soy un fanboy de Go. Lo he usado para muchos proyectos y soy abiertamente un defensor 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 hilo de ejecución corresponda a un único hilo del SO, trabaja con el concepto de «goroutines». Y el tiempo de ejecución de Go puede asignar una goroutine a un hilo del SO y hacer que se ejecute, o suspenderla y hacer que no se asocie a un hilo del SO, en función de lo que esté haciendo esa goroutine. Cada solicitud que llega desde el servidor HTTP de Go se maneja en una goroutina separada.

El diagrama de cómo funciona el planificador se ve así:

Bajo el capó, esto es implementado por varios puntos en el tiempo de ejecución de Go que implementan la llamada de E/S haciendo la solicitud de escribir/leer/conectar/etc., poner la goroutina actual en reposo, con la información para despertar la goroutina de nuevo cuando se puede tomar otra acción.

En efecto, el tiempo de ejecución Go está haciendo algo no terriblemente diferente a lo que Node está haciendo, excepto que el mecanismo de devolución de llamada se construye en la implementación de la llamada de E/S e interactúa con el planificador de forma automática. Además, no sufre la restricción de tener que ejecutar todo el código del manejador en el mismo hilo, Go asignará automáticamente sus Goroutines a tantos hilos del sistema operativo que considere apropiados basándose en la lógica de su planificador. El resultado es un código como este:

func ServeHTTP(w http.ResponseWriter, r *http.Request) {// the underlying network call here is non-blockingrows, 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 puedes ver arriba, la estructura básica del código de lo que estamos haciendo se asemeja a la de los enfoques más simplistas, y sin embargo logra una E/S sin bloqueo bajo el capó.

En la mayoría de los casos, esto termina siendo «lo mejor de ambos mundos». La E/S sin bloqueo se utiliza para todas las cosas importantes, pero su código parece que está 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 SO se encarga del resto. No es magia completa, y si construyes un sistema grande, vale la pena dedicar tiempo a entender más detalles sobre cómo funciona; pero al mismo tiempo, el entorno que obtienes «out-of-the-box» funciona y escala bastante bien.

Go puede tener sus defectos, pero en general, la forma en que maneja I/O no está entre ellos.

Mentiras, malditas mentiras y Benchmarks

Es difícil dar tiempos exactos sobre el cambio de contexto involucrado con estos diversos modelos. También podría argumentar que es menos útil para usted. Así que en su lugar, te daré algunos puntos de referencia básicos que comparan el rendimiento general del servidor HTTP de estos entornos de servidor. Ten en cuenta que hay muchos factores que intervienen en el rendimiento de toda la ruta de solicitud/respuesta HTTP de extremo a extremo, y los números que se presentan aquí son sólo algunas muestras que he reunido para ofrecer una comparación básica.

Para cada uno de estos entornos, escribí el código apropiado para leer un archivo de 64k con bytes aleatorios, ejecutar un hash SHA-256 en él N veces (N se especifica en la cadena de consulta de la URL, por ejemplo, .../test.php?n=100) e imprimir el hash resultante en hexadecimal. Elegí esto porque es una forma muy simple de ejecutar los mismos benchmarks con algo de I/O consistente y una forma controlada de aumentar el uso de la CPU.

Vea estas notas de benchmark para un poco más de detalle en los entornos utilizados.

Primero, veamos algunos ejemplos de baja concurrencia. Ejecutando 2000 iteraciones con 300 peticiones concurrentes y sólo un hash por petición (N=1) nos da esto:

Los tiempos son el número medio de milisegundos para completar una petición entre todas las peticiones concurrentes. Más bajo es mejor.

Es difícil sacar una conclusión de sólo este gráfico, pero a mí me parece que, a este volumen de conexión y computación, estamos viendo tiempos que tienen más que ver con la ejecución general de los propios lenguajes, mucho más que con la E/S. Nótese que los lenguajes que se consideran «lenguajes de scripting» (tipado suelto, interpretación dinámica) son los más lentos.

Pero qué pasa si aumentamos N a 1000, todavía con 300 peticiones concurrentes – la misma carga pero 100x más iteraciones de hash (significativamente más carga de CPU):

Los tiempos son el número medio de milisegundos para completar una petición entre todas las peticiones concurrentes. Cuanto más bajo, mejor.

De repente, el rendimiento de Node cae significativamente, porque las operaciones intensivas 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 notar que en PHP la implementación de SHA-256 está escrita en C y la ruta de ejecución está gastando mucho más tiempo en ese bucle, ya que estamos haciendo 1000 iteraciones de hash ahora).

Ahora probemos 5000 conexiones concurrentes (con N=1) – o lo más cercano a eso que pude llegar. Desafortunadamente, para la mayoría de estos entornos, la tasa de fallos no era insignificante. Para este gráfico, miraremos el número total de peticiones por segundo. Cuanto más alto, mejor:

Número total de peticiones por segundo. Cuanto más alto mejor.

Y el panorama se ve bastante diferente. Es una suposición, pero parece que a un alto volumen de conexiones, la sobrecarga por conexión involucrada con el desove de nuevos procesos y la memoria adicional asociada con ello en PHP+Apache parece convertirse en un factor dominante y debilita el rendimiento de PHP. Claramente, Go es el ganador aquí, seguido por Java, Node y, finalmente, PHP.

Aunque los factores que intervienen en el rendimiento general son muchos y también varían ampliamente de una aplicación a otra, cuanto más entiendas sobre las tripas de lo que está pasando bajo el capó y las compensaciones involucradas, mejor estarás.

En resumen

Con todo lo anterior, está bastante claro que a medida que los lenguajes han evolucionado, las soluciones para hacer frente a las aplicaciones a gran escala que hacen un montón de E/S han evolucionado con él.

Para ser justos, tanto PHP como Java, a pesar de las descripciones en este artículo, tienen implementaciones de E/S sin bloqueo disponibles para su uso en aplicaciones web. Pero no son tan comunes como los enfoques descritos anteriormente, y habría que tener en cuenta la sobrecarga operativa que conlleva el mantenimiento de los servidores que utilizan dichos enfoques. Por no mencionar que su código debe estar estructurado de manera que funcione con tales entornos; su aplicación web «normal» de PHP o Java normalmente no se ejecutará sin modificaciones significativas en un entorno de este tipo.

Como comparación, si tenemos en cuenta algunos factores significativos que afectan al rendimiento, así como a la facilidad de uso, obtenemos esto:

Lenguaje Hilos vs. Procesos SinE/S de bloqueo Facilidad de uso
PHP Procesos No
Java Hilos Disponible Requiere Callbacks
Node.js Hilos Requiere Callbacks
Ir Hilos (Goroutines) No se necesitan callbacks

Los hilos van a ser generalmente mucho más eficientes en memoria que los procesos, ya que comparten el mismo espacio de memoria mientras que los procesos no. Combinando esto con los factores relacionados con la E/S sin bloqueo, podemos ver que, al menos con los factores considerados anteriormente, a medida que bajamos en la lista, la configuración general en relación con la E/S mejora. Así que si tuviera que elegir un ganador en el concurso anterior, sin duda sería Go.

Aún así, en la práctica, la elección de un entorno en el que construir su aplicación está estrechamente relacionada con la familiaridad que su equipo tiene con dicho entorno, y la productividad general que puede lograr con él. Por lo tanto, puede que no tenga sentido que todos los equipos se lancen a desarrollar aplicaciones y servicios web en Node o Go. De hecho, la búsqueda de desarrolladores o la familiaridad de su equipo interno se cita a menudo como la principal razón para no utilizar un lenguaje y/o entorno diferente. Dicho esto, los tiempos han cambiado en los últimos quince años más o menos, mucho.

Esperemos que lo anterior ayude a pintar una imagen más clara de lo que está sucediendo bajo el capó y le da algunas ideas de cómo hacer frente a la escalabilidad del mundo real para su aplicación. Feliz entrada y salida!

Deja una respuesta

Tu dirección de correo electrónico no será publicada.