Python'da Bellek Sızıntılarını Avlamak

Selamlar, bu yazıda Python'da bellek sızıntılarını nasıl avladığımıza bakacağız. Konu, ilk bakışta 'Python'da garbage collector var, niye sızıntı olsun ki?' dedirten cinsten ama gerçek hayatta tam tersi: prod'da gece yarısı pod'unuz OOMKilled olduğunda anlıyorsunuz ki GC her şeyi çözmüyor. Lafı uzatmadan girelim.

Bellek sızıntısı, Python'da bellek kaybı değildir aslında. Daha çok 'artık ihtiyacımız olmayan nesnelere hâlâ bir yerden referans tutuyor olmamız' meselesidir. Referans varken GC kimseyi toplamaz. Sayaç tıkır tıkır artar, RSS şişer, bir gün konteyner ölür. Hepsi bu.

Python belleği nasıl yönetiyor?

Python iki katmanlı çalışır: birincisi reference counting (referans sayımı), ikincisi de döngüsel referansları toplayan cyclic garbage collector. Tek başına sayaç çoğu işi halleder, ama A nesnesi B'yi, B de A'yı tutuyorsa sayaç ikisi için de sıfırlanmaz. GC işte bu noktada devreye girer.

x = [1, 2, 3]   # sayaç = 1
y = x           # sayaç = 2
del x           # sayaç = 1
del y           # sayaç = 0, bellek serbest

class Dugum:
    def __init__(self):
        self.ebeveyn = None
        self.cocuklar = []

a = Dugum()
b = Dugum()
a.cocuklar.append(b)   # a -> b
b.ebeveyn = a          # b -> a (dongusel!)

Yukarıdaki son iki satırı yazdığınız anda referans sayımıyla kurtulamayacağınız bir döngü kurmuş oldunuz. GC bunu temizler ama bedava değil; sık döngü oluşturursanız GC turları uzar ve siz farkında olmadan CPU yiyorsunuz.

tracemalloc ile sızıntıyı koklamak

Python'ın yerleşik silahı tracemalloc. Belleğin nereden, hangi satırdan ayrıldığını izler. En sevdiğim kullanım şekli, şüpheli bir bloğun öncesinde ve sonrasında snapshot alıp ikisini karşılaştırmak.

import tracemalloc

def sizinti_yakala():
    tracemalloc.start()
    once = tracemalloc.take_snapshot()

    sizan = []
    for i in range(10000):
        sizan.append({'anahtar': f'deger_{i}' * 100})

    sonra = tracemalloc.take_snapshot()
    fark = sonra.compare_to(once, 'lineno')

    print('En cok bellek tuketen satirlar:')
    for stat in fark[:10]:
        print(stat)

    tracemalloc.stop()

Çıktıda hangi dosyanın hangi satırının kaç KB ek bellek aldığını birebir görürsünüz. Bence sızıntı avının %80'i burada bitiyor; geri kalanı zaten 'bu satır neden böyle yapıyor?' sorusu.

Uzun süre çalışan servislerde tracemalloc'u arka planda bir thread'e bağlayıp belirli aralıklarla snapshot karşılaştırması yapabilirsiniz. Eşik aşıldığında log'a en büyük ayırıcıları basın, gerisi rahattır.

Sık karşılaşılan sızıntı kalıpları

Sınırsız cache

Tecrübemden söyleyebilirim ki en sık görülen sızıntı sebebi 'şuraya küçük bir dict koyalım, geçici cache olsun' diye başlayan kodlardır. O dict bir daha temizlenmez.

from functools import lru_cache

# KOTU: sinirsiz, sonsuza kadar buyur
_cache = {}

def kotu_getir(anahtar):
    if anahtar not in _cache:
        _cache[anahtar] = veritabanindan_cek(anahtar)
    return _cache[anahtar]

# IYI: lru_cache ile boyut sinirli
@lru_cache(maxsize=1000)
def iyi_getir(anahtar):
    return veritabanindan_cek(anahtar)

lru_cache size üç satırla TTL'siz ama boyut sınırlı bir cache verir. TTL de istiyorsanız cachetools kütüphanesindeki TTLCache işinizi görür.

Event handler'lar ve closure'lar

Bir nesnenin bound method'unu (self.handle) bir emitter'a callback olarak verdiğinizde, emitter o metodu tutar; metot da self'i tutar. Yani nesne hiç ölmez. Çözüm weakref.WeakMethod. Closure'larda da benzeri var: bir fonksiyon içinde tanımladığınız iç fonksiyon, dış scope'taki büyük listeyi kullanmasa bile yakalar. Sadece ihtiyacınız olanı dışarı çıkarın.

Thread local birikimi

Thread pool'larda thread'ler tekrar kullanılır. threading.local() üzerine veri yığarsanız, sonraki istek aynı thread'e düştüğünde önceki kalıntıyı görür. Modern kodda contextvars.ContextVar daha güvenli; async görev bittiğinde otomatik temizleniyor.

objgraph: 'bu nesneyi kim tutuyor?'

tracemalloc size 'kim ayırdı'yı söyler, objgraph ise 'kim hâlâ tutuyor'u. Aşağıdaki kod, sınıf seviyesi listeye giren nesnelerin neden ölmediğini görselleştirir:

import objgraph, gc

class Baglanti:
    _kayit = []

    def __init__(self, ad):
        self.ad = ad
        Baglanti._kayit.append(self)   # sizinti tam burada

for i in range(100):
    Baglanti(f'b_{i}')

gc.collect()
objgraph.show_most_common_types(limit=10)

orneklem = objgraph.by_type('Baglanti')
objgraph.show_backrefs(orneklem[0], max_depth=3, filename='backrefs.png')

backrefs.png size düğüm-düğüm zinciri gösterir. Çoğu zaman PNG'ye gerek bile kalmadan show_most_common_types çıktısında 'şu sınıftan 12 bin tane var' diye yüzünüze çarpar.

Sık yapılan hatalar

  • GC'yi suçlamak: 'GC çalışmıyor' diye düşünmeden önce kendinize 'ben hâlâ referans tutuyor olabilir miyim?' diye sorun. Cevabın %95 evettir.
  • __del__ metoduna güvenmek: Döngüsel referans varsa __del__ GC'yi karıştırır, nesneler gc.garbage'a düşer. __del__ yerine context manager kullanın.
  • Lokalde test edip rahatlamak: Sızıntılar dakikada değil saatlerde belli olur. Staging'de en az birkaç saatlik yük testi şart.
  • Her şeyi cache'lemek: Cache çözüm değil; sınırsız cache kesin sızıntıdır. maxsize ya da TTL olmadan cache yazmayın.

Kapanış

Bu yazıda tracemalloc ile ölçtük, objgraph ile gördük, weakref ile kestik. Bence en kritik alışkanlık, sızıntıyı prod ölmeden önce yakalamak için tracemalloc tabanlı küçük bir izleyiciyi servisinize gömmek; sonradan koşmak yerine sürekli bilgi akıyor olur. Umarım faydalı olur, bir sonraki yazıda görüşmek üzere.