C++’da Placement New Kullanımı

Okan Barut
Nettsi Bilişim Teknoloji A.Ş.
5 min readAug 14, 2020

Merhabalar,

Bu günkü yazımızda, C++’ın çok da bilinmeyen bir özelliği olan placement new operatörü ile ilgili bir blog yazısı hazırladık. Kullanım amacı ve dikkat edilmesi gereken yanlarından bahsedip, birkaç örnek ile bunları pekiştirmeye çalışacağız. Paylaşılan bilgiler doğrultusunda genel amaçlı kullanıma yönelik bir kütüphane oluşturulmasıyla, kütüphanenin, projelerimizde faydalı bir eklenti olarak kullanılabileceğini umut ediyoruz.

C++’da dinamik bellek tahsisi (dynamic memory allocation) iki şekilde yapılmaktadır. Temel C++ yolu new/delete operatörlerini kullanımından geçmektedir. Ancak C varyantlarında olduğu gibi malloc/free de kullanılabilir. Genel olarak bu şekilde bellek tahsis edildiği zaman, C++ terminolojisinde free store adı verilen bölgede alan tahsis edilmektedir. (Bu alan eski C varyantlarında ise heap olarak geçmektedir.) [1]

Dinamik bellek tahsisinde işletim sisteminden alan talep edilir ve o alana yerleştirilmek istenen değişken bir takım adres ve yönerge verileriyle orada depolanır. placement new operatörü kullanımıyla, yukarıda belirtilen durumun tam aksine, stack (yığın) adı verilen bellek alanında, daha önceden tahsis edilmiş bir alan içerisinde, nesne yaratımı yapılabilmektedir. Yani görüldüğü gibi, placement new operatörünün newoperatöründen temel farkı, bellek tahsisini, constructor çağrımından ayırmasıdır. İşte tam olarak bu özellik, bize birkaç önemli fayda sağlamaktadır.

Bu faydaları detaylandırırsak;
* Yığın bellekte alan tahsisi genel olarak daha hızlıdır [2]
* placement new kullanımında alan tahsisi yapılmamakta, yalnızca `constructor` çağrılmaktadır. Dolayısıyla alan tahsisi için vakit kaybı yoktur [3]
* Yığın bellek üzerinde `contigous` (bitişik/ardışık) veri yapılarını depolayabiliriz
* `memory pool` (bellek havuzu) olarak kullanılabilecek bir yapıda yığın bellek çağırmak mümkündür, aynı zamanda bu belleğin yok edilmesi, içinde barındırdığı tüm objeleri de yok edeceği için `garbage collector` benzeri bir yapı tasarlamak mümkündür (Bu konuda önemli bir ayrıntı mevcut, ilerleyen aşamalarda bahsedilecektir)
* Nesneyi hafızada istediğimiz konuma koyabilmekteyiz

Şimdi gerçeklenimlerle yukarıdaki anlatımı güçlendirelim:

placement new operatörünün basit ve naif kullanımı aşağıdaki gibidir:

Ancak bu naif kullanımda bir takım sıkıntılar mevcuttur. İlk olarak yığın bellek, nesne sınıfına (some_class) `align` (hizalanma) edilmemiştir. İkincisi, placement new operatöründe `placement delete` bulunmamaktadır, ve nesne kullanıldıktan sonra nesnenin `destructor`’ının çağrılması gerekmektedir.

Bazı durumlarda, kullandığımız nesnenin destructor ‘ını çağırmak mecburiyetinde değiliz [4–5]. Trivial destructor’larda bunu çağırmanın da nesnenin lifetime’ı ile ilgili bir etkisi yoktur. Ayrıca, direkt olarak kullanılmış nesnenin üzerine yeni nesne yaratılabilir. Ancak bir takım yan etkileri olabilir, dolayısıyla, nesneyi yok etmek daha kapsayıcı bir çözüm olacaktır. Yan etki olup olmaması, nesnenin constructor veya destructor’unun trivial olup olmamasına bağlıdır. Trivial olma durumu genel olarak nesne içerisinde dinamik bellek tahsis edilip edilmemesine bağlıdır, eğer bu durum varsa, nesnenin ctor/dtor ‘u (constructor/destructor) trivial değildir.

Biz en genel ve uygulanabilir durum için kodumuzu güncellersek, yeni düzeltmelerle aşağıdaki hali almaktadır:

Hizalanma mevzusu çok önemli bir konudur. Doğru hizalanmamış bir belleğe placement new operatörüyle yerleşim yapılırsa, program çalışıyor görünse de, aslında `undefined behaviour` (belirsiz davranış) olarak tanımlıdır. Çünkü placement new operatörü, belleğin hizalı olduğunu varsayarak geri kalan işlemleri sürdürmektedir ve bu varsayımın göz ardı edilmesi UB (BD) ile sonuçlanacaktır.[6]

Hizalanma için standart kütüphanede bulunan std::aligned_storage özelliği kullanılabilir, ancak, bu yapının kullanımı ile ilgili bir takım sıkıntılar mevcuttur ve C++23 ve sonrası için kaldırılması planlanmaktadır [7–8]. Ancak dezavantajları bilinmek kaydıyla kullanılabilir, bir örneğini paylaşıyoruz:

Şimdi ufak bir yazılımla, placement new operatörünün bize sağladığı hız avantajını görmeye çalışalım. Aşağıdaki gist’te, önce new/delete kullanılarak nesne yaratıp yok edilmiştir, daha sonra ise placement new kullanılarak tek sefer tahsis edilmiş bellekte nesne yaratılıp yok edilmiştir. Bellek tahsisi nesnenin her yaratılıp yok edilmesinde tekrarlanmadığı için, belirli bir performans artışı beklenmektedir.

Benim sistemimde yaklaşık olarak ~%50 performans artışı görülmektedir, elbette yazılımın her çalıştırılmasında elde edilen değerler biraz oynayacaktır.

Normal new time: 15334
Placement new time: 6402

Yukarıda trivial nesnelerde destructor çağırmanın gerekli olmadığından bahsetmiştik, bir üstte paylaşılan kodda destructor çağırma işlemini kaldırırsak ufak bir performans artışı daha gözlemlemekteyiz.

Normal new time: 16219
Placement new time: 4312

Kısaca değinmek istediğimiz bir başka husus ise new[] operatörünün kullanımı ile ilgilidir. new[] operatörü, ilk birkaç byte’ı cookie olarak kullanabilmektedir [9]. Dolayısıyla, belirli bir overhead mevcuttur. Bu overhead’i görmek için aşağıdaki gibi bir test yazabiliriz:

char buffer[256];some_class *pe = new(buffer) some_class[5];// Buffer adresi ve pe adresi karşılaştırılıyor.
// Eğer new[] ileri bir adres dönmüşse cookie vardır
int overhead_bytes = (int)(reinterpret_cast<char*>(pe) - buffer);
std::cout << "The overhead is: " << overhead_bytes << "bytes.\n";

Zorunlu olmamakla beraber, bazı derleyiciler ve sistemlerde overhead mevcuttur, dolayısıyla bunun test edilmesi ve kullanılan yığın belleğin bu overhead’i de karşılayacak şekilde genişletilmesi gerekmektedir. Örneğin overhead 8 byte çıkmışsa, yığın bellek aşağıdaki gibi genişletilebilir:

char buffer[256 + 8];

Bellek tahsisinden elde edilen performansın yanında, nesne oluşturma (construct) işlemini daha sıkı yönetebildiğimiz için, aslında placement new operatörünün en sık kullanım alanlarından biri std::vector gibi container’lar olmaktadır [10]. Örnek vermek gerekirse, bir container yapısı, kendisi construct edildiğinde yalnızca alan tahsisi yapmak (std::reserve), kapsayacağı nesnelerin constructor’ını ise ayrı bir metotta (örn: push_back ) çağırmak isteyebilir.

Contigous ve memory pool olarak oluşturmak için placement new kullanılabileceğini paylaşmıştık. Havuz oluşturmanın birkaç avantajı vardır. Havuzun miktarı bellidir ve bütün nesneler aynı anda yok edilip otomatik garbage collector vari bir yapı oluşturulabilir. Aynı zamanda newoperatörünü override edebiliriz. Ancak havuz oluştururken, özellikle trivial olmayan nesneler için, exception handling yapmak gereklidir [11]. Örnek vermek gerekirse (aşağıdaki satırlar 11. kaynaktan alınmıştır);

Havuz yapısı aşağıdaki gibiyse:

class Pool {
public:
void* alloc(size_t nbytes);
void dealloc(void* p);
private:
// bir takım havuz değişkenleri
};
void* Pool::alloc(size_t nbytes)
{
// bellek tahsisi için kod
}
void Pool::dealloc(void* p)
{
// bellek serbestlenmesi için kod
}

Havuzu placement new ile kullanırsak:

Pool pool;
// ...
void* raw = pool.alloc(sizeof(Foo));
Foo* p = new(raw) Foo();
// ... p kullanılırp->~Foo(); // dtor direkt olarak çağrılır
pool.dealloc(p); // serbestlenme direkt olarak çağrılır

Ancak bu yaklaşımdaki temel sıkıntı Foo() nun ctor unda yaşanabilecek bir exception’ın memory leak oluşturmasıdır. Dolayısıyla bir düzeltme yapmak gerekirse, aşağıdaki şekilde ilerlenebilir:

// Bunun yerine: "pool.alloc(sizeof(Foo))"
// Bunu kullanabiliriz, exceptionları alt satır için yakalamayacağız
void* raw = operator new(sizeof(Foo), pool);

// Foo()'nun ctor exceptionlarını yakalayacağız
try {
p = new(raw) Foo();
}
catch (...) {
// Buraya gelirsek exception yakalanmıştır
operator delete(raw, pool); // override edilmiş bir delete metodu
throw; // Gerekirse re-throw yapabiliriz.
}

Genel olarak, placement new bir takım kullanım zorlukları getirse de, performans ve detaylı kontrol sağladığı için C++ yazılımcılarının kullanabileceği önemli araçlardan biridir. Yazımızı burada sonlandırmakla beraber, okuyucuyu daha detaylı bilgiler için kaynakları incelemeye davet ediyoruz.

Kaynaklar

[1] https://stackoverflow.com/questions/1350819/c-free-store-vs-heap
[2] https://stackoverflow.com/questions/79923/what-and-where-are-the-stack-and-heap/80113#80113
[3] https://stackoverflow.com/questions/35087204/how-c-placement-new-works
[4] https://stackoverflow.com/questions/41385355/is-it-ok-not-to-call-the-destructor-on-placement-new-allocated-objects
[5] https://stackoverflow.com/questions/27552466/c-is-constructing-object-twice-using-placement-new-undefined-behaviour
[6] (kaynak: https://stackoverflow.com/questions/11781724/do-i-really-have-to-worry-about-alignment-when-using-placement-new-operator)
[7] (kaynak1: https://stackoverflow.com/questions/58288225/can-stdbyte-replace-stdaligned-storage,
[8] http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1413r2.pdf)
[9] https://stackoverflow.com/questions/24603142/why-is-operator-new-necessary-when-operator-new-is-enough
[10] https://stackoverflow.com/questions/33906008/c-placement-new-in-a-home-made-vector-container
[11] https://isocpp.org/wiki/faq/dtors#memory-pools

--

--