¿Cómo causar una pérdida de memoria en una aplicación angular?

El rendimiento es la clave del éxito de una aplicación web. Por lo tanto, los desarrolladores necesitan saber cómo ocurren las pérdidas de memoria y cómo lidiar con ellas.

Este conocimiento es especialmente importante cuando la aplicación con la que se enfrenta el desarrollador alcanza un cierto tamaño. Si no presta suficiente atención a las pérdidas de memoria, entonces todo puede terminar con el desarrollador entrando en el "equipo para eliminar las pérdidas de memoria" (tenía que ser parte de ese equipo). Las pérdidas de memoria pueden ocurrir por varias razones. Sin embargo, creo que cuando usa Angular, puede encontrar un patrón que coincida con la causa más común de pérdidas de memoria. Hay una manera de lidiar con tales pérdidas de memoria. Y lo mejor, por supuesto, no es luchar contra los problemas, sino evitarlos.





¿Qué es la gestión de memoria?


JavaScript utiliza un sistema automático de gestión de memoria. El ciclo de vida de la memoria generalmente consta de tres pasos:

  1. Asignación de la memoria necesaria.
  2. Trabajar con memoria asignada, realizar operaciones de lectura y escritura.
  3. Liberar memoria después de que ya no sea necesaria.

En MDN dice que la administración automática de memoria es una fuente potencial de confusión. Esto puede dar a los desarrolladores una falsa sensación de que no necesitan preocuparse por la administración de la memoria.

Si no le importa la administración de la memoria en absoluto, esto significa que después de que su aplicación crezca a un cierto tamaño, puede encontrar una pérdida de memoria.

En general, las pérdidas de memoria pueden considerarse como la memoria asignada a la aplicación, que ya no necesita, pero no se libera. En otras palabras, estos son objetos que no pudieron someterse a operaciones de recolección de basura.

¿Cómo funciona la recolección de basura?


Durante el procedimiento de recolección de basura, que es bastante lógico, se limpia todo lo que se puede considerar “basura”. El recolector de basura limpia la memoria que la aplicación ya no necesita. Para averiguar qué áreas de memoria aún necesita la aplicación, el recolector de basura utiliza el algoritmo "marcar y barrer" (algoritmo de etiquetado). Como su nombre lo indica, este algoritmo consta de dos fases: la fase de marcado y la fase de barrido.

▍ Fase de bandera


Los objetos y enlaces a ellos se presentan en forma de árbol. La raíz del árbol es, en la siguiente figura, un nodo root. En JavaScript, este es un objeto window. Cada objeto tiene una bandera especial. Pongamos nombre a esta bandera marked. En la fase de marcado, en primer lugar, todos los indicadores markedse establecen en un valor false.


Al principio, las banderas de los objetos marcados se establecen en falso y

luego se recorre el árbol de objetos. Todas las banderas demarkedobjetos accesibles desde el nodorootestán establecidas entrue. Y las banderas de esos objetos que no se pueden alcanzar, permanecen en el valorfalse.

Un objeto se considera inalcanzable si no se puede alcanzar desde el objeto raíz.


Los objetos alcanzables se marcan como marcados = verdadero, los objetos inalcanzables como marcados = falso

Como resultado, todas las banderas demarkedobjetos inalcanzables permanecen en el valorfalse. La memoria aún no se ha liberado, pero, después de completar la fase de etiquetado, todo está listo para la fase de limpieza.

▍ Fase de limpieza


La memoria se borra precisamente en esta fase del algoritmo. Aquí, todos los objetos inalcanzables (aquellos cuya bandera markedpermanece en el valor false) son destruidos por el recolector de basura.


Árbol de objetos después de la recolección de basura. El recolector de basura destruye todos los objetos cuya marca marcada se establece en falso.

La recolección de basura se realiza periódicamente mientras se ejecuta el programa JavaScript. Durante este procedimiento, se libera memoria que se puede liberar.

Quizás la siguiente pregunta surge aquí: "Si el recolector de basura elimina todos los objetos marcados como inalcanzables, ¿cómo crear una pérdida de memoria?".

El punto aquí es que el objeto no será procesado por el recolector de basura si la aplicación no lo necesita, pero aún puede alcanzarlo desde el nodo raíz del árbol de objetos.

El algoritmo no puede saber si la aplicación usará alguna pieza de memoria a la que pueda acceder o no. Solo un programador tiene ese conocimiento.

Fugas de memoria angular.


Muy a menudo, las pérdidas de memoria ocurren con el tiempo cuando un componente se vuelve a representar repetidamente. Por ejemplo, a través del enrutamiento o como resultado del uso de la directiva *ngIf. Digamos, en una situación en la que algún usuario avanzado trabaja con la aplicación todo el día sin actualizar la página de la aplicación en el navegador.

Para reproducir este escenario, crearemos una construcción de dos componentes. Estos serán los componentes AppComponenty SubComponent.

@Component({
  selector: 'app-root',
  template: `<app-sub *ngIf="hide"></app-sub>`
})
export class AppComponent {
  hide = false;

  constructor() {
    setInterval(() => this.hide = !this.hide, 50);
  }
}

La plantilla AppComponentdel componente usa el componente app-sub. Lo más interesante aquí es que nuestro componente utiliza una función setIntervalque cambia la bandera hidecada 50 ms. Esto da como resultado que un componente se vuelva a representar cada 50 ms app-sub. Es decir, se realiza la creación de nuevas instancias de la clase SubComponent. Este código imita el comportamiento de un usuario que trabaja todo el día con una aplicación web sin actualizar una página en un navegador.

Nosotros, en SubComponent, hemos implementado diferentes escenarios, en el uso de los cuales, con el tiempo, comienzan a aparecer cambios en la cantidad de memoria utilizada por la aplicación. Tenga en cuenta que el componenteAppComponentsiempre permanece igual. En cada escenario, descubriremos si lo que estamos tratando es una pérdida de memoria analizando el consumo de memoria del proceso del navegador.

Si el consumo de memoria del proceso aumenta con el tiempo, esto significa que nos enfrentamos a una pérdida de memoria. Si un proceso usa una cantidad de memoria más o menos constante, significa que no hay pérdida de memoria o que la pérdida, aunque está presente, no se manifiesta de una manera bastante obvia.

▍ Escenario # 1: enorme para el bucle


Nuestro primer escenario está representado por un ciclo que se ejecuta 100,000 veces. En el bucle, se agregan valores aleatorios a la matriz. No olvidemos que el componente se vuelve a representar cada 50 ms. Eche un vistazo al código y piense si creamos una pérdida de memoria o no.

@Component({
  selector:'app-sub',
  // ...
})
export class SubComponent {
  arr = [];

  constructor() {
    for (let i = 0; i < 100000; ++i) {
      this.arr.push(Math.random());
    }
  }
}

Aunque dicho código no debe enviarse a producción, no crea una pérdida de memoria. Es decir, el consumo de memoria no va más allá del rango limitado a un valor de 15 MB. Como resultado, no hay pérdida de memoria. A continuación hablaremos de por qué esto es así.

▍ Escenario 2: Suscripción de BehaviorSubject


En este escenario, nos suscribimos BehaviorSubjecty asignamos un valor a una constante. ¿Hay una pérdida de memoria en este código? Como antes, no olvide que el componente se representa cada 50 ms.

@Component({
    selector:'app-sub',
    // ...
})
export class SubComponent {
  subject = new BehaviorSubject(42);
  
  constructor() {
    this.subject.subscribe(value => {
        const foo = value;
    });
  }
}

Aquí, como en el ejemplo anterior, no hay pérdida de memoria.

▍ Escenario 3: asignar un valor a un campo de clase dentro de una suscripción


Aquí, se presenta casi el mismo código que en el ejemplo anterior. La principal diferencia es que el valor no se asigna a una constante, sino a un campo de clase. Y ahora, ¿crees que hay una fuga en el código?

@Component({
    selector:'app-sub',
    // ...
})
export class SubComponent {
  subject = new BehaviorSubject(42);
  randomValue = 0;
  
  constructor() {
    this.subject.subscribe(value => {
        this.randomValue = value;
    });
  }
}

Si cree que no hay una fuga aquí, tiene toda la razón.

En el escenario # 1 no hay suscripción. En los escenarios No. 2 y 3, nos suscribimos a la secuencia del objeto observado inicializado en nuestro componente. Parece que estamos a salvo suscribiéndonos a flujos de componentes.

Pero, ¿y si agregamos servicio a nuestro esquema?

Escenarios que usan el servicio


En los siguientes escenarios, vamos a revisar los ejemplos anteriores, pero esta vez nos suscribiremos a la transmisión proporcionada por el servicio DummyService. Aquí está el código de servicio.

@Injectable({
  providedIn: 'root'
})
export class DummyService {

   some$ = new BehaviorSubject<number>(42);
}

Ante nosotros hay un servicio simple. Este es solo un servicio que proporciona stream ( some$) en forma de un campo de clase pública.

▍ Escenario 4: suscribirse a una secuencia y asignar un valor a una constante local


Vamos a recrear aquí el mismo esquema que ya se describió anteriormente. Pero esta vez, nos suscribimos a la transmisión some$desde DummyService, y no al campo del componente.

¿Hay una pérdida de memoria? Nuevamente, al responder esta pregunta, recuerde que el componente se usa AppComponenty se representa muchas veces.

@Component({
    selector:'app-sub',
    // ...
})
export class SubComponent {
  
  constructor(private dummyService: DummyService) {
    this.dummyService.some$.subscribe(value => {
        const foo = value;
    });
  }
}

Y ahora finalmente creamos una pérdida de memoria. Pero esta es una pequeña fuga. Por "pequeña fuga" me refiero a una que, con el tiempo, conduce a un lento aumento en la cantidad de memoria consumida. Este aumento apenas se nota, pero una inspección superficial de la instantánea del montón mostró la presencia de muchas instancias no eliminadas Subscriber.

▍ Escenario 5: suscribirse a un servicio y asignar un valor a un campo de clase


Aquí nos suscribimos nuevamente a dummyService. Pero esta vez asignamos el valor resultante al campo de clase, y no una constante local.

@Component({
    selector:'app-sub',
    // ...
})
export class SubComponent {
  randomValue = 0;
  
  constructor(private dummyService: DummyService) {
    this.dummyService.some$.subscribe(value => {
        this.randomValue = value;
    });
  }
}

Y aquí finalmente creamos una pérdida significativa de memoria. El consumo de memoria rápidamente, en un minuto, superó 1 GB. Hablemos de por qué esto es así.

▍ ¿Cuándo ocurrió una pérdida de memoria?


Es posible que haya notado que en los primeros tres escenarios no pudimos crear una pérdida de memoria. Estos tres escenarios tienen algo en común: todos los enlaces son locales al componente.

Cuando nos suscribimos a un objeto observable, almacena una lista de suscriptores. Nuestra devolución de llamada también está en esta lista, y la devolución de llamada puede referirse a nuestro componente.


Sin pérdida de memoria

Cuando se destruye un componente, es decir, cuando Angular ya no tiene un enlace, lo que significa que no se puede alcanzar el componente desde el nodo raíz, tampoco se puede alcanzar el objeto observado y su lista de suscriptores desde el nodo raíz. Como resultado, todo el objeto componente es basura recolectada.

Mientras estemos suscritos a un objeto observable, cuyos enlaces están solo dentro del componente, no surgen problemas. Pero cuando el servicio entra en juego, la situación cambia.


Pérdida de memoria

Tan pronto como nos suscribimos a un objeto observable proporcionado por un servicio u otra clase, creamos una pérdida de memoria. Esto se debe al objeto observado, debido a su lista de suscriptores. Debido a esto, la devolución de llamada, y por lo tanto el componente, son accesibles desde el nodo raíz, aunque Angular no tiene una referencia directa al componente. Como resultado, el recolector de basura no toca el objeto correspondiente.

Aclararé: puede usar tales construcciones, pero necesita trabajar con ellas correctamente, y no como nosotros.

Trabajo de suscripción adecuado


Para evitar pérdidas de memoria, es importante darse de baja correctamente del objeto observado, haciendo esto cuando la suscripción ya no sea necesaria. Por ejemplo, cuando se destruye un componente. Hay muchas formas de darse de baja de un objeto observado.

La experiencia de asesorar a los propietarios de grandes proyectos corporativos indica que, en esta situación, es mejor utilizar la entidad destroy$creada por el equipo new Subject<void>()en combinación con el operador takeUntil.

@Component({
  selector:'app-sub',
  // ...
})
export class SubComponent implements OnDestroy {

  private destroy$: Subject<void> = new Subject<void>();
  randomNumber = 0;

  constructor(private dummyService: DummyService) {
      dummyService.some$.pipe(
          takeUntil(this.destroy$)
      ).subscribe(value => this.randomNumber = value);
  }

  ngOnDestroy(): void {
      this.destroy$.next();
      this.destroy$.complete();
  }
}

Aquí nos damos de baja de la suscripción utilizando el destroy$operador y takeUntildespués de la destrucción del componente.

Implementamos un enlace de ciclo de vida en el componente ngOnDestroy. Cada vez que se destruye un componente, llamamos destroy$métodos nexty complete.

La llamada es completemuy importante porque borra la suscripción destroy$.

Luego usamos el operador takeUntily le pasamos nuestro flujo destroy$. Esto garantiza que la suscripción se borre (es decir, que nos hemos dado de baja de la suscripción) después de que se destruya el componente.

¿Cómo recordar borrar las suscripciones?


Es fácil olvidar agregar el componente destroy$y olvidar llamar next, y completeen el ciclo de vida de Hook ngOnDestroy. Incluso a pesar del hecho de que enseñé esto a los equipos que trabajan en proyectos, a menudo lo olvidé.

Afortunadamente, existe una maravillosa regla de linter, que forma parte de un conjunto de reglas que le permite garantizar la baja de las suscripciones. Puede establecer un conjunto de reglas como este:

npm install @angular-extensions/lint-rules --save-dev

Entonces debe estar conectado a tslint.json:

{
  "extends": [
    "tslint:recommended",
    "@angular-extensions/lint-rules"
  ]
}

Le recomiendo que use este conjunto de reglas en sus proyectos. Esto le ahorrará muchas horas de depuración para encontrar fuentes de pérdidas de memoria.

Resumen


En Angular, es muy fácil crear una situación que provoque pérdidas de memoria. Incluso pequeños cambios de código en lugares que, aparentemente, no deberían estar relacionados con pérdidas de memoria, pueden tener graves consecuencias adversas.

La mejor manera de evitar pérdidas de memoria es administrar sus suscripciones correctamente. Desafortunadamente, el funcionamiento de las suscripciones de limpieza requiere una gran precisión por parte del desarrollador. Esto es fácil de olvidar. Por lo tanto, se recomienda que aplique reglas @angular-extensions/lint-rulesque lo ayuden a organizar el trabajo correcto con sus suscripciones.

Aquí está el repositorio con el código subyacente a este material.

¿Ha encontrado pérdidas de memoria en aplicaciones angulares?


All Articles