Ana içeriğe geç
Kesit Python
İleri · Eşzamanlılık ve performans

Python'ın iç yapısı: nesne, referans, bellek

Her şey nesne; id/is ve ==, referans sayımı ve gc, değişebilirlik ve copy/deepcopy, interning, __slots__, sys.getsizeof ve dis ile bytecode.

Uygulamada aç, 21 alıştırmayı çözÜcretsiz, hesap gerekmez, kurulum yok.

Bu sayfa dersin okuma sürümüdür. Metin bir kutuyu çalıştırmanı ya da adıma geçmeni söylüyorsa dersi uygulamada aç.

Neden iç yapı?

Ilgaz Bank'ta bir geliştirici müşteri listesini "kopyalayıp" değiştirdi; asıl liste de değişti ve yanlış hesaplara SMS gitti. Başka biri if durum is "aktif" yazdı, bazen çalıştı bazen çalışmadı. Üçüncüsü 10 milyon küçük nesneyle belleği doldurdu. Üçü de Python'ın nesne modelini bilmemekten kaynaklanır. Bu ders, günlük hatalara yol açan iç mekanizmaları gösterir.

Her şey nesne, değişken bir etiket

Python'da a = [1, 2] demek, bellekte bir liste nesnesi yaratıp a adını ona bağlamaktır. b = a ikinci bir ad bağlar, yeni nesne yaratmaz. id() nesnenin kimliğini (CPython'da adresi) verir; is "aynı nesne mi", == "aynı değer mi" sorar.

a = [1, 2, 3]
b = a                 # aynı nesneye ikinci etiket
c = [1, 2, 3]         # ayrı nesne, aynı değer
print(a is b, a is c, a == c)
print(id(a) == id(b), id(a) == id(c))

b.append(4)
print(a)              # a da değişti; çünkü aynı nesne

def ekle(lst):
    lst.append(99)    # çağıranın listesi değişir
ekle(a)
print(a)
Çağrı modeliPython "nesne referansıyla çağırır": fonksiyona nesnenin adresi gider. Değişebilir (mutable) nesneyi içeride değiştirirsen dışarısı etkilenir; değişmez (immutable) nesneye yeniden atama yaparsan yalnızca yerel ad değişir.

Değişebilirlik ve kopya

int, float, str, tuple, frozenset değişmezdir; list, dict, set ve kendi sınıfların değişebilir. Bağımsız bir liste istiyorsan kopyala: lst.copy(), lst[:], list(lst) sığ kopya (shallow) verir: dış kap yeni, içindeki nesneler aynı. İç içe yapıda copy.deepcopy gerekir.

import copy

musteri = {"ad": "Ayşe", "hesaplar": [1500.0, 320.0]}
sig = musteri.copy()
derin = copy.deepcopy(musteri)

sig["ad"] = "Ayşe K."            # dış sözlük bağımsız
sig["hesaplar"].append(50.0)     # iç liste paylaşılıyor!
print(musteri["ad"], musteri["hesaplar"])

derin["hesaplar"].append(999.0)
print(musteri["hesaplar"], derin["hesaplar"])

print(musteri["hesaplar"] is sig["hesaplar"], musteri["hesaplar"] is derin["hesaplar"])
Varsayılan argüman tuzağıdef ekle(x, liste=[]) yazarsan liste bir kez yaratılır ve tüm çağrılar arasında paylaşılır. liste=None ver, içeride if liste is None: liste = [].

Referans sayımı ve çöp toplayıcı

CPython her nesnede bir sayaç tutar: kaç ad/kap ona işaret ediyor. Sayı sıfıra inince nesne anında silinir. sys.getrefcount bunu gösterir (çağrının kendisi +1 ekler). Sayım, birbirini gösteren döngüsel referanslarda yetmez (a → b → a); bunları periyodik çalışan gc modülü bulup temizler.

import sys, gc

veri = [1, 2, 3]
print(sys.getrefcount(veri))          # 2: veri + getrefcount argümanı
alias = veri
print(sys.getrefcount(veri))          # 3
del alias
print(sys.getrefcount(veri))          # 2

class Dugum:
    def __init__(self, ad):
        self.ad = ad
        self.es = None

a, b = Dugum("a"), Dugum("b")
a.es, b.es = b, a                     # döngü
del a, b                              # sayaç sıfıra inmez
print("gc topladı:", gc.collect() >= 2)
print("gc eşikleri:", gc.get_threshold())
Pratiktegc'yi elle çağırman nadiren gerekir. Sızıntı şüphesinde tracemalloc ile anlık görüntü al, büyüyen satırı bul; çoğu zaman sorun küresel bir liste ya da önbelleğin sınırsız büyümesidir, gc değil.

Interning: is tuzağı

CPython küçük tam sayıları (-5..256) ve bazı kısa string'leri bir kez yaratıp paylaşır (interning). Bu yüzden a = 100; b = 100; a is b True çıkar ama 1000 için False olabilir. Çıkarım: değer karşılaştırması her zaman ==; is yalnızca None, True, False ve "aynı nesne mi" sorusu için.

a, b = 100, 100
c, d = 10**5, 10**5
print(a is b, c is d, c == d)          # interning küçük sayıda

s1 = "aktif"
s2 = "".join(["ak", "tif"])
print(s1 == s2, s1 is s2)              # değer eşit, kimlik farklı olabilir

import sys
s3 = sys.intern(s2)
print(s3 is sys.intern("aktif"))       # intern ile zorla paylaştırma

Bellek boyutu ve __slots__

sys.getsizeof(x) nesnenin kendi boyutunu verir (içindekileri saymaz). Normal bir sınıf örneği öznitelikleri bir __dict__ sözlüğünde tutar; milyonlarca küçük nesnede bu sözlükler belleği yutar. __slots__ tanımlarsan öznitelikler sabit yuvalarda saklanır, sözlük oluşmaz, bellek belirgin düşer ve erişim biraz hızlanır. Bedeli: tanımlanmamış öznitelik eklenemez.

import sys

class Islem:
    def __init__(self, hesap, tutar):
        self.hesap = hesap
        self.tutar = tutar

class IslemSlots:
    __slots__ = ("hesap", "tutar")
    def __init__(self, hesap, tutar):
        self.hesap = hesap
        self.tutar = tutar

n = Islem(1, 10.0)
s = IslemSlots(1, 10.0)
print("normal:", sys.getsizeof(n) + sys.getsizeof(n.__dict__), "bayt")
print("slots :", sys.getsizeof(s), "bayt")
print(hasattr(s, "__dict__"))
try:
    s.aciklama = "x"
except AttributeError as e:
    print("slots engelledi:", e)
print(sys.getsizeof([]), sys.getsizeof([1, 2, 3]), sys.getsizeof((1, 2, 3)), sys.getsizeof("ab"))
KavramAraçGünlük etkisi
Kimlik vs değeris, ==, idis yalnızca None/singleton için
Kopyacopy(), deepcopyİç içe yapıda deepcopy
Ömürrefcount, gcDöngüsel referans, sızıntı
Bellekgetsizeof, __slots__, tracemallocÇok sayıda küçük nesne
ÇalışmadisNeden bazı ifadeler hızlı

Bytecode: dis ile kısa bakış

Python kaynak kodu önce bytecode'a derlenir, sonra sanal makine bu komutları yorumlar. dis modülü bu komutları gösterir; "neden yerel değişken küreselden hızlı?" gibi soruların cevabı buradadır (LOAD_FAST vs LOAD_GLOBAL).

import dis

def kdv(tutar):
    return tutar * 1.2

komutlar = [i.opname for i in dis.get_instructions(kdv)]
print(komutlar)
print("sabit katlama:", [i.argval for i in dis.get_instructions(lambda: 60 * 60 * 24) if i.opname == "RETURN_CONST" or i.opname == "LOAD_CONST"])
Derinleş

Fonksiyona geçen ne? "Nesne referansıyla çağrı"

Python'da fonksiyona ne değer kopyası ne de C++'taki gibi bir referans geçer; parametre adı, çağıranın verdiği aynı nesneye bağlanır (call by sharing / call by object reference). Sonuç iki kurala iner: fonksiyon içinde nesneyi değiştirirsen (append, anahtar atama) çağıran da görür; ada yeni nesne atarsan (x = ...) yalnızca yerel ad değişir.

def indirim_uygula(sepet):
    sepet["indirim"] = 50            # nesneyi değiştirir: çağıran görür

def sepeti_sifirla(sepet):
    sepet = {}                       # yalnız yerel adı yeni nesneye bağlar

def kalem_ekle(kalemler):
    kalemler += ["kalem"]            # list için += yerinde değiştirir (extend)

def kalem_ekle_kopya(kalemler):
    kalemler = kalemler + ["kalem"]  # + yeni liste üretir

s = {"tutar": 400}
indirim_uygula(s); sepeti_sifirla(s)
print(s)

k1, k2 = ["defter"], ["defter"]
kalem_ekle(k1); kalem_ekle_kopya(k2)
print(k1, k2)
+= her türde aynı değilListe için a += b yerinde değiştirir (__iadd__); demet, str ve int değişmez olduğu için aynı satır yeni nesne üretir. Kodu okurken türü bilmeden +='ın yan etkisini tahmin edemezsin.

Liste neden "fazla" yer ayırır?

Liste elemanları değil, elemanlara işaretçileri (pointer) yan yana tutar. Her append'te yeniden bellek istememek için CPython listeyi büyütürken fazladan yer (over-allocation) ayırır; bu yüzden append ortalamada O(1)'dir. Boyutu aşağıda kademeli artarken görürsün; tam sayılar sürüme göre değişebilir.

import sys

liste = []
onceki = sys.getsizeof(liste)
print(f"boş: {onceki} bayt")
for i in range(20):
    liste.append(i)
    boyut = sys.getsizeof(liste)
    if boyut != onceki:
        print(f"{len(liste):2} eleman -> {boyut} bayt (yeniden ayırma)")
        onceki = boyut

Sayılar için kompakt kaplar: array, bytes, bytearray, memoryview

Bir listede bir milyon tamsayı, bir milyon ayrı int nesnesi + bir milyon işaretçi demektir. Aynı türden çok sayıda sayıyı ham bayt olarak saklamak için array modülü, ikili veri için bytes (değişmez) ve bytearray (değişebilir) vardır. memoryview ise bu tamponların bir bölümüne kopyalamadan bakmanı sağlar: büyük bir dosya tamponunu dilimlemek bellek harcamaz.

KapElemanDeğişebilir?Ne zaman?
listHerhangi bir nesneye işaretçiEvetGenel amaçlı, karışık türler
array.array('d', ...)Ham C doubleEvetTek tip çok sayı, numpy yoksa
bytes0-255 baytHayırDosya/ağ verisi, hash, anahtar
bytearray0-255 baytEvetTampon doldurma, protokol yazma
memoryviewBaşka tampona görünümKaynağa bağlıKopyasız dilimleme
numpy.ndarrayHam, çok boyutluEvetSayısal hesap (numpy dersi)
import sys
from array import array

n = 100_000
liste = [float(i) for i in range(n)]
dizi = array("d", liste)
liste_toplam = sys.getsizeof(liste) + sum(sys.getsizeof(x) for x in liste)
print(f"list : {liste_toplam / 1024:7.0f} KB")
print(f"array: {sys.getsizeof(dizi) / 1024:7.0f} KB")

tampon = bytearray(b"HDR|ILGAZ-BANKA|0001234500|TRY")
gorunum = memoryview(tampon)
tutar_alani = gorunum[16:26]          # kopya yok, aynı belleğe pencere
print(bytes(tutar_alani).decode())
tutar_alani[0:2] = b"99"              # pencereden yazmak kaynağı değiştirir
print(tampon.decode())

gc modülü: döngüsel çöp toplayıcıyı yönetmek

Referans sayımı nesnelerin çoğunu anında temizler; yalnızca döngüler (a → b → a) için ayrı bir toplayıcı çalışır. Bu toplayıcı nesneleri üç kuşakta (generation 0, 1, 2) izler: yeni nesneler 0. kuşaktadır, bir toplamadan sağ çıkanlar yukarı kuşağa geçer. Eşikler gc.get_threshold() ile görülür.

import gc

class Hesap:
    def __init__(self, no):
        self.no = no
        self.ortak = None

print("eşikler:", gc.get_threshold())
a, b = Hesap(1), Hesap(2)
a.ortak, b.ortak = b, a          # döngü
del a, b                         # referans sayısı 0'a inmez
print("toplanan nesne:", gc.collect() >= 2)
  • gc.collect(): elle tam toplama; toplanan nesne sayısını döndürür. Büyük bir toplu işin sonunda belleği hemen geri istemek için kullanılır.
  • gc.disable(): milyonlarca küçük nesne oluşturan kısa ömürlü bir betikte toplayıcının sık sık devreye girmesini engeller; iş bitince gc.enable(). Döngü üreten kodda bellek büyür, dikkat.
  • gc.freeze() (3.7+): sunucu süreçleri çatallamadan (fork) önce mevcut nesneleri toplayıcıdan saklar; kopya-yazma (copy-on-write) sayfalarını korur.
  • __del__ metoduna güvenme: ne zaman çağrılacağı garanti değildir, içindeki hata yalnız uyarı basar. Kaynak kapatmak için with ve bağlam yöneticisi (context manager) kullan.

Zayıf referanslı önbellek: WeakValueDictionary

Bir önbellek nesneleri sözlükte tutarsa, nesneler hiç ölmez ve bellek sızıntısı (memory leak) başlar. weakref.WeakValueDictionary değerlere zayıf referans tutar: programda o nesneye başka güçlü referans kalmayınca kayıt kendiliğinden silinir. "Bellekte zaten varsa aynı nesneyi ver, yoksa yükle" (identity map) kalıbı ORM'lerde böyle çalışır.

import weakref

class Urun:
    def __init__(self, kod):
        self.kod = kod

kayit = weakref.WeakValueDictionary()

def urun_getir(kod):
    u = kayit.get(kod)
    if u is None:
        u = Urun(kod)            # gerçekte veritabanından yüklenir
        kayit[kod] = u
    return u

x = urun_getir("AM-100")
print(x is urun_getir("AM-100"), len(kayit))
del x
print(len(kayit))               # kullanılmayan nesne önbellekten düştü
Sızıntı kontrol listesiUzun yaşayan süreçte bellek sürekli artıyorsa sırayla bak: sınırsız büyüyen modül düzeyi listeler/sözlükler (önbellek, olay kaydı), hiç çıkarılmayan gözlemci (observer) abonelikleri, kapanışların (closure) yakaladığı büyük nesneler, istisna nesnesinde saklanan traceback'in tuttuğu yerel değişkenler. tracemalloc anlık görüntü farkı (performans dersi) büyüyen satırı gösterir.
Gerçek işteBir web servisinin belleği günler içinde yavaşça artıp konteyner yeniden başlıyorsa klasik suçlu, istek başına bir şey ekleyip hiç silmeyen modül düzeyi bir sözlüktür. Önbelleğe her zaman bir sınır koy (lru_cache(maxsize=...), TTL ya da WeakValueDictionary) ve servis metriklerinde süreç belleğini (RSS) izle. "Yeniden başlatınca düzeliyor" bir çözüm değil, bir belirtidir.
Uzmanlaş

Değişmezliği tasarımda kullan

Fonksiyona verilen listeyi değiştirmeyen, yeni liste döndüren "saf" fonksiyonlar paylaşım hatalarını baştan önler. @dataclass(frozen=True), tuple, frozenset ve MappingProxyType değişmez arayüz sağlar; eşzamanlı kodda kilit ihtiyacını azaltır.

Zayıf referans

Önbellek tutarken nesnelerin ömrünü uzatmak istemezsin; weakref sayaç artırmadan referans tutar, nesne başka yerde silinince önbellek girdisi de düşer (WeakValueDictionary).

Bellek ayırıcı

CPython küçük nesneler için kendi havuz ayırıcısını (pymalloc) kullanır; serbest bırakılan bellek işletim sistemine hemen dönmeyebilir. Uzun süreli süreçlerde "bellek düşmüyor" gözlemi çoğu zaman sızıntı değil bu havuzlamadır. Gerçek sızıntıyı tracemalloc ile iki anlık görüntü arasındaki farktan bul.

Alıştırmalara geç (21)