cProfile memberi tahu function mana yang boros waktu — tetapi program yang pelan-pelan membengkak dari 100 MB menjadi 2 GB butuh alat yang berbeda. Memory profiling adalah pengukuran ke mana memori pergi dan siapa yang menyimpannya. Python membawa alat utamanya di standard library: module tracemalloc. Pada Chapter ini kita gunakan alat tersebut untuk menangkap kebocoran sungguhan, lalu membedah mitos kebocoran ala Python yang berbeda dari bahasa C.

tracemalloc: Menimbang Setiap Alokasi #

Setelah tracemalloc.start() diaktifkan, interpreter mencatat setiap alokasi memori beserta baris kode yang memintanya. Fungsi get_traced_memory() mengembalikan pasangan (current, peak) — total yang sedang dipegang dan titik tertingginya sejak tracing aktif. Alat paling kuatnya adalah snapshot: potret seluruh alokasi pada satu saat, yang bisa dibandingkan dengan snapshot lain.

Simpan program berikut sebagai deteksi_bocor.py. Loop di tengahnya mensimulasikan pola kebocoran yang sangat umum: cache hasil yang terus bertambah dan tidak pernah dibersihkan:

python
import os
import tracemalloc

tracemalloc.start()
snapshot_awal = tracemalloc.take_snapshot()

# simulasi kebocoran: cache yang terus tumbuh dan tidak pernah dibersihkan
cache_hasil = {}
for i in range(50_000):
    cache_hasil[f"kunci-operasi-{i}"] = [float(i)] * 10

snapshot_akhir = tracemalloc.take_snapshot()
perbedaan = snapshot_akhir.compare_to(snapshot_awal, "lineno")

print("5 baris kode dengan pertumbuhan memori terbesar:")
for statistik in perbedaan[:5]:
    frame = statistik.traceback[0]
    nama_file = os.path.basename(frame.filename)
    print(f"  {nama_file}:{frame.lineno} : "
          f"size={statistik.size / 1024:.1f} KiB, "
          f"count={statistik.count}")

del cache_hasil
snapshot_bersih = tracemalloc.take_snapshot()
selisih = snapshot_bersih.compare_to(snapshot_akhir, "lineno")
teratas = selisih[0]
frame = teratas.traceback[0]
nama_file = os.path.basename(frame.filename)
print(f"setelah cache dihapus, top-1 : {nama_file}:{frame.lineno} : "
      f"selisih = {teratas.size_diff:,} byte")
saat_ini, puncak = tracemalloc.get_traced_memory()
print(f"current = {saat_ini:,} byte, peak = {puncak:,} byte")

Output yang Dihasilkan:

text
5 baris kode dengan pertumbuhan memori terbesar:
  deteksi_bocor.py:10 : size=12607.9 KiB, count=199984
  tracemalloc.py:560 : size=0.3 KiB, count=2
  tracemalloc.py:423 : size=0.3 KiB, count=2
  deteksi_bocor.py:9 : size=0.0 KiB, count=1
setelah cache dihapus, top-1 : deteksi_bocor.py:10 : selisih = -12,904,562 byte
current = 14,385 byte, peak = 12,913,258 byte

Mari kita baca laporan detektif ini:

  1. Baris compare_to(snapshot_awal, "lineno"): membandingkan dua snapshot dan mengelompokkan selisihnya per baris kode — kolom size adalah total alokasi aktif dari baris tersebut, bukan selisih.
  2. Baris deteksi_bocor.py:10 : size=12607.9 KiB, count=199984: 12,3 MB dan hampir 200 ribu alokasi berasal dari satu baris — isi cache (dict entry, string kunci, dan list nilai). Angka count inilah bau khas kebocoran: bukan satu alokasi raksasa, melainkan ratusan ribu alokasi kecil yang menumpuk dari satu titik kode.
  3. Baris selisih = -12,904,562 byte: setelah del cache_hasil, pembandingan snapshot kedua menunjukkan memori baris yang sama turun 12,9 MB — bukti bahwa yang tadi membesar memang cache ini, bukan hal lain.
  4. Baris peak = 12,913,258 byte: titik tertinggi memori terjadwal — angka acuan saat memverifikasi perbaikan.

Dalam program nyata, cukup ambil snapshot sebelum dan sesudah bagian yang dicurigai (satu request web, satu iterasi batch), lalu bandingkan. Bila size terus naik antar-iterasi untuk baris yang sama, kita menemukan kebocoran.

gc.get_objects: Menghitung Populasi Object #

Kadang kita perlu tahu apa yang hidup, bukan hanya berapa besar. Fungsi gc.get_objects() mengembalikan semua object yang dilacak garbage collector — semuanya, termasuk module, class, dan function:

python
import gc

jumlah_objek = len(gc.get_objects())
print(f"jumlah objek terlacak gc.get_objects(): {jumlah_objek:,}")

dari_dict = sum(1 for obj in gc.get_objects() if isinstance(obj, dict))
dari_list = sum(1 for obj in gc.get_objects() if isinstance(obj, list))
dari_fungsi = sum(1 for obj in gc.get_objects() if callable(obj))
print(f"di antaranya   dictionary: {dari_dict:,}")
print(f"di antaranya   list      : {dari_list:,}")
print(f"di antaranya   callable  : {dari_fungsi:,}")

Output yang Dihasilkan (jumlah bervariasi antar mesin dan versi Python):

text
jumlah objek terlacak gc.get_objects(): 5,087
di antaranya   dictionary: 470
di antaranya   list      : 70
di antaranya   callable  : 3,422

Pada skrip kecil ini sudah hidup lebih dari 5.000 object sebelum kita menulis satu baris pun — mayoritas adalah internal interpreter. Teknik yang berguna untuk pelacakan kebocoran: hitung object dari satu jenis mencurigakan (misalnya instance class Sesi atau Koneksi) di titik-titik waktu berbeda; bila jumlahnya hanya naik dan tidak pernah turun, ada reference yang tidak dilepas.

getsizeof vs Ukuran Total Bersarang #

Peringatan dari Chapter Memahami Memory Management berulang di sini: sys.getsizeof() dangkal. Untuk struktur bersarang, kita perlu menelusuri isinya secara recursive:

python
import sys


def ukuran_dalam(objek, sudah_dilihat=None):
    """Perkiraan total ukuran objek termasuk isi yang dirujuk."""
    if sudah_dilihat is None:
        sudah_dilihat = set()
    id_objek = id(objek)
    if id_objek in sudah_dilihat:
        return 0
    sudah_dilihat.add(id_objek)
    total = sys.getsizeof(objek)
    if isinstance(objek, dict):
        for kunci, nilai in objek.items():
            total += ukuran_dalam(kunci, sudah_dilihat)
            total += ukuran_dalam(nilai, sudah_dilihat)
    elif isinstance(objek, (list, tuple, set)):
        for isi in objek:
            total += ukuran_dalam(isi, sudah_dilihat)
    return total


menu = {
    "makanan": ["nasi goreng", "mie ayam", "sate"],
    "minuman": ("es teh", "es jeruk"),
}

print(f"sys.getsizeof(menu) : {sys.getsizeof(menu)} byte")
print(f"ukuran_dalam(menu)  : {ukuran_dalam(menu)} byte")

Output yang Dihasilkan:

text
sys.getsizeof(menu) : 184 byte
ukuran_dalam(menu)  : 666 byte

Set sudah_dilihat mencegah struktur berulang dihitung dua kali — perlakuan yang sama dengan ide visited set pada penelusuran graph. Untuk pekerjaan serius, library pympler menyediakan asizeof() yang menangani kasus-kasus sudut ini dengan lebih lengkap.

Kebocoran ala Python dan Pemantauan RSS #

Di C, kebocoran berarti lupa memanggil free() — memori hilang tanpa jejak. Python hampir tidak pernah bocor seperti itu, karena reference counting dan GC otomatis membebaskan object tanpa pemilik. Kebocoran Python hampir selalu berarti: object mati secara logis tetapi masih direferensikan, sehingga GC tidak berhak menyentuhnya. Tiga tersangka utamanya:

  • cache global yang tumbuh tanpa batas (contoh cache_hasil di atas),
  • data yang menempel pada object berumur panjang — list attribute pada singleton, session yang tidak dihapus,
  • pengecualian yang menyimpan traceback yang menyimpan frame yang menyimpan local variable.

Karena itu memantau RSS proses saja belum cukup memvonnis — ingat dari Chapter pertama bahwa memori yang dibebaskan pymalloc sering tidak dikembalikan ke OS. Alat praktisnya psutil: library pembacaan proses sistem yang bisa dicatat berkala dari dalam program untuk membedakan “RSS datar tinggi” (normal, arena dipertahankan) dari “RSS menanjak linear” (kebocoran). Kombinasikan dengan tracemalloc yang menunjuk baris kodenya, dan kebocoran biasanya selesai dalam satu sesi investigasi.