Como causar um vazamento de memória em um aplicativo Angular?

O desempenho é a chave para o sucesso de um aplicativo da web. Portanto, os desenvolvedores precisam saber como ocorrem vazamentos de memória e como lidar com eles.

Esse conhecimento é especialmente importante quando o aplicativo que o desenvolvedor está lidando atinge um determinado tamanho. Se você não prestar atenção suficiente aos vazamentos de memória, tudo poderá acabar com o desenvolvedor entrando na "equipe para eliminar vazamentos de memória" (eu precisava fazer parte dessa equipe). Vazamentos de memória podem ocorrer por vários motivos. No entanto, acredito que ao usar o Angular, você pode encontrar um padrão que corresponda à causa mais comum de vazamento de memória. Existe uma maneira de lidar com esses vazamentos de memória. E a melhor coisa, é claro, não é combater problemas, mas evitá-los.





O que é gerenciamento de memória?


JavaScript usa um sistema de gerenciamento automático de memória. O ciclo de vida da memória geralmente consiste em três etapas:

  1. Alocação de memória necessária.
  2. Trabalhe com memória alocada, executando operações de leitura e gravação.
  3. Liberar memória depois que ela não é mais necessária.

No MDN diz que o gerenciamento automático de memória - é uma fonte potencial de confusão. Isso pode dar aos desenvolvedores uma falsa sensação de que eles não precisam se preocupar com o gerenciamento de memória.

Se você não se importa com o gerenciamento de memória, isso significa que, depois que o aplicativo crescer para um determinado tamanho, é possível que haja um vazamento de memória.

Em geral, os vazamentos de memória podem ser considerados como a memória alocada para o aplicativo, da qual ele não precisa mais, mas não é liberado. Em outras palavras, esses são objetos que falharam ao passar pelas operações de coleta de lixo.

Como funciona a coleta de lixo?


Durante o procedimento de coleta de lixo, que é bastante lógico, tudo o que pode ser considerado "lixo" é limpo. O coletor de lixo limpa a memória que o aplicativo não precisa mais. Para descobrir de que áreas da memória o aplicativo ainda precisa, o coletor de lixo usa o algoritmo “marcar e varrer” (algoritmo de marcação). Como o nome indica, esse algoritmo consiste em duas fases - a fase de marcação e a fase de varredura.

▍ Fase de sinalização


Objetos e links para eles são apresentados na forma de uma árvore. A raiz da árvore é, na figura a seguir, um nó root. Em JavaScript, este é um objeto window. Cada objeto tem uma bandeira especial. Vamos nomear essa bandeira marked. Na fase de sinalização, primeiro de tudo, todos os sinalizadores markedsão configurados para um valor false.


No início, os sinalizadores dos objetos marcados são definidos como false e, em

seguida, a árvore de objetos é percorrida. Todas as bandeiras demarkedobjetos acessíveis a partir do nórootestá definido paratrue. E as bandeiras desses objetos que não podem ser alcançados permanecem no valorfalse.

Um objeto é considerado inacessível se não puder ser alcançado a partir do objeto raiz.


Objetos alcançáveis ​​são marcados como marcado = true, objetos inacessíveis como marcado = false

Como resultado, todos os sinalizadores demarkedobjetos inacessíveis permanecem no valorfalse. A memória ainda não foi liberada, mas, após a conclusão da fase de marcação, tudo está pronto para a fase de limpeza.

Fase de limpeza


A memória é limpa precisamente nesta fase do algoritmo. Aqui, todos os objetos inacessíveis (aqueles cujo sinalizador markedpermanece no valor false) são destruídos pelo coletor de lixo.


Árvore de objetos após a coleta de lixo. Todos os objetos cujo sinalizador marcado está definido como false são destruídos pelo coletor de

lixo.A coleta de lixo é executada periodicamente enquanto o programa JavaScript está em execução. Durante esse procedimento, é liberada memória que pode ser liberada.

Talvez a seguinte pergunta surja aqui: “Se o coletor de lixo remover todos os objetos marcados como inacessíveis - como criar um vazamento de memória?”.

O ponto aqui é que o objeto não será processado pelo coletor de lixo se o aplicativo não precisar, mas você ainda poderá alcançá-lo no nó raiz da árvore de objetos.

O algoritmo não pode saber se o aplicativo usará algum pedaço de memória que pode acessar ou não. Somente um programador tem esse conhecimento.

Vazamentos de memória angular


Na maioria das vezes, vazamentos de memória ocorrem ao longo do tempo quando um componente é repetido repetidamente. Por exemplo - por meio de roteamento ou como resultado do uso da diretiva *ngIf. Digamos, em uma situação em que algum usuário avançado trabalhe com o aplicativo o dia inteiro sem atualizar a página do aplicativo no navegador.

Para reproduzir esse cenário, criaremos uma construção de dois componentes. Estes serão os componentes AppComponente SubComponent.

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

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

O modelo AppComponentdo componente usa o componente app-sub. O mais interessante aqui é que nosso componente usa uma função setIntervalque alterna o sinalizador a hidecada 50 ms. Isso resulta em um componente sendo renderizado novamente a cada 50 ms app-sub. Ou seja, a criação de novas instâncias da classe é realizada SubComponent. Esse código imita o comportamento de um usuário que trabalha o dia inteiro com um aplicativo Web sem atualizar uma página em um navegador.

Nós, em SubComponent, implementaram diferentes cenários, em cuja utilização, ao longo do tempo, as mudanças na quantidade de memória usada pelo aplicativo começam a aparecer. Observe que o componenteAppComponentsempre permanece o mesmo. Em cada cenário, descobriremos se estamos lidando com um vazamento de memória analisando o consumo de memória do processo do navegador.

Se o consumo de memória do processo aumentar com o tempo, isso significa que estamos diante de um vazamento de memória. Se um processo usa uma quantidade mais ou menos constante de memória, isso significa que não há vazamento de memória ou que o vazamento, embora presente, não se manifesta de maneira bastante óbvia.

▍ Cenário 1: enorme para loop


Nosso primeiro cenário é representado por um loop que é executado 100.000 vezes. No loop, valores aleatórios são adicionados à matriz. Não devemos esquecer que o componente é renderizado novamente a cada 50 ms. Dê uma olhada no código e pense se criamos um vazamento de memória ou não.

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

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

Embora esse código não deva ser enviado para produção, ele não cria um vazamento de memória. Nomeadamente, o consumo de memória não excede o intervalo limitado a um valor de 15 MB. Como resultado, não há vazamento de memória. Abaixo falaremos sobre por que isso é assim.

▍ Cenário 2: Assinatura BehaviorSubject


Nesse cenário, assinamos BehaviorSubjecte atribuímos um valor a uma constante. Há um vazamento de memória nesse código? Como antes, não esqueça que o componente é renderizado a cada 50 ms.

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

Aqui, como no exemplo anterior, não há vazamento de memória.

3 Cenário 3: atribuindo um valor a um campo de classe dentro de uma assinatura


Aqui, quase o mesmo código é apresentado como no exemplo anterior. A principal diferença é que o valor é atribuído não a uma constante, mas a um campo de classe. E agora, você acha que há um vazamento no código?

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

Se você acredita que não há vazamento aqui - está absolutamente certo.

No cenário # 1, não há assinatura. Nos cenários 2 e 3, assinamos o fluxo do objeto observado inicializado em nosso componente. Parece que estamos seguros assinando fluxos de componentes.

Mas e se adicionarmos serviço ao nosso esquema?

Cenários que usam o serviço


Nos cenários a seguir, revisaremos os exemplos acima, mas desta vez assinaremos o fluxo fornecido pelo serviço DummyService. Aqui está o código de serviço.

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

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

Antes de nós é um serviço simples. Este é apenas um serviço que fornece stream ( some$) na forma de um campo de classe pública.

4 Cenário 4: assinando um fluxo e atribuindo um valor a uma constante local


Vamos recriar aqui o mesmo esquema que já foi descrito anteriormente. Mas desta vez, assinamos o fluxo some$de DummyService, e não o campo do componente.

Existe um vazamento de memória? Novamente, ao responder a essa pergunta, lembre-se de que o componente é usado AppComponente renderizado várias vezes.

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

E agora finalmente criamos um vazamento de memória. Mas este é um pequeno vazamento. Por "pequeno vazamento", quero dizer um que, com o tempo, leva a um lento aumento na quantidade de memória consumida. Esse aumento é quase imperceptível, mas uma inspeção superficial do instantâneo da pilha mostrou a presença de muitas cópias não recuperadas Subscriber.

5 Cenário 5: assinando um serviço e atribuindo um valor a um campo de classe


Aqui assinamos novamente dummyService. Mas desta vez atribuímos o valor resultante ao campo da classe, e não uma constante local.

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

E aqui finalmente criamos um vazamento de memória significativo. O consumo de memória rapidamente, em um minuto, excedeu 1 GB. Vamos falar sobre por que isso é assim.

OccurQuando ocorreu um vazamento de memória?


Você deve ter notado que nos três primeiros cenários não conseguimos criar um vazamento de memória. Esses três cenários têm algo em comum: todos os links são locais para o componente.

Quando assinamos um objeto observável, ele armazena uma lista de assinantes. Nosso retorno de chamada também está nesta lista, e o retorno de chamada pode se referir ao nosso componente.


Sem vazamento de memória

Quando um componente é destruído, ou seja, quando o Angular não possui mais um link, o que significa que o componente não pode ser acessado a partir do nó raiz, o objeto observado e sua lista de assinantes também não podem ser alcançados a partir do nó raiz. Como resultado, todo o objeto do componente é coletado como lixo.

Enquanto estivermos inscritos em um objeto observável, cujos links estão apenas dentro do componente, não haverá problemas. Mas quando o serviço entra em ação, a situação muda.


Vazamento de memória

Assim que assinamos um objeto observável fornecido por um serviço ou outra classe, criamos um vazamento de memória. Isso ocorre devido ao objeto observado, devido à sua lista de assinantes. Por esse motivo, o retorno de chamada e, portanto, o componente, são acessíveis no nó raiz, embora o Angular não tenha uma referência direta ao componente. Como resultado, o coletor de lixo não toca no objeto correspondente.

Vou esclarecer: você pode usar essas construções, mas precisa trabalhar com elas corretamente, e não como nós.

Trabalho de assinatura adequado


Para evitar vazamento de memória, é importante cancelar a inscrição corretamente do objeto observado, fazendo isso quando a assinatura não for mais necessária. Por exemplo, quando um componente é destruído. Existem várias maneiras de cancelar a inscrição de um objeto observado.

A experiência de aconselhar proprietários de grandes projetos corporativos indica que, nessa situação, é melhor usar a entidade destroy$criada pela equipe new Subject<void>()em combinação com o 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();
  }
}

Aqui, cancelamos a assinatura da assinatura usando o destroy$operador e takeUntilapós a destruição do componente.

Implementamos um gancho de ciclo de vida no componente ngOnDestroy. Toda vez que um componente é destruído, chamamos destroy$métodos nexte complete.

A chamada é completemuito importante porque esta limpa a assinatura de destroy$.

Em seguida, usamos o operador takeUntile transmitimos a ele nosso fluxo destroy$. Isso garante que a assinatura seja limpa (ou seja, cancelamos a inscrição na assinatura) após a destruição do componente.

Como se lembrar de limpar assinaturas?


É fácil esquecer de adicionar o componente destroy$e de ligar nexte completeno ciclo de vida do Hook ngOnDestroy. Mesmo apesar de ter ensinado isso às equipes que trabalham em projetos, muitas vezes eu me esqueci disso.

Felizmente, existe uma regra maravilhosa de linter, que faz parte de um conjunto de regras que permite garantir a desinscrição adequada das assinaturas. Você pode definir um conjunto de regras como este:

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

Em seguida, ele deve estar conectado a tslint.json:

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

Eu recomendo que você use esse conjunto de regras em seus projetos. Isso poupará muitas horas de depuração na localização de fontes de vazamento de memória.

Sumário


No Angular, é muito fácil criar uma situação que leva a vazamentos de memória. Mesmo pequenas alterações de código em locais que, aparentemente, não devem estar relacionados a vazamentos de memória, podem levar a sérias conseqüências adversas.

A melhor maneira de evitar vazamentos de memória é gerenciar suas assinaturas corretamente. Infelizmente, a operação de limpeza de assinaturas requer grande precisão do desenvolvedor. Isso é fácil de esquecer. Portanto, é recomendável que você aplique regras @angular-extensions/lint-rulesque ajudem a organizar o funcionamento adequado das assinaturas.

Aqui está o repositório com o código subjacente a este material.

Você encontrou vazamentos de memória em aplicativos Angular?


All Articles