El día que Dodo se detuvo. Script asincrónico

Hola Habr! Cada SRE en nuestro equipo alguna vez soñó con dormir tranquilo por la noche. Los sueños se hacen realidad. En este artículo hablaré sobre esto y cómo logramos el rendimiento y la estabilidad de nuestro sistema Dodo IS.


Una serie de artículos sobre el colapso del sistema Dodo IS * :

1. El día que Dodo se detuvo. Script sincrónico.
2. El día en que Dodo se detuvo. Script asincrónico

* Los materiales fueron escritos en base a mi desempeño en DotNext 2018 en Moscú .
En un artículo anterior, vimos problemas de código de bloqueo en el paradigma de la multitarea preventiva. Se suponía que era necesario reescribir el código de bloqueo en async / wait. Entonces lo hicimos. Ahora hablemos sobre los problemas que surgieron cuando hicimos esto.

Introducimos el término concurrencia


Antes de llegar a asíncrono, debe ingresar el término Concurrencia.
En la teoría de colas, la concurrencia es el número de clientes que están actualmente dentro del sistema. La concurrencia a veces se confunde con el paralelismo, pero en realidad estas son dos cosas diferentes.
Para aquellos nuevos en Concurrency por primera vez, les recomiendo el video de Rob Pike . La concurrencia es cuando estamos tratando con muchas cosas al mismo tiempo, y el paralelismo es cuando estamos haciendo muchas cosas al mismo tiempo.

En las computadoras, no suceden muchas cosas en paralelo. Una de esas cosas es la computación en múltiples procesadores. El grado de paralelismo está limitado por el número de subprocesos de la CPU.

De hecho, Threads es parte del concepto de multitarea preventiva, una de las formas de modelar la concurrencia en un programa cuando confiamos en el sistema operativo en la pregunta de concurrencia. Este modelo sigue siendo útil siempre y cuando comprendamos que estamos tratando específicamente con el modelo de concurrencia, y no con la concurrencia.

Async / await es azúcar sintáctico para State Machine, otro modelo de concurrencia útil que puede ejecutarse en un entorno de subproceso único. En esencia, esto es multitarea cooperativa: el modelo en sí mismo no tiene en cuenta el paralelismo en absoluto. En combinación con Multithreading, tenemos un modelo encima de otro, y la vida es muy complicada.

Comparación de los dos modelos.


Cómo funcionó en el modelo de multitarea preventiva


Digamos que tenemos 20 subprocesos y 20 solicitudes en proceso por segundo. La imagen muestra un pico: 200 solicitudes en el sistema al mismo tiempo. ¿Cómo podría suceder esto?

  • las solicitudes podrían agruparse si 200 clientes hacían clic en un botón al mismo tiempo;
  • el recolector de basura podría detener las solicitudes de varias decenas de milisegundos;
  • las solicitudes podrían retrasarse en cualquier cola si el proxy admite la cola.

Hay muchas razones por las cuales las solicitudes por un corto período de tiempo se han acumulado y vienen en un solo paquete. En cualquier caso, no pasó nada terrible, se pararon en la cola de Thread Pool y se completaron lentamente. No hay más picos, todo continúa, como si nada hubiera pasado.

Suponga que el algoritmo inteligente de Thread Pool (y hay elementos de aprendizaje automático allí) decidió que hasta ahora no hay razón para aumentar el número de Threads. El Pool de conexiones en MySql también es 20 porque Threads = 20. En consecuencia, solo necesitamos 20 conexiones a SQL.



En este caso, el nivel de concurrencia del servidor desde el punto de vista del sistema externo = 200. El servidor ya ha recibido estas solicitudes, pero aún no lo ha completado. Sin embargo, para una aplicación que se ejecuta en el paradigma Multithreading, el número de solicitudes simultáneas está limitado por el tamaño actual de Thread Pool = 20. Por lo tanto, estamos tratando con el grado de Concurrencia = 20.

Cómo funciona todo ahora en el modelo asíncrono




Veamos qué sucede en una aplicación que se ejecuta async / wait con la misma carga y distribución de solicitudes. No hay cola antes de crear una Tarea, y la solicitud se procesa inmediatamente. Por supuesto, Thread de ThreadPool se usa por un corto tiempo, y la primera parte de la solicitud, antes de contactar con la base de datos, se ejecuta de inmediato. Debido a que Thread regresa rápidamente a Thread Pool, no necesitamos muchos Threads para procesar. En este diagrama no mostramos Thread Pool en absoluto, es transparente.



¿Qué significará esto para nuestra aplicación? La imagen externa es la misma: el nivel de concurrencia = 200. Al mismo tiempo, la situación en el interior ha cambiado. Anteriormente, las solicitudes estaban "llenas" en la cola ThreadPool, ahora el grado de concurrencia de la aplicación también es 200, porque no tenemos restricciones por parte de TaskScheduler. ¡Hurra! Hemos logrado el objetivo de asíncrono: la aplicación "hace frente" a casi cualquier grado de concurrencia.

Consecuencias: degradación no lineal del sistema.


La aplicación se ha vuelto transparente desde el punto de vista de la concurrencia, por lo que ahora se proyecta la concurrencia en la base de datos. Ahora necesitamos un grupo de conexiones del mismo tamaño = 200. La base de datos es la CPU, memoria, red, almacenamiento. Este es el mismo servicio con sus problemas, como cualquier otro. Cuantas más solicitudes intentemos ejecutar al mismo tiempo, más lento se ejecutarán.

A plena carga en la base de datos, en el mejor de los casos, el tiempo de respuesta se degrada linealmente: usted dio el doble de consultas, comenzó a funcionar el doble de lento. En la práctica, debido a la competencia de consultas, necesariamente se producirá una sobrecarga y puede resultar que el sistema se degradará de manera no lineal.

¿Por qué está pasando esto?


Razones para el segundo orden:

  • Ahora la base de datos debe mantenerse simultáneamente en la memoria de la estructura de datos para atender más solicitudes;
  • Ahora la base de datos necesita servir colecciones más grandes (y esto es algorítmicamente desventajoso).

Motivo de primer orden:


Al final, async lucha contra recursos limitados y ... ¡gana! La base de datos falla y comienza a ralentizarse. A partir de esto, el servidor aumenta aún más la concurrencia y el sistema ya no puede salir de esta situación con honor.

Síndrome de muerte súbita del servidor


A veces ocurre una situación interesante. Tenemos un servidor Él trabaja para sí mismo así, todo está en orden. Hay suficientes recursos, incluso con un margen. Luego, de repente, recibimos un mensaje de los clientes de que el servidor se está ralentizando. Observamos el gráfico y vemos que hubo un aumento repentino en la actividad del cliente, pero ahora todo es normal. Pensando en un ataque de DOS o una coincidencia. Ahora todo parece estar bien. Solo que ahora el servidor continúa siendo estúpido, y todo es más difícil hasta que los tiempos de espera comienzan a llegar. Después de un tiempo, otro servidor que usa la misma base de datos también comienza a doblarse. ¿Una situación familiar?

¿Por qué murió el sistema?


Puede intentar explicar esto por el hecho de que en algún momento el servidor recibió un número máximo de solicitudes y se "rompió". Pero sí sabemos que la carga se redujo, y el servidor después de eso no mejoró durante mucho tiempo, hasta que la carga desapareció por completo.

La pregunta retórica: ¿se suponía que el servidor se rompería debido a una carga excesiva? ¿Hacen eso?

Simulamos una situación de bloqueo del servidor


Aquí no analizaremos gráficos de un sistema de producción real. En el momento del bloqueo del servidor, a menudo no podemos obtener ese horario. El servidor se está quedando sin recursos de CPU y, como resultado, no puede escribir registros, dar métricas. En los diagramas en el momento del desastre, a menudo se observa una ruptura en todos los gráficos.

Los SRE deberían poder producir sistemas de monitoreo que sean menos propensos a este efecto. Los sistemas que en cualquier situación proporcionan al menos algo de información y, al mismo tiempo, pueden analizar sistemas post-mortem utilizando información fragmentaria. Para fines educativos, utilizamos un enfoque ligeramente diferente en este artículo.

Intentemos crear un modelo que funcione matemáticamente como un servidor bajo carga. A continuación, estudiaremos las características del servidor. Descartamos la no linealidad de los servidores reales y simulamos una situación en la que se produce una desaceleración lineal cuando la carga crece por encima del nominal. El doble de solicitudes que sea necesario: atendemos el doble de lento.

Este enfoque permitirá:

  • considere lo que sucederá en el mejor de los casos;
  • tomar métricas precisas

Navegación programada:

  • azul: el número de solicitudes al servidor;
  • verde: respuestas del servidor;
  • amarillo - tiempos de espera;
  • gris oscuro: solicitudes que se desperdiciaron en los recursos del servidor porque el cliente no esperó una respuesta de tiempo de espera. A veces, un cliente puede informar esto al servidor desconectándose, pero en general, tal lujo puede ser técnicamente imposible, por ejemplo, si el servidor realiza un trabajo vinculado a la CPU, sin cooperación con el cliente.




¿Por qué el gráfico de solicitud del cliente (azul en el diagrama) resultó ser así? Por lo general, el horario de pedidos en nuestras pizzerías crece suavemente por la mañana y disminuye por la noche. Pero observamos tres picos en el contexto de la curva uniforme habitual. Esta forma del gráfico no fue elegida para el modelo por casualidad, sino más bien. La modelo nació durante la investigación de un incidente real con el servidor del centro de contacto de la pizzería en Rusia durante la Copa del Mundo.

Caso "Copa Mundial"


Nos sentamos y esperamos más pedidos. Preparados para el Campeonato, ahora los servidores podrán pasar una prueba de fuerza.

El primer pico: los fanáticos del fútbol van a ver el campeonato, tienen hambre y compran pizza. Durante la primera mitad, están ocupados y no pueden ordenar. Pero las personas que son indiferentes al fútbol pueden, así que en la tabla todo continúa como de costumbre.

Y luego termina la primera mitad, y llega el segundo pico. Los fanáticos se pusieron nerviosos, hambrientos e hicieron tres veces más pedidos que en el primer pico. La pizza se compra a un precio terrible. Luego comienza la segunda mitad, y de nuevo no a la pizza.

Mientras tanto, el servidor del centro de contacto comienza a doblarse lentamente y atender solicitudes cada vez más lentamente. El componente del sistema, en este caso, el servidor web de Call Center, está desestabilizado.

El tercer pico llegará cuando termine el partido. Los fanáticos y el sistema esperan una penalización.

Analizamos los motivos del bloqueo del servidor.


Que paso El servidor podría contener 100 solicitudes condicionales. Entendemos que está diseñado para este poder y ya no lo soportará. Llega un pico, que en sí mismo no es tan grande. Pero el área gris de la concurrencia es mucho más alta.

El modelo está diseñado para que la concurrencia sea numéricamente igual al número de órdenes por segundo, por lo que visualmente en el gráfico debe ser de la misma escala. Sin embargo, es mucho más alto porque se acumula.

Aquí vemos una sombra del gráfico: estas son solicitudes que comenzaron a regresar al cliente, ejecutadas (mostradas por la primera flecha roja). La escala de tiempo es condicional para ver el desplazamiento de tiempo. El segundo pico ya noqueó a nuestro servidor. Se estrelló y comenzó a procesar cuatro veces menos solicitudes de lo habitual.



En la segunda mitad del gráfico, está claro que algunas solicitudes todavía se ejecutaron al principio, pero luego aparecieron manchas amarillas: las solicitudes se detuvieron por completo.



Una vez más todo el horario. Se puede ver que la concurrencia se está volviendo loca. Aparece una gran montaña.



Por lo general, analizamos métricas completamente diferentes: qué tan lento se completó la solicitud, cuántas solicitudes por segundo. Ni siquiera miramos la concurrencia, ni siquiera pensamos en esta métrica. Pero en vano, porque es precisamente esta cantidad la que mejor muestra el momento del fallo del servidor.

¿Pero de dónde vino una montaña tan grande? ¡El pico de carga más grande ya ha pasado!

Poca ley


La ley de Little rige la concurrencia.

L (número de clientes dentro del sistema) = λ (velocidad de su estadía) ∗ W (tiempo que pasan dentro del sistema)

Este es un promedio. Sin embargo, nuestra situación se está desarrollando dramáticamente, el promedio no nos conviene. Diferenciaremos esta ecuación, luego la integraremos. Para hacer esto, mire el libro de John Little, quien inventó esta fórmula, y vea la integral allí.



Tenemos el número de entradas en el sistema y el número de quienes abandonan el sistema. La solicitud llega y se va cuando todo está completo. A continuación se muestra una región de crecimiento gráfico correspondiente al crecimiento lineal de la concurrencia.



Hay pocas solicitudes verdes. Estos son los que realmente se están implementando. Los azules son los que vienen. Entre tiempos, tenemos el número habitual de solicitudes, la situación es estable. Pero la concurrencia sigue creciendo. El servidor ya no hará frente a esta situación en sí. Esto significa que caerá pronto.

Pero, ¿por qué aumenta la concurrencia? Nos fijamos en la integral de la constante. Nada cambia en nuestro sistema, pero la integral parece una función lineal que solo crece.

Vamos a jugar?


La explicación con las integrales es complicada si no recuerdas las matemáticas. Aquí te propongo calentar y jugar el juego.

Juego número 1


Requisitos previos : el servidor recibe solicitudes, cada una requiere tres períodos de procesamiento en la CPU. El recurso de la CPU se divide en partes iguales entre todas las tareas. Esto es similar a cómo se consumen los recursos de la CPU durante la multitarea preventiva. El número en la celda significa la cantidad de trabajo que queda después de esta medida. Para cada paso condicional, llega una nueva solicitud.

Imagine que recibió una solicitud. Solo quedan 3 unidades de trabajo, al final del primer período de procesamiento quedan 2 unidades.

En el segundo período, hay otra solicitud en capas, ahora ambas CPU están ocupadas. Hicieron una unidad de trabajo para las dos primeras consultas. Queda por completar 1 y 2 unidades para la primera y segunda solicitud, respectivamente.

Ahora ha llegado la tercera solicitud, y comienza la diversión. Parece que la primera solicitud debería haberse completado, pero en este período tres solicitudes ya comparten el recurso de la CPU, por lo que el grado de finalización de las tres solicitudes ahora es fraccional al final del tercer período de procesamiento:



Además más interesante! Se agrega la cuarta solicitud, y ahora el grado de concurrencia ya es 4, ya que las cuatro solicitudes requieren un recurso en este período. Mientras tanto, la primera solicitud al final del cuarto período ya se ha completado, no pasa al siguiente período y le quedan 0 trabajos para la CPU.

Como la primera solicitud ya se completó, resumamos para él: se ejecutó un tercio más de lo que esperábamos. Se supuso que la longitud de cada tarea horizontalmente idealmente = 3, de acuerdo con la cantidad de trabajo. Lo marcamos con naranja, como señal de que no estamos completamente satisfechos con el resultado.



Llega la quinta solicitud. El grado de concurrencia sigue siendo 4, pero vemos que en la quinta columna el trabajo restante es más total. Esto se debe a que queda más trabajo en la cuarta columna que en la tercera.

Continuamos tres períodos más. Esperando respuestas.
- Servidor, hola!
- ...



"Tu llamada es muy importante para nosotros ..."



Bueno, finalmente llegó la respuesta a la segunda solicitud. Los tiempos de respuesta son el doble de lo esperado.



El grado de concurrencia ya se ha triplicado, y nada augura que la situación cambiará para mejor. No dibujé más, porque el tiempo de respuesta a la tercera solicitud ya no cabe en la imagen.

Nuestro servidor ha entrado en un estado no deseado, del cual nunca saldrá por sí solo. Juego terminado

¿Qué caracteriza el estado GameOver del servidor?


Las solicitudes se acumulan en la memoria indefinidamente. Tarde o temprano, la memoria simplemente terminará. Además, con un aumento en la escala, aumenta la sobrecarga de la CPU para dar servicio a diversas estructuras de datos. Por ejemplo, el grupo de conexiones ahora debe rastrear los tiempos de espera para más conexiones, el recolector de basura ahora debe verificar dos veces más objetos en el montón, y así sucesivamente.

Investigar todas las posibles consecuencias de la acumulación de objetos activos no es el propósito de este artículo, pero incluso una simple acumulación de datos en la RAM ya es suficiente para llenar el servidor. Además, ya hemos visto que el servidor del cliente proyecta sus problemas de concurrencia en el servidor de la base de datos y en otros servidores que utiliza como cliente.

Lo más interesante: ahora, incluso si envía una carga menor al servidor, aún no se recuperará. Todas las solicitudes finalizarán con un tiempo de espera y el servidor consumirá todos los recursos disponibles.

¿Y qué esperábamos realmente? Después de todo, a sabiendas le dimos al servidor una cantidad de trabajo que no podía manejar.

Cuando se trata de arquitectura de sistema distribuido, es útil pensar en cómo la gente común resuelve tales problemas. Tome, por ejemplo, una discoteca. Dejará de funcionar si ingresan demasiadas personas. El portero hace frente al problema simplemente: mira cuántas personas hay dentro. Una izquierda - lanza otra. Un nuevo invitado vendrá y apreciará el tamaño de la cola. Si la cola es larga, se irá a casa. ¿Qué pasa si aplica este algoritmo al servidor?



Juguemos de nuevo.

Juego número 2


Requisitos previos : nuevamente tenemos dos CPU, las mismas tareas de 3 unidades, llegando a cada período, pero ahora configuraremos el dispositivo de seguridad, y las tareas serán inteligentes: si ven que la longitud de la cola es 2, se van a casa de inmediato.





Llegó la tercera solicitud. En este período, él hace cola. Tiene el número 3 al final del período. No hay números fraccionarios en los residuos, porque dos CPU realizan dos tareas, una por un período.

Aunque tenemos tres solicitudes en capas, el grado de concurrencia dentro del sistema = 2. La tercera está en la cola y no cuenta.



Llegó el cuarto: la misma imagen, aunque ya se ha acumulado más trabajo.


...
...

En el sexto período, la tercera solicitud se completó con un tercer retraso, y el grado de concurrencia ya es = 4.



El grado de concurrencia se ha duplicado. Ella ya no puede crecer, porque hemos establecido una prohibición clara sobre esto. Con la velocidad máxima, solo se completaron las dos primeras solicitudes: las que vinieron primero al club, mientras había suficiente espacio para todos.

Las solicitudes amarillas estuvieron en el sistema durante más tiempo, pero se mantuvieron en línea y no retrasaron el recurso de la CPU. Por lo tanto, los que estaban adentro se estaban divirtiendo. Esto podría continuar hasta que un hombre viniera y dijera que no haría cola, sino que se iría a casa. Esta es una solicitud fallida:



La situación se puede repetir sin cesar, mientras que el tiempo de ejecución de la consulta permanece en el mismo nivel, exactamente el doble de lo que quisiéramos.



Vemos que una restricción simple en el nivel de concurrencia elimina el problema de viabilidad del servidor.

Cómo aumentar la viabilidad del servidor a través del límite de nivel de concurrencia


Puedes escribir el "gorila" más simple tú mismo. A continuación se muestra el código que utiliza el semáforo. No hay límite para la longitud de la línea exterior. , .

const int MaxConcurrency = 100; SemaphoreSlim bulkhead = new SemaphoreSlim(MaxConcurrency, MaxConcurrency); public async Task ProcessRequest() { if (!await bulkhead.WaitAsync()) { throw new OperationCanceledException(); } try { await ProcessRequestInternal(); return; } finally { bulkhead.Release(); } } 

Para crear una cola limitada, necesita dos semáforos. Para esto, la biblioteca Polly , que Microsoft recomienda, es adecuada. Presta atención al patrón de mamparo. Traducido literalmente como "mamparo": un elemento estructural que permite que el barco no se hunda. Para ser sincero, creo que el término gorila es más adecuado. Lo importante es que este patrón permite al servidor sobrevivir en situaciones desesperadas.

Primero, exprimimos todo lo que es posible en el banco de carga del servidor hasta que determinamos cuántas solicitudes puede contener. Por ejemplo, determinamos que es 100. Ponemos mamparo.

Además, el servidor omitirá solo el número requerido de solicitudes, el resto se pondrá en cola. Sería aconsejable elegir un número un poco más pequeño para que haya un margen. No tengo ninguna recomendación sobre este tema, porque existe una fuerte dependencia del contexto y la situación específica.

  1. Si el comportamiento del servidor depende de manera estable de la carga en términos de recursos, entonces este número puede acercarse al límite.
  2. Si el medio está sujeto a fluctuaciones de carga, se debe elegir un número más conservador, teniendo en cuenta el tamaño de estas fluctuaciones. Tales fluctuaciones pueden ocurrir por varias razones, por ejemplo, el entorno de rendimiento con GC se caracteriza por pequeños picos de carga en la CPU.
  3. Si el servidor realiza tareas periódicas en un horario, esto también debe ser considerado. Incluso puede desarrollar un mamparo adaptativo que calculará cuántas consultas se pueden enviar simultáneamente sin degradación del servidor (pero esto ya está fuera del alcance de este estudio).

Consultar experimentos


Eche un vistazo a este post-mortem por último, no volveremos a ver esto.

Todo este montón gris se correlaciona inequívocamente con el bloqueo del servidor. Gray es la muerte del servidor. Vamos a cortarlo y ver qué pasa. Parece que un cierto número de solicitudes se enviarán a casa, simplemente no se cumplirán. Pero cuanto?

100 adentro, 100 afuera



Resultó que nuestro servidor comenzó a vivir muy bien y divertido. Él constantemente ara a la máxima potencia. Por supuesto, cuando ocurre un pico, lo expulsa, pero no por mucho tiempo.

Inspirado por el éxito, trataremos de asegurarnos de que no sea rebotado en absoluto. Intentemos aumentar la longitud de la cola.

100 adentro, 500 afuera




Mejoró, pero la cola creció. Estas son las solicitudes que se ejecutan mucho tiempo después.

100 adentro, 1000 afuera


Como algo ha mejorado, intentemos llevarlo al punto del absurdo. Resolvamos la longitud de la cola 10 veces más de lo que podemos servir simultáneamente:



Si hablamos de la metáfora del club y los gorilas, esta situación es casi imposible: nadie quiere esperar más tiempo en la entrada que pasar tiempo en el club. Tampoco vamos a fingir que esta es una situación normal para nuestro sistema.

Es mejor no atender al cliente en absoluto que atormentarlo en el sitio o en la aplicación móvil cargando cada pantalla durante 30 segundos y estropeando la reputación de la empresa. Es mejor decirle honestamente de inmediato a una pequeña parte de los clientes que ahora no podemos atenderlos. De lo contrario, serviremos a todos los clientes varias veces más lentamente, porque el gráfico muestra que la situación persiste durante bastante tiempo.

Hay un riesgo más: otros componentes del sistema pueden no estar diseñados para dicho comportamiento del servidor y, como ya sabemos, la concurrencia se proyecta en los clientes.

Por lo tanto, volvemos a la primera opción "100 por 100" y pensamos en cómo escalar nuestras capacidades.

Ganador: 100 adentro, 100 afuera




¯ \ _ (ツ) _ / ¯

Con estos parámetros, la mayor degradación en tiempo de ejecución es exactamente 2 veces la "nominal". Al mismo tiempo, es una degradación del 100% en el tiempo de ejecución de la consulta.

Si su cliente es sensible al tiempo de ejecución (y esto suele ser cierto tanto con clientes humanos como con clientes de servidor), puede pensar en reducir aún más la longitud de la cola. En este caso, podemos tomar un porcentaje de la concurrencia interna, y sabremos con certeza que el servicio no se degrada en tiempo de respuesta en más de este porcentaje en promedio.

De hecho, no estamos tratando de crear una cola, estamos tratando de protegernos de las fluctuaciones de carga. Aquí, al igual que en el caso de determinar el primer parámetro del mamparo (cantidad dentro), es útil determinar qué fluctuaciones en la carga puede causar el cliente. Entonces sabremos en qué casos, en términos generales, perderemos el beneficio del servicio potencial.

Es aún más importante determinar qué fluctuaciones de latencia pueden soportar otros componentes del sistema que interactúan con el servidor. Entonces sabremos que realmente estamos exprimiendo al máximo el sistema existente sin el peligro de perder el servicio por completo.

Diagnóstico y tratamiento.


Estamos tratando la concurrencia no controlada con aislamiento de mamparo.
Este método, como los otros discutidos en esta serie de artículos, es implementado convenientemente por la biblioteca Polly .

La ventaja del método es que será extremadamente difícil desestabilizar un componente individual del sistema como tal. El sistema adquiere un comportamiento muy predecible en términos de tiempo para solicitudes exitosas y posibilidades mucho mayores para solicitudes completas exitosas.

Sin embargo, no resolvemos todos los problemas. Por ejemplo, el problema de la insuficiente potencia del servidor. En esta situación, obviamente debe decidir "soltar el lastre" en caso de un salto en la carga, lo que consideramos excesivo.

Otras medidas que nuestro estudio no aborda pueden incluir, por ejemplo, el escalado dinámico.

Source: https://habr.com/ru/post/461081/


All Articles