Debugging memori

Alat pemeriksa kebocoran memori dan kebiasaan desain yang peka terhadap siklus referensi.

Daftar isi

Kebocoran memori (memory leak) pada program Nim berbasis ARC atau ORC jarang berasal dari bug alokator, melainkan dari rujukan yang tersisa, baik berupa siklus referensi maupun pemegang yang lupa dilepas. Topik ini membahas cara melacak masa hidup objek dengan destruktor, kebiasaan desain yang menjauhkan program dari siklus, dan alat pemeriksa yang tersedia di luar program. Contohnya memakai pengelola bawaan ORC kecuali bagian yang sengaja mengompilasi ulang dengan --mm:arc untuk memperlihatkan kebocorannya.

Melacak masa hidup lewat destruktor#

Cara paling murah untuk menyelidiki kebocoran pada Nim adalah menambahkan pesan pada destruktor, karena pemusnahan ARC dan ORC bersifat deterministik sehingga urutan pesannya dapat diprediksi lebih dahulu. Pola yang sehat pada program pemrosesan data adalah pergantian alokasi dan pembebasan yang teratur, sedangkan pesan pembebasan yang tidak pernah muncul menandakan ada pemegang yang tersisa. Program berikut memproses tiga buffer satu per satu di dalam fungsi tersendiri, sehingga setiap buffer seharusnya dibebaskan tepat sebelum fungsi berikutnya dimulai.

nim lacak_buffer.nim
type
  BufferSinyal = object
    id: int
    data: seq[float64]

proc `=destroy`(b: var BufferSinyal) =
  echo "buffer ", b.id, " dibebaskan"
  `=destroy`(b.data)

type
  RefBuffer = ref BufferSinyal

proc proses(id: int) =
  let b = RefBuffer(id: id, data: newSeq[float64](4))
  echo "memproses buffer ", b.id

for i in 1 .. 3:
  proses(i)
echo "program berakhir"
bash
# kompilasi program pelacak masa hidup dengan pengelola bawaan
nim c --hints:off lacak_buffer.nim
# jalankan program hasil kompilasi
./lacak_buffer
text Keluaran
memproses buffer 1
buffer 1 dibebaskan
memproses buffer 2
buffer 2 dibebaskan
memproses buffer 3
buffer 3 dibebaskan
program berakhir

Keluaran menunjukkan pola yang sehat, yaitu setiap buffer dibebaskan sebelum buffer berikutnya diproses. Dekstruktor pada program ini juga memanggil =destroy untuk bidang data, sesuai aturan bahwa destruktor buatan sendiri wajib melepaskan bidangnya sendiri. Apabila sebuah buffer dipindahkan ke daftar untuk dipakai lagi, pesan pembebasannya akan bergeser ke titik ketika daftar itu dikosongkan, dan pergeseran semacam itulah yang perlu dicocokkan dengan niat desain.

Metode ini bekerja karena pertanyaan yang diujikan berubah bentuk. Alih-alih menduga dari konsumsi memori proses yang dipengaruhi banyak hal, pertanyaannya menjadi apakah pesan pemusnahan muncul pada baris yang diperkirakan, dan jawabannya tegas. Pesan yang muncul lebih dulu atau lebih lambat dari perkiraan bukan kebocoran, melainkan petunjuk bahwa ada pemegang yang belum diperhitungkan, misalnya objek yang tersimpan di dalam tabel global. Petunjuk semacam itu langsung menunjuk bagian kode yang perlu diperiksa.

Kebiasaan desain yang peka siklus#

Siklus referensi muncul dari relasi dua arah yang dirancang tanpa perencanaan kepemilikan, misalnya simpul yang saling merujuk sebagai teman atau anak yang menunjuk induknya dengan rujukan penuh. Kebiasaan yang menjauhkan program dari masalah ini ada tiga, yaitu memakai objek bernilai ketika berbagi rujukan tidak diperlukan, mengarahkan kepemilikan hanya satu arah, dan menandai referensi balik sebagai non-owning dengan pragma {.cursor.}. Program berikut adalah versi bermasalah dari dua simpul yang saling merujuk, dikompilasi dengan ARC agar pengumpul siklus tidak menutupi masalahnya.

nim siklus_biasa.nim
type
  Simpul = object
    id: int
    lawan: ref Simpul

proc `=destroy`(s: var Simpul) =
  echo "simpul ", s.id, " dimusnahkan"
  `=destroy`(s.lawan)

type
  RefSimpul = ref Simpul

var a = RefSimpul(id: 1)
var b = RefSimpul(id: 2)
a.lawan = b
b.lawan = a
a = nil
b = nil
echo "program berakhir"
bash
# kompilasi program siklus dengan ARC untuk mengamati kebocorannya
nim c --mm:arc --hints:off siklus_biasa.nim
# jalankan program hasil kompilasi
./siklus_biasa
text Keluaran
program berakhir

Tidak ada satu pun pesan pemusnahan, karena kedua simpul saling menahan meski kedua variabelnya sudah dikosongkan. Perbaikannya adalah memberi satu arah rujukan penuh untuk kepemilikan dan arah balik non-owning. Program berikut memodelkan relasi induk dan anak dengan pola itu, dan tetap dikompilasi dengan ARC agar pembandingannya setara.

nim induk_anak.nim
type
  IndukObj = object
    id: int
    anak: ref AnakObj

  AnakObj = object
    id: int
    induk {.cursor.}: ref IndukObj

proc `=destroy`(a: var AnakObj) =
  echo "anak ", a.id, " dimusnahkan"

proc `=destroy`(i: var IndukObj) =
  echo "induk ", i.id, " dimusnahkan"
  `=destroy`(i.anak)

type
  Induk = ref IndukObj
  Anak = ref AnakObj

var i = Induk(id: 1)
block:
  var a = Anak(id: 10)
  i.anak = a
  a.induk = i
  echo "blok berakhir"
i = nil
echo "program berakhir"
bash
# kompilasi program induk-anak dengan referensi balik non-owning
nim c --mm:arc --hints:off induk_anak.nim
# jalankan program hasil kompilasi
./induk_anak
text Keluaran
blok berakhir
induk 1 dimusnahkan
anak 10 dimusnahkan
program berakhir

Kali ini kedua objek dimusnahkan pada titik yang dapat diperkirakan, yaitu induk beserta anaknya ketika variabel induk dikosongkan. Bidang induk bertanda {.cursor.} tidak menaikkan penghitung referensinya, sehingga tidak menahan induk dan siklus tidak terbentuk. Destruktor anak karena itu hanya mencetak pesan tanpa melepaskan bidang induk, karena bidang tersebut bukan miliknya.

Demo berikut menyimulasikan perlakuan kedua pengelola terhadap siklus yang sama, lengkap dengan pemanggilan pengumpul siklus seperti pada contoh ORC sebelumnya. Pilih mode, buat siklusnya, kosongkan variabelnya, lalu bandingkan hasil pemanggilan GC_fullCollect() di kedua mode.

Alat pemeriksa kebocoran#

Untuk pengamatan di luar program, valgrind adalah alat standar pada Linux yang menjalankan biner sambil mencatat setiap alokasi, lalu melaporkan blok yang masih tertahan saat program berakhir. Contoh perintahnya ditulis di bawah, dan baris pertamanya menandai bahwa potongan ini memerlukan pemasangan valgrind yang tidak tersedia pada lingkungan eksekusi contoh di topik ini.

bash
# tidak-dijalankan
# pemeriksaan ini memerlukan valgrind yang terpasang pada sistem
valgrind --leak-check=full ./lacak_buffer

Dokumentasi memori Nim menyebut biner ORC kompatibel dengan valgrind, sehingga laporan yang dihasilkan dapat dipercaya untuk program modern. Alternatif lain adalah heaptrack yang memprofilkan alokasi sepanjang umur proses dan menampilkan pemanggil yang mengalokasikan memori paling banyak, berguna ketika kebocorannya bertahap alih-alih muncul mendadak. Alat eksternal seperti ini melengkapi, bukan menggantikan, teknik destruktor pada bagian awal, karena pesan destruktor menjawab kapan objek musnah sedangkan valgrind menjawab berapa banyak memori yang tertahan.

Interpretasi laporan valgrind pun lebih mudah pada program Nim daripada pada program yang mengelola memori secara manual. Satuan yang dilaporkan adalah blok alokasi yang tertahan, sedangkan blok yang dipegang oleh siklus tetap terjangkau dari perspektif alokator, sehingga laporan tetap perlu dibaca bersama pengetahuan tentang siklus. Heaptrack menutup celah ini dari arah berbeda karena merekam siapa yang mengalokasikan dan kapan beban memori naik, yang mempermudah penunjukan struktur data yang tumbuh tanpa henti. Perpaduan laporan keduanya dengan pesan destruktor memberi gambaran yang cukup lengkap sebelum kode dirombak.

Ringkasan#

  • Pesan pada destruktor memperlihatkan titik pemusnahan secara pasti, dan selisih antara banyaknya pembuatan dan pembebasan menandai kebocoran.
  • Siklus mencegah pemusnahan pada ARC, dan rujukan balik non-owning bertanda {.cursor.} memutus siklusnya tanpa mengubah struktur relasi.
  • valgrind memeriksa blok yang tertahan saat program berakhir, sedangkan heaptrack memprofilkan alokasi beserta pemanggilnya.
  • Kebiasaan desain yang paling menentukan adalah kepemilikan satu arah dengan objek bernilai bila berbagi rujukan tidak diperlukan.

Referensi#