fatih bakır

Referans Takibi

Üç boyutlu bir oyunda, oyuncu tarafından görülmesini istediğimiz tüm varlıklar, ekran kartı tarafından çizilmesini sağlayan ve temel şeklini belirten bir üçgen örgüsüne (Mesh) sahip olurlar. Çizilme vakti geldiğinde, her varlığın mesh bilgileri ekran kartına gönderilir ve oyuncumuz parlak grafikleri ekranda görür.

Örneğin, bir varlığın tanımını aşağıdaki gibi yazabiliriz:

Mesh LoadMesh(const std::string& meshName);

class Entity
{
    Mesh m;

public:
    Entity(const std::string& meshName) : m { LoadMesh(meshName) } {}
};

Bu noktada önemli iki gözlem yapmamız gerekiyor: birincisi Mesh nesnesi içinde çok fazla geometrik bilgi barındırığı için hafızada devasa boyutlarda yer kaplayabilir. İkincisiyse, görsel olarak oyunlardaki nesneler kartaneleri gibi çeşitli değildirler ve aynı anda pek çok nesne ortak bir modeli paylaşır.

Bu gözlemleri yararlı bir şekilde kullanmak istersek, yapmamız gereken hafızada bir meshten sadece bir adet tutmak, ve bu meshi, aynı modele sahip bütün varlıklar arasında paylaştırmak. Bu durumda, artık Entity nesnelerimiz koca bir Mesh nesnesi değil, göz ardı edilebilir büyüklükte bir pointer tutacaklar:

Mesh* LoadMesh(const std::string& meshName);

{
    Mesh* m;

public:
    Entity(const std::string& meshName) : m { LoadMesh(meshName) } {}
    Entity(const Entity&) = delete; // artık kopyalamaya izin vermiyoruz
};

Harika! Her ne kadar LoadMesh fonksyonunun nasıl yazılacağını konuşmamış olsak da, yapması gereken verilen isimdeki meshi daha önce hafızaya yüklediyse, daha önce yüklediği nesnenin adresini dönmek, eğer yüklemediyse yükleme işleminden sonra adresi dönmek.

Fakat, C++ biliyorsanız, bir classta düz (aptal) pointerlar kullandığınız durumda başınızın çok temiz ağrıyabileceğini biliyorsunuz demektir. Örneğin, eğer verilen meshi kullanan ilk varlıksak hafızaya koca bir modelin yüklenmesine sebep oluyoruz demektir. Hafızaya bir şey yüklemek, sistemden alan ödünç almak demektir ve sorumluluk sahibi varlıklar olarak, ödünç aldığımız şeyleri geri vermemiz gerekir. > Koca derken, bir modelin kaplamalarıyla beraber onlarca megabayt alan kullanabildiğden bahsediyorum. Leak etmezsek iyi olabilir.

Hemen bir destructor yazarak işe koyulabiliriz:

class Entity
{
    // ayni seyler
    ~Entity()
    {
        delete m; // harikayiz
    }
};

Eğer ki yazıyı okumaya ortasından başlamadıysanız, hemen bu kodun çok korkunç olduğunu siz de göreceksiniz: evet, eğer bu meshe sahip ilk varlıksak modeli biz yüklemeliyiz, fakat bu modele sahip son varlık değilsek bu modeli silmememiz gerekir zira kalan varlıklar hâla çizilmek istiyor olabilir ve saygılı nesneler olarak bunu yapmamalıyız. Ne yapsak, ne yapsak… > Ayrıca LoadMesh fonksyonu önceden yüklediği nesneleri yüklemeyeceği için, meshe sahip son varlık olsak bile bundan sonra bu meshi kullanan varlıklar yaratılırsa, onlara yanlış bir pointer dönecek.

Gözlem zamanı! Yukarıdaki (ve yandaki) sorunlar, düzenli bir yoldan (LoadMesh) oluşturulan mesh nesnelerini kafamıza göre silip kimseye haber verme zahmetinde bulunmamamızdan kaynaklanıyor. Dolayısıyla, LoadMesh ve aynı modeli paylaşan hiç bir varlığın sahip olduğu pointerın sallandığından haberi olmuyor. > Sallanan (dangling) pointer: gösterdiği alan silinmiş olan pointer.

Bunu çözmek için, standart kaynak yönetimi fonksyonlarını taklit edebiliriz. Örneğin, malloc ile alan ayırırsanız, o alanı free ile vermeniz gerekir. Bu örnekleri epey çoğaltabiliriz: new/delete, open/close (ve varyasonları), lock/unlock (mutexler) … gibi. Temel şablon, kaynak ödünç aldığımız bir fonksyonu tamamlayan bir de geri verme fonksyonunun olması.

Peki, bu durumda meshleri iade etmek için UnloadMesh adlı bir fonksyon oluşturalım. Bu metodun yapması gereken temel iş, kendine verilen pointerin gösterdiği meshi hafızadan kaldırmak. Fakat yine saçma yanlış bir kod yazıp uzatmak istemiyorum. Bu durumda da, sonradan oluşacak varlıklar için sallanan bir pointer dönmeyecek olsak da, var olan varlıkların sahip olduğu pointerlar sallanmaya başlayacak. > Yazılarda metod ve fonksyonu eşanlamlı olarak kullanıyorum çünkü fonksyonun nasıl yazıldığı konusunda hâla emin değilim: fonksyon mu, fonksiyon mu?

Fakat bir ekleme yapmak istiyorum çünkü yukarıda tarif ettiğim iade yönteminin geçerli olduğu durumlar var: gösterilen kaynağa sahip olan programda sadece tek bir nesne varsa, kaynağa sahip olan nesneyle beraber kaynağı de silebiliriz. Bu durumda, kaynakları iki kategoriye ayırabiliriz: sahibiyle beraber güvenli olarak silinebilen eşsiz kaynaklar, ve programda aynı anda pek çok nesne tarafından paylaşılan paylaşımlı kaynaklar. Bu yazıda, başlıca ikinci kategorideki kaynakları konuşacağız zira ilk kategoriyi yönetmek son derece basit.

Peki, meshlerimiz gibi paylaşılan kaynaklar için yapmamız gereken ne?

Referans takibi, yetiş!

Şanslıyız ki, tam olarak bu gibi sorunları çözmek için geliştirilmiş pek çok çözüm var. Bu yazıda hem göreli olarak basitçe yazılabildiği, hem de işlem olarak genelde daha az iş yaptığı için sık sık tercih edilen referans sayma (reference counting) tekniğini göreceğiz. Bu teknik son derece basit olduğu kadar yaygın da. Mesela PHP, Objective C, Perl, Python gibi pek çok ünlü dilin temel hafıza yönetim aracı olarak kullanılıyor. İşletim sistemi seviyesinde de dosya, soket gibi kaynakların takibi için yararlanılıyor.

Referans sayma yöntemini kısaca bir kaynağın bir anda kaç nesne tarafından sahiplenildiğinin takibini tutma işlemi olarak özetleyebiliriz. Örneğin, bir model meshine kaç varlığın sahip olduğunu bir şekilde takip edebilirsek, oyunda bir modeli kullanan tam olarak 0 varlık kaldığında modeli silebiliriz. Bunun yanı sıra, daha sonra bu modelle oluşacak varlıklar için yine bu sayıya bakarak modeli tekrar yüklemeyi seçebiliriz.

Bu işe gireceksek, referans takibini nerede yapmamız gerektiğini konuşmamız gerekiyor. Bu konuda bir kaç seçenek var:

Varlıklar sahip oldukları meshin paylaşım detaylarına sahip olsun

Bu durumda, LoadMesh yada UnloadMesh metodlarımızın referans sayımı işleminden haberdar olmasına bile gerek yok, malloc/free ikilisi gibi sorgusuzca yükleme ve silme yapabilirler. Fakat bu durumda paylaşım işini sadece ve sadece varlıklar içinde yapabiliriz. Varlıkların dışına bu pointerlardan aktarırsak ve o meshe sahip varlık kalmadığında silersek, dışarıda sallanan bir pointera sebebiyet vereceğiz.

Eğer ki oyunda meshlere sadece ve sadece varlıkar ulaşabilecekse, bu kolay bir çözüm olabilir: Entity sınıfı içinde static bir map<string, int> nesnesi kullanabiliriz, mapin her eleman bir meshin adını ve o meshe o an sahip olan varlık sayısı tutar. Yeni bir varlık oluştuğunda kendi meshimize ait sayıyı bir arttırıp, varlıkları yok ederken sayıyı bir arttırır ve 0 olduğunda silersek bütün sorunlarımız çözülebilir. > LoadMesh hafızada bir meshten tek bir tane bulunmasını sağlamaya devam ediyor.

class Entity
{
    static std::map<std::string, int> refCount;
    Mesh* m;
public:
    Entity(const std::string& meshName) : m { LoadMesh(meshName) }
    {
        if (refCount.find(meshName) == refCount.end())
            refCount.insert(meshName, 0);
        refCount[meshName]++;
    }

    ~Entity()
    {
        refCount[meshName]--;
        if (refCount[meshName] == 0)
        {
            UnloadMesh(m);
        }
    }
};

Mesh yönetim metodlarımız sayımdan sorumlu olsun

Bu durumda, varlıkların içinde yaptığımız referans sayım işleminden, LoadMesh ve UnloadMesh çağrıları sorumlu olacak. Bu durumda varlıkların kodu çok basitleşiyor ve mesh yönetim metodlarını kullanan herkes kolayca güvenli bir biçimde mesh yükleyip iade edebiliyor. Bu açıkça yukarıda bahsettiğimiz yöneteme göre üstün, zira hem mesh paylaşımını kullanan kod çok daha temiz oluyor hem de bu sistemi kodun her yerinde kullanabiliyoruz.

Ayrıca bu yöntemin implementation’ı tahminen daha kolay olacak zira bir meshten hafızada sadece bir adet tutabilmek için mesh isimleri ve pointerları arasında ilişkisel bir bağ kurmak gerektiği için, muhtemelen bir map<string, Mesh*>‘ı hali hazırda tutuyoruz. Bu yapıyı referans sayılarını da takip edecek hâle getirmek son derece basit, zira doğrudan bir mesh pointerı yerine, pointer ve referans sayısından oluşan bir std::pair tutmak bütün işimizi çözebilir:

std::map<std::string, std::pair<int, Mesh*>> _meshes;

Mesh* LoadMesh(const std::string& name)
{
    if (_meshes.find(name) == _meshes.end())
    {
        _meshes.insert(name, std::make_pair(0, nullptr));
    }
    if (_meshes[name] == nullptr)
    {
        _meshes[name].second = __loadMeshFromDisk(name);
    }
    
    _meshes[name].first++;
    return _meshes[name].second;
}

void UnloadMesh(Mesh* mPtr)
{
    auto it = std::find_if(_meshes.begin(), _meshes.end(), 
    [=](const std::pair<int, Mesh*>& p){
        return p.second == mPtr;
    });
    
    if (it == _meshes.end()) 
	{ /* olmayan meshi silmeye çalıştık, hatayı raporla */}
    
    it->second.first--;
    if (it->second.first == 0)
    {
        delete it->second.second;
        it->second.second = nullptr;
    }
}

Küçük bir sorun, ve harika bir çözüm

Şu ana kadar ne yaparsak yapalım kaynağı sahiplenen her nesnenin bir mesh ile işi bittiğinde belirgin bir şekilde iade etmesi gerekiyor. Varlık kodumuzda destructorda meshlerini iade ediyoruz ve dolayısıyla çok sorun değil, ancak görünmese de aşağıdaki örnekte alınan meshin iade edilememe ihtimali var:

//...
Mesh* mesh = LoadMesh("Yapılar/Ev");
BB* box = BoundingBox(mesh); //meshi kapsayan en küçük kutuyu hesapla
UnloadMesh(mesh);
//...

Evet doğru bildiniz! Eğer ki BoundingBox bir sebepten exception fırlatırsa, yüklediğimiz mesh asla ama asla hafızadan silinmeyecek! Temel sorun, aldığımız her kaynağı işimiz bittiğinde iade etmek zorunda olmamız. Fakat, bir fonksyon içinde bile, fonksyondan birden çok çıkış yolu olabileceği için kaynakla işimizin tam olarak ne zaman biteceğini takip edemiyoruz. Diğer diller try-catch blokları için ek olarak finally yapısını da ekleyerek biraz çözüm getirmeye çalışsa da ne tam bir çözüm sunuyorlar ne de kolay kullanılıyorlar. Exceptionların temel özelliklerinden biri, kendilerini idare edecek bir try-catch bloğu bulana kadar stackde tırmanmaları. Exception atabilecek her fonksyonu try-catch-finally bloklarıyla süsleyecek olduktan sonra exceptionları kullanmanın hiç bir anlamı yok. Bunun dışında, bir fonksyon birden çok noktada dönebilir, dolayısıyla, her return ifadesinden önce fonksyonda önceden aldığımız tüm kaynakları iade mi edeceğiz?

C++’ın bu sorunu çözmek için doğrudan bir dil desteği yok ama, belki de en önemli tekniklerinden biri olan RAII idiomu var. Bu teknik, kısaca bir kaynağın yaşam sürecini, bir nesnenin yaşam sürecine bağlamayı önerir. Bildiğiniz gibi, C++’da, fonksyondan ne şekilde çıkılsa çıkılsın, stackte oluşmuş tüm nesnelerin destructorları çağırılır. Üstteki sorunumuz da tam olarak bu. Fakat meshin kendine değil, adresine sahip olduğumuz için sadece pointerı siliyoruz, meshin kendini değil. Bunu çözmek için, mesh pointerlarını saracak yeni bir sınıf yazabiliriz: > idiom‘un sanırım tam bir türkçe karşılığı yok

class MeshHandle
{
    Mesh* _ptr;
public:
    MeshHandle(Mesh* ptr) : _ptr(ptr) {}
    ~MeshHandle() { UnloadMesh(_ptr); }
    
    Mesh* get() { return _ptr; }
    // -> ve * operatörünü implement edersek, doğrudan pointer gibi davranabiliriz
};

Son derece basit değil mi? Bu yöntem ne kadar kısa da olsa, üst paragrafta bahsettiğimiz bütün exception ve çoklu dönüş yollarından oluşan problemleri çözebiliyor:

MeshHandle mesh = LoadMesh("Yapılar/Ev"); // constructor çağrılacak
BB* box = BoundingBox(mesh.get());

Bu durumda, fonksyon bittiği gibi, MeshHandle nesnemiz yok olacak. Bu da, meshimiz için UnloadMesh fonksyonunun çağırılmasını sağlayacak: herkes mutlu oldu. Daha da iyisi, sunduğumuz bu sınıfın ek herhangi bir hafıza yada işlem masrafı yok, hala ham UnloadMesh çağrılarıyla aynı performansa sahibiz, sadece daha iyi bir şekilde. Üstüne kodda yorum olarak yazdığım operatörleri implement edersek, kodun geri kalanında 1 karakter bile değiştirmeden değişikliği yapabilirdik.

Son olarak Entity sınıfımızı da handleımızı kullanacak hale getirelim:

class Entity
{
    MeshHandle mesh;
public:
    Entity(const std::string& meshName) : m { LoadMesh(meshName) } {}
};

Son notlar

Referans takibini ilk duyduğunuzda sevimli bir araç gibi görünebilir fakat bu yöntemi kullanmadan yada bu yöntemi kullanan diğer metodları (std::shared_ptr) kullanmadan önce düşünmeniz gereken çok önemli bir kaç nokta var.

Öncelikle, bütün bu takip işleri bedavaya gelmiyor: bu örnekte varlıkların kopyalabilir olmadıklarını kabul ettik, fakat kopyalanabilir oldukları durumda, her kopyalanma işleminde referans sayısının değişmesi gerektiğini unutmayın.

Toparlamak adına, referans sayımı, hayat süreci önden kestirilemeyen paylaşımlı kaynakları yönetmek için inanılmaz kullanışlı olabilen bir yöntem. Fakat, bu kolaylık bedavaya gelmiyor: kaynağı gösteren handlelar her kopyalandığında, bir değişkeni arttırdığınızı ve potansiyel olarak bir cache miss yediğinizi unutmayın. Gözardı edilebilir gibi görünse de, bütün kaynaklarınızı bu şekilde yönetmeye çalışırsanız, bu işlemlerin birikeceğini unutmayın.

Handle, bir nesneyi gösteren herhangi bir yapı olabilir: pointerlar, indexler, referanslar yada MeshHandle gibi özel nesneler gibi.

Sonuç olarak olabildiğince eşsiz kaynaklar kullanın fakat eğer ki probleminizi mutlaka paylaşımlı kaynaklar üzerinden ifade etmeniz gerekiyorsa, masrafların ve risklerin farkında olun.


Share this: