Bagaimana cara menyebabkan kebocoran memori pada aplikasi Angular?

Kinerja adalah kunci keberhasilan aplikasi web. Oleh karena itu, pengembang perlu mengetahui bagaimana kebocoran memori terjadi dan cara menanganinya.

Pengetahuan ini sangat penting ketika aplikasi yang ditangani pengembang mencapai ukuran tertentu. Jika Anda tidak cukup memperhatikan kebocoran memori, maka semuanya mungkin berakhir dengan pengembang masuk ke "tim untuk menghilangkan kebocoran memori" (saya harus menjadi bagian dari tim semacam itu). Kebocoran memori dapat terjadi karena berbagai alasan. Namun, saya percaya bahwa saat menggunakan Angular, Anda mungkin menemukan pola yang sesuai dengan penyebab paling umum dari kebocoran memori. Ada cara untuk mengatasi kebocoran memori tersebut. Dan hal terbaik, tentu saja, bukan untuk melawan masalah, tetapi untuk menghindarinya.





Apa itu manajemen memori?


JavaScript menggunakan sistem manajemen memori otomatis. Daur hidup memori biasanya terdiri dari tiga langkah:

  1. Alokasi memori yang diperlukan.
  2. Bekerja dengan memori yang dialokasikan, melakukan operasi baca dan tulis.
  3. Melepaskan memori setelah itu tidak lagi diperlukan.

Pada MDN mengatakan bahwa manajemen memori otomatis - itu adalah sumber potensi kebingungan. Ini dapat memberi pengembang rasa salah bahwa mereka tidak perlu khawatir tentang manajemen memori.

Jika Anda sama sekali tidak peduli dengan manajemen memori, ini berarti bahwa setelah aplikasi Anda tumbuh ke ukuran tertentu, Anda mungkin mengalami kebocoran memori.

Secara umum, kebocoran memori dapat dianggap sebagai memori yang dialokasikan untuk aplikasi, yang tidak lagi dibutuhkan, tetapi tidak dirilis. Dengan kata lain, ini adalah objek yang gagal menjalani operasi pengumpulan sampah.

Bagaimana cara kerja pengumpulan sampah?


Selama prosedur pengumpulan sampah, yang cukup logis, semua yang dapat dianggap "sampah" dibersihkan. Pengumpul sampah membersihkan memori yang tidak lagi dibutuhkan aplikasi. Untuk mengetahui area memori apa yang masih dibutuhkan aplikasi, pengumpul sampah menggunakan algoritme "mark and sweep" (algoritma penandaan). Sesuai namanya, algoritma ini terdiri dari dua fase - fase penandaan dan fase sapuan.

▍ Fase bendera


Objek dan tautannya disajikan dalam bentuk pohon. Akar pohon adalah, pada gambar berikut, sebuah simpul root. Dalam JavaScript, ini adalah objek window. Setiap objek memiliki bendera khusus. Beri nama bendera ini marked. Pada fase penandaan, pertama-tama, semua bendera markeddiatur ke nilai false.


Pada awalnya, bendera dari objek yang ditandai disetel ke false,

kemudian pohon objek dilintasi. Semua benderamarkedobjek yang dapat dijangkau dari noderootdiatur ketrue. Dan bendera dari benda-benda itu yang tidak bisa dijangkau, tetap nilainyafalse.

Suatu objek dianggap tidak terjangkau jika tidak dapat dijangkau dari objek root.


Objek yang dapat dijangkau ditandai sebagai ditandai = benar, objek yang tidak dapat dijangkau sebagai ditandai = false

Akibatnya, semua benderamarkedobjek yang tidak dapat dijangkau tetap berada dalam nilaifalse. Memori belum dibebaskan, tetapi, setelah selesainya fase penandaan, semuanya siap untuk fase pembersihan.

▍ Tahap pembersihan


Memori dihapus tepat pada fase algoritma ini. Di sini, semua benda yang tidak dapat dijangkau (mereka yang benderanya markedtetap nilainya false) dihancurkan oleh pengumpul sampah.


Pohon objek setelah pengumpulan sampah. Semua objek yang ditandai bendera disetel ke salah dihancurkan oleh pengumpul sampah.Pengumpulan

sampah dilakukan secara berkala saat program JavaScript berjalan. Selama prosedur ini, memori dilepaskan yang dapat dibebaskan.

Mungkin pertanyaan berikut muncul di sini: "Jika pengumpul sampah menghapus semua objek yang ditandai sebagai tidak dapat dijangkau - bagaimana cara membuat kebocoran memori?".

Intinya di sini adalah bahwa objek tidak akan diproses oleh pengumpul sampah jika aplikasi tidak membutuhkannya, tetapi Anda masih dapat mencapainya dari simpul akar dari pohon objek.

Algoritma tidak dapat mengetahui apakah aplikasi akan menggunakan sebagian memori yang dapat diakses atau tidak. Hanya seorang programmer yang memiliki pengetahuan seperti itu.

Kebocoran memori sudut


Paling sering, kebocoran memori terjadi seiring waktu ketika suatu komponen berulang kali dirender. Misalnya - melalui perutean, atau sebagai akibat dari menggunakan arahan *ngIf. Katakanlah, dalam situasi di mana beberapa pengguna tingkat lanjut bekerja dengan aplikasi sepanjang hari tanpa memperbarui halaman aplikasi di browser.

Untuk mereproduksi skenario ini, kami akan membuat konstruksi dua komponen. Ini akan menjadi komponen AppComponentdan SubComponent.

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

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

Templat komponen AppComponentmenggunakan komponen app-sub. Yang paling menarik di sini adalah komponen kita menggunakan fungsi setIntervalyang mengganti flag hidesetiap 50 ms. Ini menghasilkan komponen yang dirender ulang setiap 50 ms app-sub. Artinya, penciptaan instance baru kelas dilakukan SubComponent. Kode ini meniru perilaku pengguna yang bekerja sepanjang hari dengan aplikasi web tanpa menyegarkan halaman di browser.

Kami, dalam SubComponent, telah menerapkan skenario yang berbeda, dalam penggunaannya, seiring waktu, perubahan dalam jumlah memori yang digunakan oleh aplikasi mulai muncul. Perhatikan bahwa komponenAppComponentselalu tetap sama. Dalam setiap skenario, kita akan mengetahui apakah yang kita hadapi adalah kebocoran memori dengan menganalisis konsumsi memori dari proses browser.

Jika konsumsi memori dari proses meningkat dari waktu ke waktu, ini berarti bahwa kita dihadapkan dengan kebocoran memori. Jika suatu proses menggunakan jumlah memori yang kurang lebih konstan, itu berarti bahwa tidak ada kebocoran memori, atau bahwa kebocoran, meskipun ada, tidak memanifestasikan dirinya dengan cara yang cukup jelas.

▍ Skenario # 1: besar untuk loop


Skenario pertama kami diwakili oleh loop yang berjalan 100.000 kali. Dalam loop, nilai acak ditambahkan ke array. Jangan lupa bahwa komponen dirender ulang setiap 50 ms. Lihatlah kodenya dan pikirkan apakah kita membuat kebocoran memori atau tidak.

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

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

Meskipun kode tersebut tidak boleh dikirim ke produksi, itu tidak membuat kebocoran memori. Yaitu, konsumsi memori tidak melampaui kisaran terbatas pada nilai 15 MB. Akibatnya, tidak ada kebocoran memori. Di bawah ini kita akan berbicara tentang mengapa demikian.

▍ Skenario 2: Berlangganan BehaviorSubject


Dalam skenario ini, kami berlangganan BehaviorSubjectdan menetapkan nilai ke konstanta. Apakah ada kebocoran memori dalam kode ini? Seperti sebelumnya, jangan lupa bahwa komponen diberikan setiap 50 ms.

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

Di sini, seperti pada contoh sebelumnya, tidak ada kebocoran memori.

▍ Skenario 3: menugaskan nilai ke bidang kelas di dalam langganan


Di sini, kode yang hampir sama disajikan seperti pada contoh sebelumnya. Perbedaan utama adalah bahwa nilai diberikan bukan untuk konstanta, tetapi ke bidang kelas. Dan sekarang, apakah Anda pikir ada kebocoran dalam kode?

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

Jika Anda yakin tidak ada kebocoran di sini - Anda memang benar.

Dalam skenario # 1 tidak ada berlangganan. Dalam skenario No. 2 dan 3, kami berlangganan aliran objek yang diamati yang diinisialisasi dalam komponen kami. Rasanya seperti kita aman dengan berlangganan aliran komponen.

Tetapi bagaimana jika kita menambahkan layanan ke skema kita?

Skenario yang menggunakan layanan


Dalam skenario berikut, kami akan merevisi contoh di atas, tetapi kali ini kami akan berlangganan aliran yang disediakan oleh layanan DummyService. Ini kode layanannya.

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

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

Di depan kami adalah layanan sederhana. Ini hanya layanan yang menyediakan aliran ( some$) dalam bentuk bidang kelas publik.

▍ Skenario 4: Berlangganan aliran dan menetapkan nilai ke konstanta lokal


Kami akan membuat ulang di sini skema yang sama yang sudah dijelaskan sebelumnya. Tapi kali ini, kami berlangganan aliran some$dari DummyService, dan bukan ke bidang komponen.

Apakah ada kebocoran memori? Sekali lagi, ketika menjawab pertanyaan ini, ingatlah bahwa komponen digunakan AppComponentdan dirender berkali-kali.

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

Dan sekarang kami akhirnya membuat kebocoran memori. Tapi ini kebocoran kecil. Yang saya maksud dengan "kebocoran kecil" yang seiring waktu, mengarah pada peningkatan lambat dalam jumlah memori yang dikonsumsi. Peningkatan ini nyaris tidak terlihat, tetapi inspeksi sepintas dari snapshot tumpukan menunjukkan adanya banyak instance yang tidak terhapus Subscriber.

▍ Skenario 5: berlangganan layanan dan memberikan nilai ke bidang kelas


Di sini kami berlangganan lagi untuk dummyService. Tapi kali ini kami menetapkan nilai yang dihasilkan ke bidang kelas, dan bukan konstanta lokal.

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

Dan di sini kami akhirnya membuat kebocoran memori yang signifikan. Konsumsi memori dengan cepat, dalam satu menit, melebihi 1 GB. Mari kita bicarakan mengapa ini terjadi.

HenKetika kebocoran memori terjadi?


Anda mungkin telah memperhatikan bahwa dalam tiga skenario pertama kami tidak dapat membuat kebocoran memori. Tiga skenario ini memiliki kesamaan: semua tautan bersifat lokal ke komponen.

Ketika kami berlangganan objek yang dapat diamati, ia menyimpan daftar pelanggan. Callback kami juga ada dalam daftar ini, dan callback dapat merujuk ke komponen kami.


Tidak ada kebocoran memori

Ketika komponen dihancurkan, yaitu, ketika Angular tidak lagi memiliki tautan ke sana, yang berarti bahwa komponen tidak dapat dijangkau dari simpul akar, objek yang diamati dan daftar pelanggannya tidak dapat dijangkau dari simpul akar juga. Akibatnya, seluruh objek komponen adalah sampah yang dikumpulkan.

Selama kita berlangganan objek yang dapat diamati, tautan yang hanya ada di dalam komponen, tidak ada masalah yang muncul. Tetapi ketika layanan mulai berlaku, situasinya berubah.


Memory Leak

Segera setelah kami berlangganan objek yang dapat diamati yang disediakan oleh layanan atau kelas lain, kami membuat kebocoran memori. Ini karena objek yang diamati, karena daftar pelanggannya. Karena itu, panggilan balik, dan oleh karena itu komponen, dapat diakses dari simpul akar, meskipun Angular tidak memiliki referensi langsung ke komponen. Akibatnya, pengumpul sampah tidak menyentuh objek yang sesuai.

Saya akan mengklarifikasi: Anda dapat menggunakan konstruksi seperti itu, tetapi Anda harus bekerja dengan mereka dengan benar, dan tidak seperti kami.

Pekerjaan Berlangganan yang Tepat


Untuk menghindari kebocoran memori, penting untuk berhenti berlangganan dengan benar dari objek yang diamati, dengan melakukan ini ketika berlangganan tidak lagi diperlukan. Misalnya, ketika komponen dihancurkan. Ada banyak cara untuk berhenti berlangganan dari objek yang diamati.

Pengalaman menasihati pemilik proyek perusahaan besar menunjukkan bahwa dalam situasi ini yang terbaik adalah menggunakan entitas yang destroy$dibuat oleh tim new Subject<void>()dalam kombinasi dengan operator 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();
  }
}

Di sini kami berhenti berlangganan dengan menggunakan destroy$operator dan takeUntilsetelah penghancuran komponen.

Kami menerapkan kait siklus hidup di komponen ngOnDestroy. Setiap kali komponen dihancurkan, kami memanggil destroy$metode nextdan complete.

Panggilan ini completesangat penting karena panggilan ini menghapus langganan dari destroy$.

Kemudian kami menggunakan operator takeUntildan memberikannya aliran kami destroy$. Ini memastikan bahwa langganan dihapus (yaitu, bahwa kami telah berhenti berlangganan dari langganan) setelah komponen dihancurkan.

Bagaimana cara mengingat untuk menghapus langganan?


Sangat mudah untuk lupa menambahkan komponen destroy$dan lupa menelepon next, dan completedalam siklus hidup Hook ngOnDestroy. Walaupun saya mengajarkan hal ini kepada tim yang mengerjakan proyek, saya sering melupakannya sendiri.

Untungnya, ada aturan linter yang luar biasa, yang merupakan bagian dari serangkaian aturan yang memungkinkan Anda untuk memastikan berhenti berlangganan dari langganan. Anda dapat menetapkan aturan yang ditetapkan seperti ini:

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

Maka itu harus terhubung ke tslint.json:

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

Saya sangat menyarankan Anda menggunakan seperangkat aturan ini dalam proyek Anda. Ini akan menghemat waktu berjam-jam untuk mencari sumber kebocoran memori.

Ringkasan


Di Angular, sangat mudah untuk menciptakan situasi yang mengarah pada kebocoran memori. Bahkan perubahan kode kecil di tempat-tempat yang, tampaknya, tidak boleh dikaitkan dengan kebocoran memori, dapat menyebabkan konsekuensi buruk yang serius.

Cara terbaik untuk menghindari kebocoran memori adalah mengelola langganan Anda dengan benar. Sayangnya, pengoperasian langganan pembersihan membutuhkan akurasi yang tinggi dari pengembang. Ini mudah dilupakan. Karena itu, Anda disarankan untuk menerapkan aturan @angular-extensions/lint-rulesyang membantu Anda mengatur pekerjaan yang tepat dengan langganan Anda.

Ini adalah repositori dengan kode yang mendasari materi ini.

Pernahkah Anda mengalami kebocoran memori di aplikasi Angular?


All Articles