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, nesnelergc.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.
maxsizeya 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.
