C++ Dilinde Kopyalama Optimizasyonu — II

Serkan Eser
Nettsi Bilişim Teknoloji A.Ş.
26 min readJan 8, 2021

Kopyalama optimizasyonuyla ilgili bir önceki blog yazımızda genel olarak yüzeysel ve derin kopyalama işlemlerinden, taşıma işleminden, geçici değişkenlerden ve geri dönüş değeri optimizasyonundan (NRVO) bahsetmiştik.

Bu yazımızda konuyla ilgili incelemelerimize devam edeceğiz. Genel olarak C++11 öncesi geçici nesnelerin kopyalanmasına ilişkin önerilmiş bir yöntemi ve kopyalama işlemininde kaynakların ortak kullanımını (implicit sharing) inceleyeceğiz.

Bir sonraki yazımızda ise C++11 ile dile eklenen sağ taraf referansları (rvalue reference) ile benzer işlemlerin nasıl çok daha kolay yapılabildiğine bakacağız.

Tür Dönüşüm Fonksiyonları İle Geçici Nesnelerin Algılanması

Bu yöntem Andrei Alexandrescu tarafından C++11 standartları öncesinde, geçici nesnelerin belirlenmesiyle ilgili dil temelli bir yaklaşım geliştirilene kadar, geçici bir çözüm olarak önerilmiştir. Bu yöntemde geçici nesneler belirlenmekte ve derin kopyalama yapmak yerine geçici nesnelerin gösterdikleri kaynaklar taşınmaktadır. Bu bölümü C++11 ile gelen araçlara neden ihtiyaç duyulduğuna örnek olması açısından ekledik. Dilerseniz bu bölümü atlayabilirsiniz.

C++11 öncesinde dil içerisinde geçici nesneleri algılamak için gerekli araçların bulunmadığını söylemiştik. Bu yöntemde geçici nesneleri belirlemek için kullanıcı tarafından tanımlanan dönüşüm fonksiyonları (user-defined conversion function) kullanılmaktadır.

İncelemelerimize temel teşkil ettiğinden dolayı ilk olarak kullanıcı tarafından tanımlanan tür dönüşüm fonksiyonlarına bakalım. Daha önceki yazımızda sınıfın tür dönüştüren constructor fonksiyonu (converting constructor) kullanılarak başka türden değerlerin ilgili sınıf türüne dönüştürebildiğini görmüştük. Account sınıfı üzerinden bu duruma tekrar bakalım.

#include <iostream>using namespace std;class Account
{
public:
Account() : m_no(0), m_balance(0) {}
Account(int no, double balance = 0)
: m_no(no)
, m_balance(balance) {}
void print() const
{
cout << "no: " << m_no << endl;
cout << "balance: " << m_balance << endl;
}
private:
int m_no;
double m_balance;
};
int main()
{
Account acc;
acc = 1234; //constructor yoluyla tür dönüşümü yapılmaktadır
acc.print(); return 0;
}

Kodu aşağıdaki gibi derleyip çalıştırabiliriz.

$ g++ -otest test.cppno: 1234
balance: 0

Hesap numarasına karşılık gelen int türden değer, sınıfın tür dönüştüren constructor fonksiyonu kullanılarak, Account türünden geçici bir nesneye dönüştürülmüş ve sonrasında acc nesnesine atanmıştır. Benzer şekilde bir sınıf türünden başka türlere de dönüşüm mümkündür. Dönüşüm için sınıfa ait dönüşüm fonksiyonları yazılabilir. Örnek olarak Account sınıfına bir dönüşüm fonksiyonu ekleyelim. Account sınıfı bir hesap numarası ve bakiyeyi tutmaktadır. Account sınıfının tuttuğu bakiyeyi direkt olarak bir double değişkene atmak isteyelim. Bu durumda aşağıdaki gibi bir dönüşüm fonksiyonu tanımlayabiliriz.

operator double() { return m_balance; }

Tür dönüşüm fonksiyonunu ekledikten sonra Account sınıfımız aşağıdaki gibi olacaktır. Kodu derleyip çalıştıralım.

#include <iostream>using namespace std;class Account
{
public:
Account() : m_no(0), m_balance(0) {}
Account(int no, double balance = 0)
: m_no(no)
, m_balance(balance) {}
void print() const
{
cout << "no: " << m_no << endl;
cout << "balance: " << m_balance << endl;
}
operator double() { return m_balance; }private:
int m_no;
double m_balance;
};
int main()
{
Account acc(1234, 500);
acc.print();
double balance = acc; // operator double() çağrılmaktadır
cout << "balance: " << balance << endl;
return 0;
}

Kodu çalıştırdığımızda double türden balance nesnesinin acc nesnesinin private balance değerini gösterdiğini görüyoruz.

no: 1234
balance: 500
balance: 500

Derleyici Account türünden bir değerin double türden bir nesneye atandığını gördüğünde sınıf içinde dönüşüm için tanımlı bir fonksiyon olup olmadığına bakmaktadır. Böyle bir fonksiyon tanımlı ise otomatik olarak sınıf türünden ilgili türe dönüşüm sağlanmaktadır. Bu dönüşüm fonksiyonu sayesinde Account türünden bir nesne double olarak işleme girebilmektedir.

Not: C++11 standartlarıyla beraber, sınıfın tür dönüştüren constructor fonksiyonlarında olduğu gibi, tür dönüşüm fonksiyonlarında da explicit anahtar sözcüğü kullanılabilmektedir. Bu sayede tür dönüşümünün derleyici tarafından otomatik olarak yapılması engellenebilmektedir.

Dönüşüm fonksiyonları geçici nesneleri belirlemek için kullanılabilir. Account sınıfına ait aşağıdaki örneği derleyip çalıştıralım.

void foo(double balance)
{
cout << __PRETTY_FUNCTION__ << endl;
}
void foo(Account& balance)
{
cout << __PRETTY_FUNCTION__ << endl;
}
int main()
{
Account acc(1111, 500);
foo(acc);
foo(Account(2222, 500)); return 0;
}

Sırasıyla aşağıdaki fonksiyonlar çağrıldığını görüyoruz.

void foo(Account&)
void foo(double)

Derleyici foo fonksiyonuna geçirilen argümana göre aday (candidate) foo fonksiyonlarından uygun olanını belirlemeye (overload resolution)
çalışmaktadır. İlk foo çağrısı için, herhangi bir tür dönüşümüne gerek duyulmaksızın, parametresi Account& olan foo fonksiyonu çağrılmaktadır. İkinci foo çağrısı için ise, const olmayan referanslar geçici nesnelere bağlanamadığı için, Account& parametreli foo fonksiyonu uygun (viable) değildir. Bu durumda Account türünden geçici nesne ilk olarak double türüne dönüştürülmüş ve sonrasında double parametreli foo fonksiyonu çağrılmıştır. Böylelikle geçici ve geçici olmayan Account nesneleri için ayrı foo fonksiyonları çağrılmaktadır. Amacımız geçici nesnelerin tuttukları kaynakları taşımak ve sonrasında geçici nesneleri geçerli yani delete edilebilir bir durumda bırakmak olduğu için çağrılan fonksiyon içinde geçici nesneye erişilmesi gerekmektedir. Bu yüzden örneğimizdeki gibi bir dönüşüm fonksiyonu tek başına yeterli olmayacaktır. Fakat genel yaklaşım uygun tür dönüşüm fonksiyonlarını kullanmak şeklinde olacaktır. Gerekli dönüşüm fonksiyonlarına geçmeden önce geçici nesnelerle referanslar arasındaki ilişkiye bakmak faydalı olacaktır. Sonrasında geçici nesnelerin belirlenmesiyle ilgili incelememize devam edeceğiz.

C++11 öncesinde geçici nesnelerin referans yoluyla değiştirilmelerine izin verilmemiştir. Geçici nesnelere ancak const referanslar bağlanabilmektedir. Bu yüzden geçici nesneler üzerinden kopyalama yapabilmek için kopyalamaya izin veren sınıfların copy constructor ve assignment operator fonksiyonlarının parametreleri const referans şeklindedir. Örneğin std::string sınıfının copy constructor ve assignment operator fonksiyonlarının bildirimleri aşağıdaki gibidir.

string (const string& str);
string& operator= (const string& str);

Bu sayede hem geçici hem de bir isimle erişilen geçici olmayan nesnelerden kopyalama mümkün olmaktadır. Aşağıda bu durumu ilişkin bir örnek verilmiştir.

#include <iostream>using namespace std;int main()
{
string s1;
string s2;
s1 = "text"; // geçici nesne kopyalanıyor
s1 = string("text"); // geçici nesne kopyalanıyor
s2 = s1; // geçici olmayan nesne kopyalanıyor
return 0;
}

Kendi örnek yazı sınıfımız String üzerinden bu kullanıma daha yakından bakalım.

#include <iostream>
#include <cstring>
using namespace std;class String
{
public:
String();
String(const char *str);
String(const String& rhs);
~String();
void print() const;
private:
char *m_p;
size_t m_len;
};
String::String()
{
m_len = 0;
m_p = NULL;
}
String::String(const char *str)
{
m_len = strlen(str);
m_p = new char[m_len + 1];
strcpy(m_p, str);
}
String::String(const String& rhs)
{
cout << __PRETTY_FUNCTION__ << endl;
m_len = rhs.m_len;
m_p = new char[m_len];
for (int i = 0; i < m_len; ++i) {
m_p[i] = rhs.m_p[i];
}
}
String::~String()
{
delete[] m_p;
}
void String::print() const
{
cout << m_p << endl;
}
int main()
{
String s1 = String("text");
s1.print();
String s2 = s1;
s1.print();
return 0;
}

Örnek kod sorunsuz şekilde derlenip çalışacaktır. copy constructor fonksiyonundan const belirleyicisini kaldırarak örneği yeniden derlemeye çalıştığımızda derleyici geçici nesne için eşleşen bir kopyalama fonksiyonu bulamadığı için hata vermektedir. Hata mesajlarının bir bölümü aşağıdaki gibidir.

error: no matching function for call to ‘String::String(String)’note: no known conversion for argument 1 from ‘String’ to ‘String&’note: no known conversion for argument 1 from ‘String’ to ‘const char*’

Amacımız yalnız geçici nesneler için çağrılacak yüklenmiş fonksiyonları (overloaded function) yazmak. const referanslar hem geçici hem de geçici olmayan nesnelere bağlandığından dolayı, normalde uygulanan yöntemin aksine, kopyalama fonksiyonlarında const referansları kullanmayacağız.
Geçici nesneleri diğer nesnelerden ayırt edebilmek için 3 tip yüklenmiş fonksiyona ihtiyacımız olacak.

  1. const olmayan isimli nesneler için çağrılacak overload
  2. const isimli nesneler için çağrılacak overload
  3. geçici nesneler için çağrılacak overload

1.türdeki nesneler için parametresi const olmayan referans türünden fonksiyonlar çağrılacaktır. İkinci ve üçüncü türdeki nesneler için ise uygun fonksiyonlar yazılmalı. İlk olarak geçici nesneler için çağrılacak fonksiyonları nasıl yazabileceğimize bakalım. Geçici nesneler için const referans parametreli fonksiyonlar çağrılamayacağı için derleyici sınıf içinde tanımlı uygun bir tür dönüşüm fonksiyonu olup olmadığına bakacaktır. Bu tür dönüşümü sonrasında uygun bir fonksiyon bulunursa bu fonksiyon çağrılacaktır. Geçici nesneler için bir tür dönüşümü yapılarak bir fonksiyon çağrısı gerçekleştirilebilir. Geçici nesnelerin dönüştürüleceği tür üzerinden orjinal nesneye erişebilmelidir. Bu amaçla yardımcı bir sınıf (proxy class) daha tanımlanmalıdır. Böyle bir sınıfı örnek yazı sınıfımız için aşağıdaki gibi tanımlayabiliriz.

struct TemporaryString
{
String *obj;
}

Tür dönüşüm fonksiyonu ise aşağıdaki gibi yazılabilir.

String::operator TemporaryString()
{
TemporaryString t;
t.obj = this;
return t;
}

TemporaryString sınıf türünden nesne gerçek String nesnesinin adresini tutmaktadır. Dönüşüm fonksiyonunu eklediğimizde String sınıfı aşağıdaki gibi olacaktır.

#include <iostream>
#include <cstring>
using namespace std;class String
{
struct TemporaryString
{
String *obj;
};
public:
String();
String(const char *str);
String(String& rhs);
String(TemporaryString rhs);
~String();
void print() const;
operator TemporaryString();
private:
char *m_p;
size_t m_len;
};
String::operator TemporaryString()
{
cout << __PRETTY_FUNCTION__ << endl;
TemporaryString t;
t.obj = this;
return t;
}
String::String()
{
m_len = 0;
m_p = NULL;
}
String::String(const char *str)
{
m_len = strlen(str);
m_p = new char[m_len + 1];
strcpy(m_p, str);
}
String::String(String& rhs)
{
cout << __PRETTY_FUNCTION__ << endl;
m_len = rhs.m_len;
m_p = new char[m_len];
for (int i = 0; i < m_len; ++i) {
m_p[i] = rhs.m_p[i];
}
}
String::String(TemporaryString rhs)
{
cout << __PRETTY_FUNCTION__ << endl;
m_p = rhs.obj->m_p;
m_len = rhs.obj->m_len;
rhs.obj->m_p = NULL;
}
String::~String()
{
delete[] m_p;
}
void String::print() const
{
cout << m_p << endl;
}

TemporaryString sınıfını String sınıfının içsel bir türü olarak tanımladık. Aşağıdaki test kodunu derleyip çalıştıralım.

int main()
{
String s = String("text");
s.print();
return 0;
}
String::operator String::TemporaryString()
String::String(String::TemporaryString)
text

Geçici nesne için String(String& rhs) fonksiyonu çağrılamayacağı için derleyici ilk olarak geçici nesne için TemporaryString türünden geçici bir nesne oluşturmuş ve bu geçici nesne ile sınıfın derin kopyalama değil taşıma yapan String(String::TemporaryString) fonksiyonunu çağırmıştır. Şimdi burada aklınıza şöyle bir soru gelebilir.

Bu yöntem derleyicinin kopyalama eliminasyonu yapmasını zorlaştırmasına karşın derleyicinin hiç optimizasyon yapamadığı ve geçici nesneler için derin kopyalama yaptığı atama işlemlerinde de kullanılabilmektedir. Ayrıca bu yöntemde oluşturulan geçici nesnelerin maliyetleri oldukça azdır. operator TemporaryString() fonksiyonu cağrıldığı nesnenin adresini tutan bir nesne oluşturmakta ve String(TemporaryString rhs) fonksiyonu ile kopyalama yerine
taşıma işlemi yapılmaktadır. Şimdi benzer işlemi atama operatör fonksiyonu için de yapalım. Atama operatör fonksiyonlarının bildirimleri ve tanımları aşağıdaki gibidir.

String& operator=(String& rhs);
String& operator=(TemporaryString rhs);
String& String::operator=(String& rhs)
{
cout << __PRETTY_FUNCTION__ << endl;
if (this != &rhs) {
delete [] m_p;
m_p = NULL;
m_len = rhs.m_len;
m_p = new char[m_len];
for (int i = 0; i < m_len; ++i) {
m_p[i] = rhs.m_p[i];
}
}
return *this;
}
String& String::operator=(TemporaryString rhs)
{
cout << __PRETTY_FUNCTION__ << endl;
delete [] m_p;
m_p = NULL;
m_p = rhs.obj->m_p; // nesnenin kaynağı transfer ediliyor
m_len = rhs.obj->m_len;
rhs.obj->m_p = NULL;
return *this;
}

Atama operatör fonksiyonlarını sınıfımıza ekledikten sonra aşağıdaki test kodunu derleyip çalıştıralım.

int main()
{
String s;
s = String("text");
s.print();
return 0;
}
String::operator String::TemporaryString()
String& String::operator=(String::TemporaryString)
text

Atama işlemi için sınıfın kopyalama yerine taşıma yapan operatör fonksiyonunun çağrıldığını görüyoruz. Bu aşamada String sınıfımız
const olmayan isimli nesneler ve geçici nesneler için gerekli fonksiyonları içermekte fakat isimli const nesneler için gerekli kopyalama fonksiyonlarını içermemektedir. const bir nesne üzerinden kopyalama yapmak istediğimizde aşağıdaki gibi bir hata mesajı üretilmektedir.

int main()
{
const String s1;
const String s2 = s1;
return 0;
}
error: passing ‘const String’ as ‘this’ argument of‘String::operator
String::TemporaryString()’ discards qualifiers

Bu durumda const nesneler için de uyugn bir tür tanımlamalı ve ilgili dönüşüm fonksiyonunu yazmalıyız. TemporaryString sınıfına benzer şekilde bir ConstantString sınıfı ve dönüşüm fonksiyonunu aşağıdaki gibi yazılabileceğini düşünebiliriz.

struct ConstantString
{
const String *obj;
};
String::operator ConstantString() const
{
ConstantString c;
c.obj = this;
return c;
}

Bu durumda const olan nesneler için ConstantString dönüşüm fonksiyonu çağrılacaktır fakat geçici nesneler için derleyici TemporaryString ile ConstantString dönüşüm fonksiyonları arasında bir tercih yapamayacak ve bir çift anlamlılık (ambiguity) hatası oluşacaktır. Geçici nesneler const olmamalarına karşın const bir fonksiyon olan operator ConstantString() bu nesneler için çağrılabilir. Bu durumu daha basit bir örnek üzerinden inceleyebiliriz.

#include <iostream>using namespace std;class Type1 {};
class Type2 {};
class Data
{
public:
operator Type1() const { cout << __PRETTY_FUNCTION__ << endl; }
operator Type2() { cout << __PRETTY_FUNCTION__ << endl; }
};
void foo(Type1) { cout << __PRETTY_FUNCTION__ << endl; }
void foo(Type2) { cout << __PRETTY_FUNCTION__ << endl; }
int main()
{
const Data d;
foo(d);
return 0;
}

Örnek kodu çalıştırdığımızda const tür dönüşüm fonksiyonunun çağrıldığını görüyoruz.

Data::operator Type1() const
void foo(Type1)

const olmayan Data nesnesi için ise derleyici çift anlamlılık hatası üretmektedir.

int main()
{
Data d;
foo(d);
return 0;
}

Hata mesajlarının bir bölümü aşağıdaki gibidir.

error: call of overloaded ‘foo(Data&)’ is ambiguous
note: candidates are:
note: void foo(Type1)
note: void foo(Type2)

const olan fonksiyonlar const olmayan nesneler için de çağrılabildiğinden farklı türlere dönüşümler sadece const yüklemesi ile çözülememektedir. Yukarıdaki örnek için Type1 ve Type2 türlerine dönüşüm derleyici açısından eşit derecede mümkün olması durumunda const yüklemesi hangi türe dönüşüm yapılacağını belirleyecektir. Type2 türünün Type1 sınıfından türemesi durumunda problem çözülecektir.

#include <iostream>using namespace std;class Type1 {};
class Type2 : Type1 {};
class Data
{
public:
operator Type1() const { cout << __PRETTY_FUNCTION__ << endl; }
operator Type2() { cout << __PRETTY_FUNCTION__ << endl; }
};
void foo(Type1) { cout << __PRETTY_FUNCTION__ << endl; }
void foo(Type2) { cout << __PRETTY_FUNCTION__ << endl; }
int main()
{
Data d;
foo(d);
const Data cd;
foo(cd);
return 0;
}
Data::operator Type2()
void foo(Type2)
Data::operator Type1() const
void foo(Type1)

Benzer şekilde bir ConstantString sınıfı yazacak ve TemporaryString sınıfını bu sınıftan türeteceğiz. Son durumda String sınıfımız aşağıdaki gibi olacaktır.

#include <iostream>
#include <cstring>
using namespace std;class String
{
struct ConstantString
{
String *obj;
};
struct TemporaryString : ConstantString
{
};
public:
String();
String(const char *str);
String(String& rhs);
String(TemporaryString rhs);
String(ConstantString rhs);
String& operator=(String& rhs);
String& operator=(TemporaryString rhs);
String& operator=(ConstantString rhs);
~String();
void print() const;
operator TemporaryString();
operator ConstantString() const;
private:
char *m_p;
size_t m_len;
};
String::operator TemporaryString()
{
TemporaryString t;
t.obj = this;
return t;
}
String::operator ConstantString() const
{
ConstantString c;
c.obj = const_cast<String*>(this);
return c;
}
String::String() {
m_len = 0;
m_p = NULL;
}
String::String(const char *str)
{
m_len = strlen(str);
m_p = new char[m_len + 1];
strcpy(m_p, str);
}
String::String(String& rhs)
{
cout << "Derin kopyalama" << endl;
m_len = rhs.m_len;
m_p = new char[m_len];
for (int i = 0; i < m_len; ++i) {
m_p[i] = rhs.m_p[i];
}
}
String::String(TemporaryString rhs)
{
cout << "Taşıma" << endl;
m_p = rhs.obj->m_p;
m_len = rhs.obj->m_len;
rhs.obj->m_p = NULL;
}
String::String(ConstantString rhs)
{
cout << "Derin kopyalama" << endl;
m_len = rhs.obj->m_len;
m_p = new char[m_len];
for (int i = 0; i < m_len; ++i) {
m_p[i] = rhs.obj->m_p[i];
}
}
String& String::operator=(String& rhs)
{
cout << "Derin kopyalama" << endl;
if (this != &rhs)
{
delete [] m_p;
m_p = NULL;
m_len = rhs.m_len;
m_p = new char[m_len];
for (int i = 0; i < m_len; ++i) {
m_p[i] = rhs.m_p[i];
}
}
return *this;
}
String& String::operator=(TemporaryString rhs)
{
cout << "Taşıma" << endl;
delete [] m_p;
m_p = NULL;
m_p = rhs.obj->m_p;
m_len = rhs.obj->m_len;
rhs.obj->m_p = NULL;
return *this;
}
String& String::operator=(ConstantString rhs)
{
cout << "Derin kopyalama" << endl;
delete [] m_p;
m_p = NULL;
m_len = rhs.obj->m_len;
m_p = new char[m_len];
for (int i = 0; i < m_len; ++i) {
m_p[i] = rhs.obj->m_p[i];
}
return *this;
}
String::~String()
{
delete[] m_p;
}
void String::print() const
{
cout << m_p << endl;
}

Şimdi isimli ve geçici nesnelere ilişkin aşağıdaki örnek kodu derleyip çalıştıralım.

String s = String("Mary had a little lamb");
s.print();
cout << "**************************" << endl;
String s1("Mary had a little lamb");
String s2 = s1;
s2.print();
cout << "**************************" << endl;
const String s3("Mary had a little lamb");
String s4 = s3;
s4.print();
cout << "**************************" << endl;
String s5;
s5 = String("Mary had a little lamb");
s5.print();
cout << "**************************" << endl;
String s6("Mary had a little lamb");
String s7;
s7 = s6;
s7.print();
cout << "**************************" << endl;
const String s8("Mary had a little lamb");
String s9;
s9 = s8;
s9.print();
Taşıma
Mary had a little lamb
**************************
Derin kopyalama
Mary had a little lamb
**************************
Derin kopyalama
Mary had a little lamb
**************************
Taşıma
Mary had a little lamb
**************************
Derin kopyalama
Mary had a little lamb
**************************
Derin kopyalama
Mary had a little lamb

Geçici nesnelerin kullanıldığı ilklendirme ve atama işlemlerinde derin kopyalama yerine taşıma işlemi yapıldığını görüyoruz.

Görüldüğü gibi bu yöntem oldukça fazla kodlama gerektirmekte ve kodun karmaşıklığını arttırmaktadır. Aslında buradaki amacımız geçici nesnelerin taşınmasının dil tarafından direkt olarak desteklenmemesi durumunda gerçeklenmesinin zorluğunu göstermek. C++11 ile beraber taşıma işlemi çok daha kolay ve anlaşılır bir biçimde yapılabilmektedir.

Kaynakların Ortak Paylaşımı

Bu yöntemde kopyalama işlemi sonucunda, mümkünse, kopyalanan nesnenin tuttuğu kaynak paylaşılmakta ancak gerektiğinde kaynaklar ayrılmaktadır. Kopyalama işleminde ilk olarak yüzeysel kopyalama yapılmakta, nesnelerden biri kaynak üzerinde bir değişiklik yapmak istediğinde ise derin kopyalama yapılmaktadır. Derin kopyalama yapılana kadar nesneler aynı kaynağı
göstermektedir. Bu yöntem İngilizce copy-on-write, lazy copy, implicit sharing veya shadowing kavramlarıyla ifade edilmektedir.

Bazı durumlarda bir nesnenin kopyası çıkarıldıktan sonra kopya nesne üzerinde bir değişiklik yapılmamaktadır. Örneğin bir fonksiyon değerle çağrılmış fakat fonksiyonun başında yapılan bir kontrol ile fonksiyon sonlandırılmış olabilir. Bu durumda daha en baştan gereksiz yere derin kopyalama yapılmış olacaktır.

void foo(Data d)
{
if (condition) {
return;
}
// d üzerinde işlem yapan kod
}

Bu yöntemde geçici nesneler diğer nesnelerden farklı olarak ele alınmamaktadır. Ancak gerekli olduğu durumlarda derin kopyalama yapılmakta, herhangi bir taşıma işlemi ise yapılmamaktadır. Ortak tutulan kaynakların yönetilebilmesi için kaynağa tutulan referans sayısı saklanmaktadır (reference counting). Her yüzeysel kopyalama işleminde kaynağın tuttuğu referans sayısı 1 arttırılmaktadır. Kaynağı tutan her nesnenin sonlandırılmasında ise kaynağın tuttuğu referans sayısı bir azaltılmakta nihayetinde referans sayısı 1'in altına düştüğünde kaynak serbest bırakılmaktadır. Kaynakların ortak paylaşılabilmesi için sınıfın tasarımının buna uygun olması gerekmektedir. Daha önce kullandığımız yazı
sınıfımızı hatırlayalım. Sınıfımızın bellekteki yerleşimini aşağıdaki gibi gösterebiliriz.

Yazı sınıfı, yazının bulunduğu alanın başlangıç adresini, uzunluğunu ayrıca yazının genişliğini tutmaktadır. Daha önceki incelemelerimizde örneği basitleştirmek için yazının genişliğini ihmal etmiştik. Tutulan dışsal kaynağın diğer nesneler tarafından da kullanılabilmesi için bir veri yapısı yani bir sınıfa daha ihtiyacımız olacaktır. Paylaşıma izin veren bir yazı sınıfının bellekteki
yerleşimini aşağıdaki gibi gösterebiliriz.

StringBuf sınıfı normalde String sınıfının yapması gereken bazı işlemleri yapacak ve kaynağa olan erişimleri takip edecektir. StringBuf sınıfı String sınıfı için yardımcı bir tür olarak tanımlandığından yalnız String sınıfı tarafından kullanılmalıdır. Bu durumda StringBuf sınıfı String sınıfının içsel bir türü olarak yazılabilir veya StringBuf sınıfı private bir başlık dosyasına yazılarak yalnız String sıfının kaynak kodu tarafından derleme işlemine dahil edilebilir. Biz incelemelerimizde karmaşıklığı arttırmamak için tüm kodları tek dosyada yazacağız. Şimdi adım adım StringBuf ve String sınıflarını oluşturalım.

StringBuf sınıfını aşağıdaki oluşturabiliriz.

struct StringBuf
{
StringBuf();
StringBuf(const StringBuf& rhs);
~StringBuf();
void reserve(size_t n);
void append(char c);
char *m_p;
size_t m_len;
size_t m_used;
unsigned int refs;
};

StringBuf sınıfı yazı için tutulan kaynağın başlangıç adresi, kapasitesi ve tutulan referans sayısı gibi bilgileri tutmakta ayrıca reserve ve append fonksiyonlarını bulundurmaktadır. reserve fonksiyonu ile yazı için ayrılmış alan dinamik olarak genişletilmekte, append ile yazının sonuna ekleme yapılabilmektedir. StringBuf sınıfına ilişkin fonksiyonları sırasıyla aşağıdaki gibi tanımlayabiliriz.

StringBuf::StringBuf() : m_p(0), m_len(0), m_used(0), refs(1) {}

Sınıfın parametresiz constructor fonksiyonu gerçekte bir alan tahsis etmemektedir. Daha sonra yapılacak ekleme işlemlerinde gerekli alan
tahsis edilecektir. Referans sayısının 1 yapıldığına dikkat ediniz.

StringBuf::StringBuf(const StringBuf& rhs)
{
reserve(rhs.m_len);
std::copy(rhs.m_p, rhs.m_p + rhs.m_used, m_p);
m_used = rhs.m_used;
}

Sınıfın copy constructor fonksiyonu reserve ile gerekli alanı tahsis etmekte ardından derin kopyalama yapmaktadır. Yazı için ayrılan alanın başlangıç adresi ve kapasitesi reserve içinde atanmaktadır.

void StringBuf::reserve(size_t n)
{
if (m_len >= n) {
return; //yazının tutulduğu bellek alanı daraltılamaz
}
size_t len;
char* p;
//kapasite değeri hesaplanıyor
len = std::max(m_len * 1.5, (double)n);
//yeni alan tahsis ediliyor
p = new char[len];
//eski kaynak bir yazı barındırıyorsa bu yazı kopyalananıyor
if (m_p) {
copy(m_p, m_p + m_used, p);
}
delete m_p; //eski kaynak geri veriliyor
m_p = p;
m_len = len;
}

reserve fonksiyonu kaynak için tutulan alanı genişletmek için kullanılmaktadır. Yazının genişliği kapasite değerine eriştiğinde bir
sonraki ekleme işleminden önce yeni bir alan tahsis edilmekte ve eski alanın içeriği kopyalanmaktadır. Kapasitenin azaltılmasına izin verilmemiştir. Fonksiyonun başındaki kontrol bu duruma ilişkindir. reserve fonksiyonu yeni kapasite değerini kendisine geçirilen argümanı kullanarak belirlemektedir. Kapasite arttırımı ancak anlamlı miktarda yapılmaktadır. Kapasite bir önceki değerin en az 1.5 katı olacak şekilde bir politika izlenmiştir. len değerinin belirlendiği satır bu duruma ilişindir.

StringBuf::~StringBuf() { delete m_p; }

destructor yazı için tutulan kaynağı geri vermektedir.

void StringBuf::append(char c)
{
reserve(m_used + 1);
m_p[m_used++] = c;
}

append ile yazının sonuna bir karakter eklenmektedir. İlk olarak reserve ile gerekli ise kaynak için kullanılan alan genişletilmekte sonrasında yeni karakter eklenmektedir.

Şimdi StringBuf sınıfını kullanacak olan String sınıfımıza geçebiliriz. String sınıfını aşağıdaki gibi oluşturulabilir.

class String
{
public:
String();
String(const String& rhs);
~String();
void print() const;
void append(char c);
void about2Modify();
void detach();
char *data();
private:
StringBuf *m_data;
};

String sınıfı yazı için tutulan kaynağı yöneten StringBuf türünden bir gösterici tutmaktadır. Bu sayede sadece bir adres kopyalamasıyla kaynağın paylaşılması mümkün olmaktadır. Karmaşıklığı arttırmamak için parametre olarak bir yazının başlangıç adresin alan constructor ve assignment operator
fonksiyonlarını göz ardı ediyoruz. StringBuf sınıfına yazı alan bir constructor fonksiyonu eklenebilir ayrıca copy-swap idiom’u kullanılarak bir assignment operator fonksiyonu yazılabilir.

Şimdi sırayla String sınıfının fonksiyonlarına bakalım.

String::String() : m_data(new StringBuf()) {}

String sınıfının default constructor fonksiyonu StringBuf türünden bir alan oluştarak başlangıç adresini saklamaktadır.

String::String(const String& rhs)
{
m_data = rhs.m_data;
++m_data->refs;
}

Sınıfın copy constructor fonksiyonu yüzeysel kopyalama yaparak kopyalanan nesnenin tuttuğu kaynağı paylaşmakta ve kaynağın referans sayısını 1 arttırmaktadır.

String::~String()
{
if (--m_data->refs < 1) {
delete m_data;
}
}

Sınıfın destructor fonksiyonu kaynağın referans sayısını 1 azaltmakta ve kaynağı gösteren başka bir nesne yok ise kaynağı geri vermektedir.

void String::detach()
{
StringBuf *data = new StringBuf(*m_data);
--m_data->refs;
m_data = data;
}

detach fonksiyonu derin kopyalama yaparak sınıfın tuttuğu kaynağın bir kopyasını çıkartmakta ve bıraktığı kaynağın referans sayısını 1 eksiltmektedir.

void String::about2Modify()
{
if (m_data->refs > 1) {
detach();
}
}

String sınıfı tuttuğu kaynak üzerinde değişikliğe sebep olacak bir işlem yapmadan önce about2Modify fonksiyonunu çağırmalıdır. Bu fonksiyon yardımcı (helper) bir fonksiyon olarak yazılmıştır. about2Modify fonksiyonu kaynak başka nesneler tarafından paylaşılıyor ise detach fonksiyonunu çağırarak kaynağı ayırmaktadır.

void String::append(char c)
{
about2Modify();
m_data->append(c);
}

append ile yazının sonunca bir karakter eklenmektedir. append fonksiyonu ilk olarak about2Modify fonksiyonunu çağırarak kaynak paylaşılıyor ise kaynağı ayırmaktadır. Ardından StringBuf sınıfının append fonksiyonunu çağırmaktadır.

char *String::data()
{
return m_data->m_p;
}

data fonksiyonu yazının başlangıç adresini dönmektedir. Şimdi yazdığımız sınıfları tek bir dosyada toplayarak bir test yapabiliriz. Test kodu ve yazı sınıflarımız son durumda aşağıdaki gibi olacaktır.

#include <iostream>
#include <algorithm>
#include <cstdio>
using namespace std;class StringBuf
{
public:
StringBuf();
StringBuf(const StringBuf& rhs);
~StringBuf();
void reserve(size_t n);
void append(char c);
char *m_p;
size_t m_len;
size_t m_used;
unsigned int refs;
};
StringBuf::StringBuf() : m_p(0), m_len(0), m_used(0), refs(1) {}StringBuf::StringBuf(const StringBuf& rhs)
{
reserve(rhs.m_len);
copy(rhs.m_p, rhs.m_p + rhs.m_used, m_p);
m_used = rhs.m_used;
}
StringBuf::~StringBuf() { delete m_p; }void StringBuf::reserve(size_t n)
{
if (m_len >= n) return;
size_t len;
char* p;
len = std::max(m_len * 1.5, (double)n);
p = new char[len];
if (m_p) {
copy(m_p, m_p + m_used, p);
}
delete m_p;
m_p = p;
m_len = len;
}
void StringBuf::append(char c)
{
reserve(m_used + 1);
m_p[m_used++] = c;
}
class String
{
public:
String();
String(const String& rhs);
~String();
void print() const;
void append(char c);
void about2Modify();
void detach();
char *data();
private:
StringBuf *m_data;
};
String::String() : m_data(new StringBuf()) {}String::String(const String& rhs)
{
m_data = rhs.m_data;
++m_data->refs;
}
String::~String()
{
if (--m_data->refs < 1) {
delete m_data;
}
}
char *String::data()
{
return m_data->m_p;
}
void String::detach()
{
StringBuf *data = new StringBuf(*m_data);
--m_data->refs;
m_data = data;
}
void String::about2Modify()
{
if (m_data->refs > 1) {
detach();
}
}
void String::append(char c)
{
about2Modify();
m_data->append(c);
}
void String::print() const
{
cout << m_data->m_p << endl;
}
int main()
{
String s1;
s1.append('M');
s1.append('a');
s1.append('r');
s1.append('y');
cout << "s1 için yazının başlangıç adresi: "
<< static_cast<void*>(s1.data()) << endl;
s1.print();
String s2(s1);
cout << "s2 için yazının başlangıç adresi: "
<< static_cast<void*>(s2.data()) << endl;
s2.print();
return 0;
}

Kodu derleyip çalıştırdığımızda aşağıdaki gibi bir sonuç üretmektedir.

s1 için yazının başlangıç adresi: 0x55f1e61dfec0
Mary
s2 için yazının başlangıç adresi: 0x55f1e61dfec0
Mary

s2 nesnesi, s1 nesnesinden kopyalanarak oluşturulmakta ve her iki nesne de aynı kaynağı göstermektedir. s2 nesnesi oluşturulurken derin kopyalama yapılmamaktadır. Şimdi s1 nesnesi üzerinde bir değişiklik yapıp tekrardan nesnelerin durumuna bakalım. main fonksiyonunun son hali aşağıdaki gibidir.

int main()
{
String s1;
s1.append('M');
s1.append('a');
s1.append('r');
s1.append('y');
cout << "s1 için yazının başlangıç adresi: "
<< static_cast<void*>(s1.data()) << endl;
s1.print();
String s2(s1);
cout << "s2 için yazının başlangıç adresi: "
<< static_cast<void*>(s2.data()) << endl;
s2.print();
cout << "s1 nesnesi değiştiriliyor" << endl;
s1.append('X');
cout << "s1 için yazının başlangıç adresi: "
<< static_cast<void*>(s1.data()) << endl;
s1.print();
cout << "s2 için yazının başlangıç adresi: "
<< static_cast<void*>(s2.data()) << endl;
s2.print();
return 0;
}

s1 nesnesi üzerinde değişiklik yapmak istediğimizde tuttuğu kaynağı bırakarak başka bir kaynağı edindiğini görüyoruz.

s1 için yazının başlangıç adresi: 0x55fcea398ec0
Mary
s2 için yazının başlangıç adresi: 0x55fcea398ec0
Mary
s1 nesnesi değiştiriliyor
s1 için yazının başlangıç adresi: 0x55fcea399320
MaryX
s2 için yazının başlangıç adresi: 0x55fcea398ec0
Mary

String sınıfı bu haliyle kullandığı kaynağın paylaşımına izin vermektedir. Kaynak üzerinde bir değişiklik yapılmak istendiğinde kaynak başka bir nesne ya da nesneler tarafından paylaşılıyor ise yeni bir kaynak oluşturulmakta ve bu sayede gereksiz kopyalamaların önüne geçilmektedir. Fakat String sınıfına operator[] fonksiyonunu eklediğimizde yazdığımız kod doğru çalışmayacaktır. Yazı sınıfları herhangi bir karaktere erişmek için operator[] fonksiyonları bulundurmaktadır. String sınıfına aşağıdaki gibi fonksiyonları ekleyelim.

char& operator[] (size_t pos);
const char& operator[] (size_t pos) const;

operator[] fonksiyonlarını aşağıdaki gibi tanımlayabiliriz.

char& String::operator[] (size_t pos)
{
return m_data->m_p[pos];
}
const char& String::operator[] (size_t pos) const
{
return m_data->m_p[pos];
}

Fakat const olmayan referans dönen operator[] fonksiyonun kullanımıyla ilgili bir problem bulunmaktadır. String sınıfına operator[] fonksiyonlarını ekledikten sonra aşağıdaki örneği derleyip çalıştıralım.

int main()
{
String s1;
s1.append('M');
s1.append('a');
s1.append('r');
s1.append('y');
char& cref = s1[0]; String s2(s1);
s2.print();
cref = 'X';
s2.print();
return 0;
}

Örneği çalıştırdığımızda s2 nesnesinin tuttuğu yazının dışarıdan değiştirildiğini görüyoruz.

Mary
Xary

s1 nesnesi kopyalanmadan önce tuttuğu yazıya bir referans alınmış sonrasında kopyalanmıştır. s2 nesnesi kopyalama işleminde yeni bir
kaynak edinmediği için daha önce elde edilen referans ile içeriği değiştirilmiştir. const referanslar üzerinden sınıf üzerinde bir değişiklik yapılamadığından const operator[] fonksiyonunu değiştirmeyeceğiz fakat char& dönen operatör fonksiyonunu yeniden yazmalıyız. Bu örnekte referans alma işlemi kopyalamadan önce yapılmıştır. Referans alma işlemi kopyalamadan sonra da yapılabilirdi. Bu durumda bir nesnenin tuttuğu yazıya bir referans alınıyor ve nesne ortak bir kaynak kullanıyorsa bu durumda kaynağını ayırmalı ve tuttuğu kaynağı paylaşılamaz olarak işaretlemelidir. Bu amaçla StringBuf sınıfına kaynağın paylaşılabilir olup olmadığını gösteren bir boolean değişken daha ekleyeceğiz. Bu durumda sınıfın constructor fonksiyonunda bu değer başlangıçta true yapılmalıdır.

StringBuf::StringBuf() : m_p(0)
, m_len(0)
, m_used(0)
, refs(1)
, isShareable(true) {}

String sınıfının copy constructor fonksiyonuda artık kaynağı direkt paylaşmak yerine kaynağın paylaşılabilir olup olmadığına göre karar vermelidir. Kopyalanan nesnenin kaynağı paylaşılamıyor ise kaynak kopyalanmalıdır.

String::String(const String& rhs)
{
if (rhs.m_data->isShareable) {
m_data = rhs.m_data;
++m_data->refs;
} else {
m_data = new StringBuf(*rhs.m_data); //derin kopyalama
}
}

Son olarak operator[] fonksiyonu da aşağıdaki gibi olmalıdır.

char& String::operator[] (size_t pos)
{
m_data->isShareable = false;
about2Modify();
return m_data->m_p[pos];
}

Son durumda yazı sınıfımız aşağıdaki gibi olacaktır. Aynı testi yeniden yapalım.

#include <iostream>
#include <algorithm>
#include <cstdio>
using namespace std;class StringBuf
{
public:
StringBuf();
StringBuf(const StringBuf& rhs);
~StringBuf();
void reserve(size_t n);
void append(char c);
char *m_p;
size_t m_len;
size_t m_used;
unsigned int refs;
bool isShareable;
};
StringBuf::StringBuf() : m_p(0)
, m_len(0)
, m_used(0)
, refs(1)
, isShareable(true) {}
StringBuf::StringBuf(const StringBuf& rhs)
{
reserve(rhs.m_len);
copy(rhs.m_p, rhs.m_p + rhs.m_used, m_p);
m_used = rhs.m_used;
}
StringBuf::~StringBuf() { delete m_p; }void StringBuf::reserve(size_t n)
{
if (m_len >= n) return;
size_t len;
char* p;
len = std::max(m_len * 1.5, (double)n);
p = new char[len];
if (m_p) {
copy(m_p, m_p + m_used, p);
}
delete m_p;
m_p = p;
m_len = len;
}
void StringBuf::append(char c)
{
reserve(m_used + 1);
m_p[m_used++] = c;
}
class String
{
public:
String();
String(const String& rhs);
~String();
void print() const;
void append(char c);
void about2Modify();
void detach();
char* data();
char& operator[] (size_t pos);
const char& operator[] (size_t pos) const;
private:
StringBuf *m_data;
};
String::String() : m_data(new StringBuf()) {}String::String(const String& rhs)
{
if (rhs.m_data->isShareable) {
m_data = rhs.m_data;
++m_data->refs;
} else {
m_data = new StringBuf(*rhs.m_data);
}
}
String::~String()
{
if (--m_data->refs < 1) {
delete m_data;
}
}
char *String::data()
{
return m_data->m_p;
}
void String::detach()
{
StringBuf *data = new StringBuf(*m_data);
--m_data->refs;
m_data = data;
}
void String::about2Modify()
{
if (m_data->refs > 1) {
detach();
}
}
void String::append(char c)
{
about2Modify();
m_data->append(c);
}
void String::print() const
{
cout << m_data->m_p << endl;
}
char& String::operator[] (size_t pos)
{
m_data->isShareable = false;
about2Modify();
return m_data->m_p[pos];
}
const char& String::operator[] (size_t pos) const
{
return m_data->m_p[pos];
}
int main()
{
String s1;
s1.append('M');
s1.append('a');
s1.append('r');
s1.append('y');
char& cref = s1[0];
String s2(s1);
s2.print();
cref = 'X';
s2.print();
return 0;
}
Mary
Mary

Kodu çalıştırdığımızda s2 nesnesinin artık dışarıdan gizlice değiştirilmediğini görüyoruz. Kaynakların ortak paylaşılmasıyla ilgili dikkate alınması gereken bir diğer konu ise birden çok thread (multithreading) ile çalışılıp
çalışılmayacağıyla ilgilidir. Yukarıdaki örnek tek bir thread ile doğru çalışmasına rağmen birden çok thread’li çalışma modelinde doğru
çalışması garanti edilemez. Son olarak yazı sınıfımınızın çoklu thread’li modelde neden güvenilmez olduğunu ve nasıl güvenilir hale
getirilebileceğini inceleyelim.

Her bir proses, işletim sistemi tarafından, tek bir thread (main thread) ile başlatılır. Prosesler daha sonra yeni thread’ler başlatabilir. Linux ve Windows gibi preemptive işletim sistemlerinde her bir thread belirli bir çalışma zamanına (timeslice) sahiptir. Belirli aralıklarla bir zamanlayıcı kesmesi (timer interrupt) oluşmakta ve çalışan thread durdurularak akış işletim sisteminin çizelgeleyicisine (operating system scheduler) geçmektedir. Çizelgeleyici tarafından bir sonraki çalışacak thread belirlenmektedir. Preemptive
sistemlerde bir thread’in ne zaman durdurulacağı kontrol edilemediğinden thread’lerin ortak kullandıkları kaynaklara erişim senkronize edilmelidir.
StringBuf içindeki int türden refs değişkeni kaynağa tutulan referans sayısını göstermekteydi. Kaynağa tutulan referans sayısı — refs ve ++refs işlemleri ile güncellenmektedir. Bu işlemlerin birden çok thread tarafından yapılması durumunda yazı sınıfı güvenilmez olacaktır. Bu durumu daha iyi anlayabilmek için önce çoklu thread’li çalışma modelinde neden problem çıktığına kısaca bakalım. Problemin nedenlerini 2 başlık altında toplayabiliriz.

  1. Tüm aritmetik işlemlerin atomik olmaması

Bir işlemin atomik olması işlemin bölünmeden tamamlanması anlamına gelmektedir. Bu durumu aşağıdaki basit bir örnek üzerinden inceleyelim.

#include <iostream>int counter;int main()
{
++counter;
return 0;
}

Derleyicinin ++counter için ürettiği 32 bitlik sembolik makina kodu aşağıdaki gibidir.

movl counter, %eax
addl $1, %eax
movl %eax, counter

++ operatörüne ilişkin işlem 3 adımda gerçekleştirilmiştir. İlk olarak counter değeri eax yazmacına çekilmiş, ardından eax yazmacına 1 eklenmiş ve bu değer belleğe aktarılmıştır. Bu işlem sıralaması İngilizce read-modify-write (RMW) olarak ifade edilmektedir. Bu işlemlerin kesilmeden ard arda işletilmesi garanti değildir. Başka bir örnek üzerinden bu duruma bakalım.

get_unique_int fonksiyonunun her çağrıldığında ardışıl yeni bir değer dönmesi beklenmektedir.

int get_unique_int()
{
return ++counter;
}

Sistemde tek bir işlemci bulunsa dahi get_unique_int fonksiyonunun farklı thread’lerden çağrıldığında farklı değerler dönmesi garanti değildir. Örneğin bir thread counter değerini yazmaca çekip bu değeri 1 arttırabilir fakat counter değişkeninin yeni değerini yazamadan yani 3. adımdan önce durdurulabilir ve yeni bir thread başlatılabilir. counter değeri henüz güncellenmediği için bu durumda her iki thread’de aynı counter değeri ile işleme başlamış olacak ve aynı sonucu dönecektir. Ayrıca birden çok işlemciye sahip sistemlerde
thread’ler aynı anda paralel olarak çalıştırılabilmektedir. Bu durumda yine get_unique_int fonksiyonu beklendiği gibi çalışmayacaktır. Aslında Intel mimarisinde arttırma ve eksiltme işlemleri için bellek operandı alan özel makina komutları bulunmaktadır. Örneğin ++counter işlemi aşağıdaki gibi tek bir makina komutu ile de yapılabilir.

incl counter

Tek işlemcili bir sistemde bu işlem atomik olarak yapılmasına karşın çok işlemcili bir sistemde yine atomik değildir. inc makina komutu içsel olarak bellekten counter değerini okumakta ve yeni değeri belleğe yazmaktadır. inc makina komutu bu işlemler sırasında kesintiye uğramamasına rağmen bellekten okuma veya yazma yaparken aynı anda diğer bir işlemci de aynı bellek bölgesine erişebilmektedir. Bu durumda yine yapılan işlem atomik olmayacaktır. Intel mimarisinde bu amaçla lock isimli bir önek bulunmaktadır.
Bu öneke sahip makina komutu işletilirken bellek yolu (memory bus) geçici olarak kilitlenmekte yani aynı bellek bölgesine eş zamanlı erişim engellenmektedir. Son olarak bir değişkenin değerinin eksiltilmesinden sonra test edilmesi durumuna bakalım. Benzer bir kullanım yazı sınıfımızın
destructor fonksiyonu içinde de bulunmakta. Aşağıdaki örnek üzerinden bu durumu inceleyelim.

#include <iostream>int count;void foo()
{
if (--count == 0) {
std::cout << "count is zero" << std::endl;
}
}
int main()
{
foo();
return 0;
}

if ( — count == 0) için derleyicinin oluşturduğu sembolik makina kodlarının bir kısmı aşağıdaki gibidir.

movl count, %eax
subl $1, %eax
movl %eax, count
movl count, %eax
testl %eax, %eax

İlk 3 makine komutu — count işlemine aittir. Sonrasında count değeri tekrardan eax yazmacına çekilmiş ve test makina komutu ile 0
değerine eşit olup olmadığı kontrol edilmiştir. counter değerinin 2 olduğunu ve foo fonksiyonunun 2 farklı thread’den çağrıldığını düşünelim. Bu durumda aşağıdaki senaryolar olabilir.

  1. Konsola herhangi bir mesaj basılmaz

İlk tread count değerini yazmaca çektikten sonra eksiltir fakat yeni değeri yani 1 değerini belleğe yazamadan ilk tread durdurulur ve 2. thread başlatılır. count değeri hala 2 iken 2. thread başlatılır. 2. thread count değerini 1 eksiltip count değerini günceller ve 0 değerine eşit olup olmadığını kontrol eder. count değeri 1 olduğu için şart sağlanmaz. İlk thread tekrar çalıştırıldığında yazmaç içindeki 1 değerini belleğe yazar. Bu durumda belleğe 2 defa 1 değeri yazılmış olacaktır. count değeri 0'a eşit olmadığından ilk thread için de şart
sağlanmaz ve konsola bir mesaj basılmaz.

2. Konsola count is zero mesajı 1 kez basılır

İlk thread herhangi bir kesintiye uğramadan count değerini 1 eksiltir ve 0'a eşit olup olmadığını kontrol eder. count 1 olduğu için şart sağlanmaz. 2. thread başlatıldığında artık count değeri 1'dir. 2. thread count değerini eksiltip 0 kontrolü yapar, şart sağlandığı için konsola mesaj basılır.

3.Konsola count is zero mesajı 2 kez basılır

İlk thread count değerini 1 eksilttikten sonra yani ilk 3 makina komutu işletildikten sonra durdurulur. 2. thread başlatıldığında count değeri 1'dir. 2. thread count değerini eksiltip 0 kontrolü yapar, şart sağlandığı için konsola mesaj basılır. İlk thread tekrar çalıştırıldığında 4. makina komutundan devam eder. count değeri yeniden bellekten yazmaça çekilir. count değeri en son 2. thread tarafından 0 yapıldığı için şart sağlanır ve ekrana bir mesaj daha basılır. — count işleminin ve sonrasında if ile yapılan kontrolün atomik olmaması yukarıdaki durumlara neden olabilmektedir. Yazı sınıfımızın
destructor fonksiyonu için aynı durumu düşünürsek m_data delete edilmeyebilir veya 2 kez delete edilmeye çalışılabilir ki bu durumda
uygulama sonlandırılacaktır.

String::~String()
{
if (--m_data->refs < 1) {
delete m_data;
}
}

2. Test işlemleri (check then act)

Aşağıdaki örnek üzerinden bu durumu inceleyelim.

#include <iostream>int count;void doSomething();void foo()
{
if (count > 0) {
try {
doSomething();
} catch(int e) {
return;
}
--count;
}
}
int main()
{
foo();
return 0;
}

foo içinde count değeri 0'dan büyük ise doSomething çağrılmakta ve count değeri 1 eksiltilmektedir. doSomething’in başarısız olması durumunda count değeri eksiltilmeyecektir. Bu durumda yalnız count üzerinde yapılan işlemlerin atomik olması yeterli değildir. foo içindeki tüm işlemler atomik olarak yapılmalıdır. Örneğin count 1 iken bir thread doSomething fonksiyonunu çalıştırırken, henüz count değerini eksiltmediği için, bir başka thread’de yine doSomething fonksiyonunu çağırabilecektir. Bu problemleri gidermek için bir senkronizasyon mekanizmasına ihtiyaç bulunmaktadır.
Ortak erişilen kaynaklara erişimin senkronize edilmesi için genel olarak iki yöntem kullanılmaktadır.

.Kilit (lock, mutex): Bir kod bloğu bir kilit tarafından korunabilir yani ancak kilidi elinde bulunduran thread tarafından çalıştırılabilir. Her bir thread bu kod bloğunu çalıştırmadan önce kilidin durumunu kontrol eder. Ancak kilit serbest durumda ise kod bloğu işletilir. Kilit serbest durumda değilse thread bloklanır. İşi biten thread kilidi tekrar serbest duruma getirmelidir.

. Kilitsiz senkronizasyon (lockless synchronization): Senkronizasyon için bir kilit yerine işlemcinin sağladığı atomik işlem yapabilme özelliği kullanılmaktadır. Bu modelde thread’ler bloklanmaz.

Kilitsiz senkronizasyon yöntemi kilitli senkronizasyona göre daha fazla algoritmik karmaşıklık barındırmasına karşın thread’ler bloklanmadığından daha etkin sonuç üretebilmektedir. Yazı sınıfımızı çoklu thread için güvenilir hale getirmeden önce basit bir örnek üzerinden 2 yöntemi de inceleyelim. C++11 ile birlikte dile ve standart kütüphaneye thread desteği eklenmiştir.

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
#define THREAD_COUNT 5using namespace std;int count;void foo()
{
for (int i = 0; i < 10000; ++i) {
++count;
}
}
int main()
{
vector<thread> threads;
for(int i = 0; i < THREAD_COUNT; ++i) {
threads.push_back(std::thread(foo));
}
for(auto& thread : threads) {
thread.join();
}
cout << "count: " << count << endl;
return 0;
}

Örneği aşağıdaki gibi derleyebilirsiniz.

$ g++ -otest test.cpp -std=c++11 -pthread

main içerisinde 5 adet thread oluşturulmakta ve her bir thread foo fonksiyonu ile çalışmaya başlamaktadır. Her thread count değerini
10000 defa arttırmalı ve thread’ler sonlandığında count değeri 50000 olmalı. Fakat daha önce bahsettiğimiz nedenlerden dolayı uygulamayı çalıştırdığımızda aşağıdaki gibi tahmin edilemez rastgele sonuçlar almaktayız.

$ ./test
count: 26379
$ ./test
count: 28573
$ ./test
count: 22221

İlk olarak bir kilit ile thread’leri senkronize edelim. Bu amaçla global bir mutex nesnesi kullanacağız. foo fonksiyonunu aşağıdaki gibi yazabiliriz.

mutex g_mutex;void foo()
{
for (int i = 0; i < 10000; ++i) {
g_mutex.lock();
++count;
g_mutex.unlock();
}
}

Kodu yeniden derleyip çalıştırdığımızda 50000 değerini ürettiğini görmekteyiz.

count üzerinde yapılan arttırma işlemi atomik olarak aşağıdaki gibi yapılabilir. Yalnızca count değişkeninin türünü int yerine aşağıdaki
gibi değiştirmek yeterlidir.

atomic<int> count;

Standart kütüphane atomik işlemler için atomic isimli bir sınıf şablonu barındırmaktadır. Kodu bu şekilde yeniden derleyip çalıştırdığımızda 50000 değerini ürettiğini görüyoruz.

Not: Atomik türden count için ++operator fonksiyonu yaklaşık aşağıdaki gibi sembolik makina kodları üretmektedir. Arttırma işlemi 3 makina komut ile yapılmak yerine lock önekine sahip tek bir makina komutu ile yapılmaktadır.
movl $count, %edx
movl $1, %eax
lock xaddl %eax, (%edx)

Örnek yazı sınıfımızı birden çok thread ile kullandığımızda karşılaşacağımız problemler buraya kadar incelediğimiz örneklerden farklı değildir. Burada amacımız yazı sınıfını tamamen çoklu thread ile çalışmaya güvenli (thread safe) hale getirmek değil. Bir sınıf tamamen thread güvenli olacak şekilde yazılabilmesine karşın bir çok sınıf performans gerekçesiyle thread güvenli olarak yazılmamaktadır. Bir sınıf yalnız tek bir thread ile kullanılabilir. Sınıfın kendisini thread güvenli şekilde yazmak tek thread ile kullanıldığında performans kaybına neden olacaktır. Bu durumda sınıf çoklu thread ile kullanılacaksa thread senkronizasyonu sınıfı kullanan kodlar tarafından sağlanmalıdır. Yazı sınıfımızı da bu şekilde yazmak istiyoruz fakat sınıfımız ortak kaynakları gizlice paylaşabildiği için belli seviyedeki senkronizasyonu sınıfın kendisi sağlamalıdır. Yazı sınıfını kullanan kodlar hangi nesnelerin ortak kaynakları tuttuklarını bilemez. Örneğin 2 farklı yazı nesnesi aynı kaynağı paylaşıyor olabilir. Yazı sınıfını kullanan kodlar tarafından
farklı thread’lerde bu nesneler üzerinde işlem yapılması bir senkronizasyonu gerektirmemektedir. Fakat içsel olarak aynı kaynağı paylaştıklarından senkronizasyon yazı sınıfı tarafından sağlanmalıdır. Yazı sınıfımızda ortak kaynaklar int türünden refs değişkeni kullanılarak yönetilmektedir. refs değişkeninin durumunu değiştiren kodlara erişim senkronize edilmelidir. Daha önce incelediğimiz çoklu thread’li örnekte olduğu gibi bir kilit kullanabilir veya refs üzerinde yapılan işlemlerin atomik olarak yapılmasını sağlayabiliriz. Şimdi bu yöntemlere bakalım.

Kilitli Senkronizasyon

StringBuf içinde bir mutex tutabilir ve refs üzerinde işlem yapan kodlar bu mutex ile senkronize edebilebilir.

class StringBuf
{
public:
...
std::mutex m_mutex;
};
String::String(const String& rhs)
{
if (rhs.m_data->isShareable) {
m_data = rhs.m_data;
m_data->m_mutex.lock();
++m_data->refs;
m_data->m_mutex.unlock();
} else {
m_data = new StringBuf(*rhs.m_data);
}
}
String::~String()
{
m_data->m_mutex.lock();
if (--m_data->refs < 1) {
delete m_data;
}
m_data->m_mutex.unlock();
}
void String::about2Modify()
{
m_data->m_mutex.lock();
if (m_data->refs > 1) {
detach();
}
m_data->m_mutex.unlock();
}

Kod üzerinden başka bir değişiklik yapmaya gerek yoktur.

Kilitsiz Senkronizasyon

Kilitsiz senkronizasyonda refs üzerinde yapılan işlemlerin atomik olması gerektiğinden daha önce unsigned int olarak tanımlanan refs değişkeni aşağıdaki gibi değiştirilmeli.

#include <atomic>class StringBuf
{
...
std::atomic<unsigned int> refs;
};

Artık refs üzerindeki işlemler atomik olarak yapılacağı için bir kilit kullanmaya gerek kalmayacaktır. Fakat daha önce test işlemlerinden sonra yapılan işlemlerde (check and act) sadece test ve aritmetik işlemlerin atomik olmasının yeterli olmadığı bir durumu incelemiştik. Benzer durum about2Modify fonksiyonu için de geçerlidir.

void String::detach()
{
StringBuf *data = new StringBuf(*m_data);
--m_data->refs; // refs değerinin değiştirilmesi
m_data = data;
}
void String::about2Modify()
{
if (m_data->refs > 1) { // refs değerinin kontrol edilmesi
detach();
}
}

refs üzerinde yapılan işlemler atomik olmasına karşın about2Modify içindeki kontrol işleminden sonra detach içindeki — m_data->refs işlemine kadar thread durdurulabilir ve yeni bir thread başlatılabilir. Örneğin refs değeri 2 iken, yani 2 nesne ortak bir kaynağı kullanıyor iken, about2Modify içindeki kontrolü geçen bir thread henüz refs değerini 1 eksiltmeden durdurulursa başka bir thread yine detach fonksiyonunu çağırabilir. Bu durumda refs değeri 0 olacak ve hiçbir nesne ortak kaynağa bir referans tutmayacaktır. Bu
durumda ortak kaynak sisteme geri verilemeyecek yani bellek sızıntısı (memory leak) oluşacaktır. Bu sebeple detach içine aşağıdaki
gibi bir kontrol eklenmelidir.

void String::detach()
{
StringBuf *data = new StringBuf(*m_data);
--m_data->refs;
// Birden fazla thread detach fonksiyonunu çağırmış olabilir
if (m_data->refs < 1) {
delete data;
return;
}
m_data = data;
}

refs 1'den küçük ise nesne elinde tuttuğu kaynağı bırakmayacak ve yeni oluşturulan alanı geri verecektir.

Görüldüğü gibi kaynakların ortak kullanılmasında dikkat edilmesi gereken hataya açık bir çok nokta bulunmaktadır. Bu sebeple, C++11 ile dile sağ taraf referanslarının eklenmesinden sonra, std::string sınıfında kaynakların ortak paylaşımından vazgeçilmiştir. Buna karşın bu yöntem hala diğer bazı kütüphanelerde kullanıma sahiptir. Örneğin Qt framework’ündeki sınıflarda kaynakların ortak paylaşımı yoğun bir kullanıma sahiptir.

--

--